Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: smtp for password reset #4630

Merged
merged 19 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ExperimentComparePage,
experimentsLoader,
ExperimentsPage,
ForgotPasswordPage,
homeLoader,
LoginPage,
ModelPage,
Expand All @@ -36,6 +37,7 @@ import {
ProjectsRoot,
resetPasswordLoader,
ResetPasswordPage,
ResetPasswordWithTokenPage,
SettingsPage,
TracePage,
TracingRoot,
Expand All @@ -50,6 +52,11 @@ const router = createBrowserRouter(
element={<ResetPasswordPage />}
loader={resetPasswordLoader}
/>
<Route
path="/reset-password-with-token"
element={<ResetPasswordWithTokenPage />}
/>
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route element={<AuthenticatedRoot />} loader={authenticatedRootLoader}>
<Route element={<Layout />}>
<Route
Expand Down
95 changes: 95 additions & 0 deletions app/src/pages/auth/ForgotPasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React, { useCallback, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { css } from "@emotion/react";

import { Alert, Button, Form, TextField, View } from "@arizeai/components";

type InitiatePasswordResetFormParams = {
email: string;
};

export function ForgotPasswordForm() {
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const onSubmit = useCallback(
async (params: InitiatePasswordResetFormParams) => {
setMessage(null);
setError(null);
setIsLoading(true);
try {
const response = await fetch("/auth/password-reset-email", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
if (!response.ok) {
setError("Failed attempt");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a better error message

return;
}
} catch (error) {
setError("Failed attempt");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs a better error message

return;
} finally {
setIsLoading(() => false);
}
setMessage(
"A link to reset your password has been sent. Check your email for details."
);
Comment on lines +38 to +40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can do the follow-up but this typically swaps out the UI

},
[setMessage, setError]
);
const { control, handleSubmit } = useForm<InitiatePasswordResetFormParams>({
RogerHYang marked this conversation as resolved.
Show resolved Hide resolved
defaultValues: { email: "" },
});
return (
<>
{message ? (
<View paddingBottom="size-100">
<Alert variant="success">{message}</Alert>
</View>
) : null}
{error ? (
<View paddingBottom="size-100">
<Alert variant="danger">{error}</Alert>
</View>
) : null}
<Form>
<Controller
name="email"
control={control}
render={({ field: { onChange, value } }) => (
<TextField
label="Email"
name="email"
isRequired
type="email"
onChange={onChange}
value={value}
placeholder="your email address"
/>
)}
/>
<div
css={css`
margin-top: var(--ac-global-dimension-size-400);
margin-bottom: var(--ac-global-dimension-size-50);
button {
width: 100%;
}
`}
>
<Button
variant="primary"
loading={isLoading}
onClick={handleSubmit(onSubmit)}
>
Submit
</Button>
</div>
</Form>
</>
);
}
20 changes: 20 additions & 0 deletions app/src/pages/auth/ForgotPasswordPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from "react";

import { Flex, View } from "@arizeai/components";

import { AuthLayout } from "./AuthLayout";
import { ForgotPasswordForm } from "./ForgotPasswordForm";
import { PhoenixLogo } from "./PhoenixLogo";

export function ForgotPasswordPage() {
return (
<AuthLayout>
<Flex direction="column" gap="size-200" alignItems="center">
<View paddingBottom="size-200">
<PhoenixLogo />
</View>
</Flex>
<ForgotPasswordForm />
</AuthLayout>
);
}
2 changes: 2 additions & 0 deletions app/src/pages/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { css } from "@emotion/react";

import { Alert, Button, Form, TextField, View } from "@arizeai/components";

import { Link } from "@phoenix/components";
import { getReturnUrl } from "@phoenix/utils/routingUtils";

type LoginFormParams = {
Expand Down Expand Up @@ -105,6 +106,7 @@ export function LoginForm() {
>
Login
</Button>
<Link to={"/forgot-password"}>Forgot password?</Link>
</div>
</Form>
</>
Expand Down
157 changes: 157 additions & 0 deletions app/src/pages/auth/ResetPasswordWithTokenForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { useCallback, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useNavigate } from "react-router";

import {
Alert,
Button,
Flex,
Form,
TextField,
View,
} from "@arizeai/components";

const MIN_PASSWORD_LENGTH = 4;

export type ResetPasswordWithTokenFormParams = {
resetToken: string;
newPassword: string;
confirmPassword: string;
};

interface ResetPasswordWithTokenFormProps {
resetToken: string;
}

export function ResetPasswordWithTokenForm({
resetToken,
}: ResetPasswordWithTokenFormProps) {
const navigate = useNavigate();
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const onSubmit = useCallback(
async ({ resetToken, newPassword }: ResetPasswordWithTokenFormParams) => {
setMessage(null);
setError(null);
setIsLoading(true);
try {
const response = await fetch("/auth/password-reset", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token: resetToken, password: newPassword }),
});
if (!response.ok) {
setError("Failed attempt");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not helpful error message

return;
}
} catch (error) {
setError("Failed attempt");
return;
} finally {
setIsLoading(() => false);
}
setMessage("Success");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not helpful message

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have something like
Screenshot 2024-09-17 at 8 36 20 AM

navigate("/login");
},
[setMessage, setError, navigate]
);
const {
control,
handleSubmit,
formState: { isDirty },
} = useForm<ResetPasswordWithTokenFormParams>({
defaultValues: {
resetToken: resetToken,
newPassword: "",
confirmPassword: "",
},
});
return (
<>
{message ? (
<View paddingBottom="size-100">
<Alert variant="success">{message}</Alert>
</View>
) : null}
{error ? (
<View paddingBottom="size-100">
<Alert variant="danger">{error}</Alert>
</View>
) : null}
<Form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="newPassword"
control={control}
rules={{
required: "Password is required",
minLength: {
value: MIN_PASSWORD_LENGTH,
message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
},
}}
render={({
field: { name, onChange, onBlur, value },
fieldState: { invalid, error },
}) => (
<TextField
label="New Password"
type="password"
isRequired
description={`Password must be at least ${MIN_PASSWORD_LENGTH} characters`}
name={name}
errorMessage={error?.message}
validationState={invalid ? "invalid" : "valid"}
onChange={onChange}
onBlur={onBlur}
defaultValue={value}
/>
)}
/>
<Controller
name="confirmPassword"
control={control}
rules={{
required: "Password is required",
minLength: {
value: MIN_PASSWORD_LENGTH,
message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
},
validate: (value, formValues) =>
value === formValues.newPassword || "Passwords do not match",
}}
render={({
field: { name, onChange, onBlur, value },
fieldState: { invalid, error },
}) => (
<TextField
label="Confirm Password"
isRequired
type="password"
description="Confirm the new password"
name={name}
errorMessage={error?.message}
validationState={invalid ? "invalid" : "valid"}
onChange={onChange}
onBlur={onBlur}
defaultValue={value}
/>
)}
/>
<View paddingTop="size-200">
<Flex direction="row" gap="size-100" justifyContent="end">
<Button
variant={isDirty ? "primary" : "default"}
type="submit"
disabled={isLoading}
>
{isLoading ? "Resetting..." : "Reset Password"}
</Button>
</Flex>
</View>
</Form>
</>
);
}
29 changes: 29 additions & 0 deletions app/src/pages/auth/ResetPasswordWithTokenPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react";
import { useNavigate } from "react-router";
import { useSearchParams } from "react-router-dom";

import { Flex, View } from "@arizeai/components";

import { AuthLayout } from "./AuthLayout";
import { PhoenixLogo } from "./PhoenixLogo";
import { ResetPasswordWithTokenForm } from "./ResetPasswordWithTokenForm";

export function ResetPasswordWithTokenPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
if (!token) {
navigate("/login");
return null;
}
Comment on lines +14 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably just show an error. We need a "back to login" link anyways. Redirecting typically should happen on user-action - it might be confusing otherwise.

return (
<AuthLayout>
<Flex direction="column" gap="size-200" alignItems="center">
<View paddingBottom="size-200">
<PhoenixLogo />
</View>
</Flex>
<ResetPasswordWithTokenForm resetToken={token} />
</AuthLayout>
);
}
2 changes: 2 additions & 0 deletions app/src/pages/auth/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from "./LoginPage";
export * from "./ResetPasswordPage";
export * from "./ResetPasswordWithTokenPage";
export * from "./resetPasswordLoader";
export * from "./ForgotPasswordPage";
Loading
Loading