Skip to content

Commit

Permalink
feat: smtp for password reset (#4630)
Browse files Browse the repository at this point in the history
  • Loading branch information
RogerHYang authored Sep 18, 2024
1 parent cc453e0 commit 34d5763
Show file tree
Hide file tree
Showing 29 changed files with 1,088 additions and 150 deletions.
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 @@ -37,6 +38,7 @@ import {
ProjectsRoot,
resetPasswordLoader,
ResetPasswordPage,
ResetPasswordWithTokenPage,
SettingsPage,
TracePage,
TracingRoot,
Expand All @@ -51,6 +53,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 ForgotPasswordFormParams = {
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: ForgotPasswordFormParams) => {
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");
return;
}
} catch (error) {
setError("Failed attempt");
return;
} finally {
setIsLoading(() => false);
}
setMessage(
"A link to reset your password has been sent. Check your email for details."
);
},
[setMessage, setError]
);
const { control, handleSubmit } = useForm<ForgotPasswordFormParams>({
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");
return;
}
} catch (error) {
setError("Failed attempt");
return;
} finally {
setIsLoading(() => false);
}
setMessage("Success");
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;
}
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

0 comments on commit 34d5763

Please sign in to comment.