mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: remove mui components from <SignInPage /> and subsidiaries (#21987)
This pull-request takes our `@mui/*` dependencies and replaces them with shiny new Tailwind ones. Furthermore, it resolves an issue with the `input` where `aria-invalid` wouldn't give it a red-ring like `<InputGroup />` does. As an added touch we've applied Formik to `<RequestOTPPage />` so that we can render an invalid email easily.
This commit is contained in:
@@ -19,6 +19,7 @@ export const Input = forwardRef<
|
||||
placeholder:text-content-secondary
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
|
||||
disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-inherit
|
||||
aria-[invalid=true]:border-border-destructive
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -34,8 +34,8 @@ describe("LoginPage", () => {
|
||||
// When
|
||||
render(<LoginPage />);
|
||||
await waitForLoaderToBeRemoved();
|
||||
const email = screen.getByLabelText(Language.emailLabel);
|
||||
const password = screen.getByLabelText(Language.passwordLabel);
|
||||
const email = screen.getByLabelText(new RegExp(Language.emailLabel));
|
||||
const password = screen.getByLabelText(new RegExp(Language.passwordLabel));
|
||||
await userEvent.type(email, "test@coder.com");
|
||||
await userEvent.type(password, "password");
|
||||
// Click sign-in
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { LoginPageView } from "./LoginPageView";
|
||||
|
||||
const meta: Meta<typeof LoginPageView> = {
|
||||
@@ -75,3 +76,27 @@ export const SigningIn: Story = {
|
||||
authMethods: MockAuthMethodsPasswordOnly,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFieldValidation: Story = {
|
||||
args: {
|
||||
authMethods: MockAuthMethodsPasswordOnly,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
await user.click(canvas.getByRole("button", { name: /sign in/i }));
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInvalidEmail: Story = {
|
||||
args: {
|
||||
authMethods: MockAuthMethodsPasswordOnly,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
const emailInput = await canvas.findByLabelText(/email/i);
|
||||
await user.type(emailInput, "not-an-email");
|
||||
await user.click(canvas.getByRole("button", { name: /sign in/i }));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { CustomLogo } from "components/CustomLogo/CustomLogo";
|
||||
@@ -36,8 +35,8 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
|
||||
authMethods?.terms_of_service_url && !tosAccepted;
|
||||
|
||||
return (
|
||||
<div css={styles.root}>
|
||||
<div css={styles.container}>
|
||||
<div className="p-6 flex items-center justify-center min-h-full text-center">
|
||||
<div className="w-full max-w-xs flex flex-col items-center gap-4">
|
||||
<CustomLogo />
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
@@ -62,7 +61,7 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
|
||||
onSubmit={onSignIn}
|
||||
/>
|
||||
)}
|
||||
<footer css={styles.footer}>
|
||||
<footer className="text-xs text-content-secondary mt-6">
|
||||
<div>
|
||||
Copyright © {new Date().getFullYear()} Coder Technologies, Inc.
|
||||
</div>
|
||||
@@ -75,33 +74,3 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100%",
|
||||
textAlign: "center",
|
||||
},
|
||||
|
||||
container: {
|
||||
width: "100%",
|
||||
maxWidth: 320,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
},
|
||||
|
||||
icon: {
|
||||
fontSize: 64,
|
||||
},
|
||||
|
||||
footer: (theme) => ({
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: 24,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from "@mui/material/Link";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { Label } from "components/Label/Label";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
@@ -39,52 +39,78 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
|
||||
validateOnBlur: false,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
const emailField = getFieldHelpers("email");
|
||||
const passwordField = getFieldHelpers("password");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit}>
|
||||
<Stack spacing={2.5}>
|
||||
<TextField
|
||||
{...getFieldHelpers("email")}
|
||||
<form onSubmit={form.handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor={emailField.id}>
|
||||
{Language.emailLabel}{" "}
|
||||
<span className="text-xs text-content-destructive font-bold">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={emailField.id}
|
||||
name={emailField.name}
|
||||
value={emailField.value}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
onBlur={emailField.onBlur}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="email"
|
||||
fullWidth
|
||||
label={Language.emailLabel}
|
||||
type="email"
|
||||
aria-invalid={Boolean(emailField.error)}
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("password")}
|
||||
{emailField.error && (
|
||||
<span className="text-xs text-content-destructive text-left">
|
||||
{emailField.helperText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor={passwordField.id}>
|
||||
{Language.passwordLabel}{" "}
|
||||
<span className="text-xs text-content-destructive font-bold">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={passwordField.id}
|
||||
name={passwordField.name}
|
||||
value={passwordField.value}
|
||||
onChange={passwordField.onChange}
|
||||
onBlur={passwordField.onBlur}
|
||||
autoComplete="current-password"
|
||||
fullWidth
|
||||
id="password"
|
||||
label={Language.passwordLabel}
|
||||
type="password"
|
||||
aria-invalid={passwordField.error}
|
||||
/>
|
||||
<Button
|
||||
size="lg"
|
||||
disabled={isSigningIn}
|
||||
className="w-full"
|
||||
type="submit"
|
||||
>
|
||||
<Spinner loading={isSigningIn} />
|
||||
{Language.passwordSignIn}
|
||||
</Button>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
{passwordField.error && (
|
||||
<span className="text-xs text-content-destructive text-left">
|
||||
{passwordField.helperText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size="lg" disabled={isSigningIn} className="w-full" type="submit">
|
||||
<Spinner loading={isSigningIn} />
|
||||
{Language.passwordSignIn}
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
asChild
|
||||
size="sm"
|
||||
showExternalIcon={false}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<RouterLink
|
||||
to={
|
||||
form.values.email
|
||||
? `/reset-password?email=${encodeURIComponent(form.values.email)}`
|
||||
: "/reset-password"
|
||||
}
|
||||
css={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: "16px",
|
||||
}}
|
||||
className="mx-auto"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</Stack>
|
||||
</RouterLink>
|
||||
</Link>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mockApiError } from "testHelpers/entities";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { SignInForm } from "./SignInForm";
|
||||
|
||||
const meta: Meta<typeof SignInForm> = {
|
||||
@@ -40,6 +41,24 @@ export const WithError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFieldValidation: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
await user.click(canvas.getByRole("button", { name: /sign in/i }));
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInvalidEmail: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
const emailInput = await canvas.findByLabelText(/email/i);
|
||||
await user.type(emailInput, "not-an-email");
|
||||
await user.click(canvas.getByRole("button", { name: /sign in/i }));
|
||||
},
|
||||
};
|
||||
|
||||
export const WithGithub: Story = {
|
||||
args: {
|
||||
authMethods: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type { AuthMethods } from "api/typesGenerated";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
@@ -7,49 +6,6 @@ import { getApplicationName } from "utils/appearance";
|
||||
import { OAuthSignInForm } from "./OAuthSignInForm";
|
||||
import { PasswordSignInForm } from "./PasswordSignInForm";
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
width: "100%",
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 400,
|
||||
margin: 0,
|
||||
marginBottom: 32,
|
||||
lineHeight: 1,
|
||||
|
||||
"& strong": {
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
alert: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
divider: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
},
|
||||
dividerLine: (theme) => ({
|
||||
width: "100%",
|
||||
height: 1,
|
||||
backgroundColor: theme.palette.divider,
|
||||
}),
|
||||
dividerLabel: (theme) => ({
|
||||
flexShrink: 0,
|
||||
color: theme.palette.text.secondary,
|
||||
textTransform: "uppercase",
|
||||
fontSize: 12,
|
||||
letterSpacing: 1,
|
||||
}),
|
||||
icon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
interface SignInFormProps {
|
||||
isSigningIn: boolean;
|
||||
redirectTo: string;
|
||||
@@ -74,17 +30,19 @@ export const SignInForm: FC<SignInFormProps> = ({
|
||||
const applicationName = getApplicationName();
|
||||
|
||||
return (
|
||||
<div css={styles.root}>
|
||||
<h1 css={styles.title}>{applicationName}</h1>
|
||||
<div className="w-full">
|
||||
<h1 className="text-3xl font-semibold m-0 mb-8 leading-none">
|
||||
{applicationName}
|
||||
</h1>
|
||||
|
||||
{Boolean(error) && (
|
||||
<div css={styles.alert}>
|
||||
<div className="mb-8">
|
||||
<ErrorAlert error={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div css={styles.alert}>
|
||||
<div className="mb-8">
|
||||
<Alert severity="info">{message}</Alert>
|
||||
</div>
|
||||
)}
|
||||
@@ -98,10 +56,12 @@ export const SignInForm: FC<SignInFormProps> = ({
|
||||
)}
|
||||
|
||||
{passwordEnabled && oAuthEnabled && (
|
||||
<div css={styles.divider}>
|
||||
<div css={styles.dividerLine} />
|
||||
<div css={styles.dividerLabel}>or</div>
|
||||
<div css={styles.dividerLine} />
|
||||
<div className="py-6 flex items-center gap-4">
|
||||
<div className="w-full h-px bg-border" />
|
||||
<div className="shrink-0 text-content-secondary uppercase text-xs tracking-widest">
|
||||
or
|
||||
</div>
|
||||
<div className="w-full h-px bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ type Story = StoryObj<typeof ChangePasswordPage>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const EmptySubmission: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
await user.click(canvas.getByRole("button", { name: /reset password/i }));
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
spyOn(API, "changePasswordWithOTP").mockResolvedValueOnce();
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { isApiValidationError } from "api/errors";
|
||||
import { changePasswordWithOTP } from "api/queries/users";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { CustomLogo } from "components/CustomLogo/CustomLogo";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { Label } from "components/Label/Label";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
@@ -62,67 +61,96 @@ const ChangePasswordPage: FC<ChangePasswordChangeProps> = ({ redirect }) => {
|
||||
},
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form, changePasswordMutation.error);
|
||||
const passwordField = getFieldHelpers("password");
|
||||
const confirmPasswordField = getFieldHelpers("confirmPassword");
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle("Reset Password", applicationName)}</title>
|
||||
|
||||
<div css={styles.root}>
|
||||
<main css={styles.container}>
|
||||
<CustomLogo css={styles.logo} />
|
||||
<h1
|
||||
css={{
|
||||
margin: 0,
|
||||
marginBottom: 24,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
lineHeight: "28px",
|
||||
}}
|
||||
>
|
||||
<div className="p-6 flex items-center justify-center flex-col min-h-full text-center">
|
||||
<main className="w-full max-w-xs flex flex-col items-center">
|
||||
<div className="mb-10">
|
||||
<CustomLogo />
|
||||
</div>
|
||||
<h1 className="m-0 mb-6 text-xl font-semibold leading-7">
|
||||
Choose a new password
|
||||
</h1>
|
||||
{changePasswordMutation.error &&
|
||||
!isApiValidationError(changePasswordMutation.error) ? (
|
||||
<ErrorAlert
|
||||
error={changePasswordMutation.error}
|
||||
css={{ marginBottom: 24 }}
|
||||
/>
|
||||
<ErrorAlert error={changePasswordMutation.error} className="mb-6" />
|
||||
) : null}
|
||||
<form css={{ width: "100%" }} onSubmit={form.handleSubmit}>
|
||||
<fieldset disabled={form.isSubmitting}>
|
||||
<Stack spacing={2.5}>
|
||||
<TextField
|
||||
label="Password"
|
||||
<form
|
||||
className="flex flex-col gap-5 w-full"
|
||||
onSubmit={form.handleSubmit}
|
||||
>
|
||||
<fieldset
|
||||
disabled={form.isSubmitting}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor={passwordField.id}>
|
||||
Password{" "}
|
||||
<span className="text-xs text-content-destructive font-bold">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={passwordField.id}
|
||||
name={passwordField.name}
|
||||
value={passwordField.value}
|
||||
onChange={passwordField.onChange}
|
||||
onBlur={passwordField.onBlur}
|
||||
autoFocus
|
||||
fullWidth
|
||||
required
|
||||
type="password"
|
||||
{...getFieldHelpers("password")}
|
||||
aria-invalid={passwordField.error}
|
||||
/>
|
||||
{passwordField.error && (
|
||||
<span className="text-xs text-content-destructive">
|
||||
{passwordField.helperText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Confirm password"
|
||||
fullWidth
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor={confirmPasswordField.id}>
|
||||
Confirm password{" "}
|
||||
<span className="text-xs text-content-destructive font-bold">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={confirmPasswordField.id}
|
||||
name={confirmPasswordField.name}
|
||||
value={confirmPasswordField.value}
|
||||
onChange={confirmPasswordField.onChange}
|
||||
onBlur={confirmPasswordField.onBlur}
|
||||
required
|
||||
type="password"
|
||||
{...getFieldHelpers("confirmPassword")}
|
||||
aria-invalid={confirmPasswordField.error}
|
||||
/>
|
||||
{confirmPasswordField.error && (
|
||||
<span className="text-xs text-content-destructive text-left">
|
||||
{confirmPasswordField.helperText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button
|
||||
disabled={form.isSubmitting}
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
<Spinner loading={form.isSubmitting} />
|
||||
Reset password
|
||||
</Button>
|
||||
<Button size="lg" className="w-full" variant="subtle" asChild>
|
||||
<RouterLink to="/login">Back to login</RouterLink>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
disabled={form.isSubmitting}
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
<Spinner loading={form.isSubmitting} />
|
||||
Reset password
|
||||
</Button>
|
||||
<Button size="lg" className="w-full" variant="subtle" asChild>
|
||||
<RouterLink to="/login">Back to login</RouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</main>
|
||||
@@ -131,34 +159,4 @@ const ChangePasswordPage: FC<ChangePasswordChangeProps> = ({ redirect }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
logo: {
|
||||
marginBottom: 40,
|
||||
},
|
||||
root: {
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
minHeight: "100%",
|
||||
textAlign: "center",
|
||||
},
|
||||
container: {
|
||||
width: "100%",
|
||||
maxWidth: 320,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
icon: {
|
||||
fontSize: 64,
|
||||
},
|
||||
footer: (theme) => ({
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: 24,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export default ChangePasswordPage;
|
||||
|
||||
@@ -27,6 +27,24 @@ export const Success: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const InvalidEmail: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
const emailInput = await canvas.findByLabelText(/email/i);
|
||||
await user.type(emailInput, "not-an-email");
|
||||
await user.click(canvas.getByRole("button", { name: /reset password/i }));
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptySubmission: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
await user.click(canvas.getByRole("button", { name: /reset password/i }));
|
||||
},
|
||||
};
|
||||
|
||||
export const ServerError: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
spyOn(API, "requestOneTimePassword").mockRejectedValueOnce(
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { requestOneTimePassword } from "api/queries/users";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { CustomLogo } from "components/CustomLogo/CustomLogo";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { Label } from "components/Label/Label";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { Link as RouterLink, useSearchParams } from "react-router";
|
||||
import { getApplicationName } from "utils/appearance";
|
||||
import { getFormHelpers, onChangeTrimmed } from "utils/formUtils";
|
||||
import { pageTitle } from "utils/page";
|
||||
import * as Yup from "yup";
|
||||
|
||||
const RequestOTPPage: FC = () => {
|
||||
const applicationName = getApplicationName();
|
||||
@@ -22,8 +24,10 @@ const RequestOTPPage: FC = () => {
|
||||
<>
|
||||
<title>{pageTitle("Reset Password", applicationName)}</title>
|
||||
|
||||
<main css={styles.root}>
|
||||
<CustomLogo css={styles.logo} />
|
||||
<main className="p-6 flex items-center justify-center flex-col min-h-full text-center">
|
||||
<div>
|
||||
<CustomLogo />
|
||||
</div>
|
||||
{requestOTPMutation.isSuccess ? (
|
||||
<RequestOTPSuccess
|
||||
email={requestOTPMutation.variables?.email ?? ""}
|
||||
@@ -50,62 +54,80 @@ type RequestOTPProps = {
|
||||
initialEmail: string;
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
email: Yup.string()
|
||||
.trim()
|
||||
.email("Please enter a valid email address.")
|
||||
.required("Please enter an email address."),
|
||||
});
|
||||
|
||||
const RequestOTP: FC<RequestOTPProps> = ({
|
||||
error,
|
||||
onRequest,
|
||||
isRequesting,
|
||||
initialEmail,
|
||||
}) => {
|
||||
const form = useFormik({
|
||||
initialValues: { email: initialEmail },
|
||||
validationSchema,
|
||||
validateOnBlur: false,
|
||||
onSubmit: (values) => {
|
||||
onRequest(values.email);
|
||||
},
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
const emailField = getFieldHelpers("email");
|
||||
|
||||
return (
|
||||
<div css={styles.container}>
|
||||
<div className="w-full max-w-xs flex flex-col items-center">
|
||||
<div>
|
||||
<h1
|
||||
css={{
|
||||
margin: 0,
|
||||
marginBottom: 24,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
lineHeight: "28px",
|
||||
}}
|
||||
>
|
||||
<h1 className="m-0 mb-6 text-xl font-semibold leading-7">
|
||||
Enter your email to reset the password
|
||||
</h1>
|
||||
{error ? <ErrorAlert error={error} css={{ marginBottom: 24 }} /> : null}
|
||||
{error ? <ErrorAlert error={error} className="mb-6" /> : null}
|
||||
<form
|
||||
css={{ width: "100%" }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const email = e.currentTarget.email.value;
|
||||
onRequest(email);
|
||||
}}
|
||||
className="flex flex-col gap-5 w-full"
|
||||
onSubmit={form.handleSubmit}
|
||||
>
|
||||
<fieldset disabled={isRequesting}>
|
||||
<Stack spacing={2.5}>
|
||||
<TextField
|
||||
name="email"
|
||||
label="Email"
|
||||
<fieldset disabled={isRequesting} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor={emailField.id}>
|
||||
Email{" "}
|
||||
<span className="text-xs text-content-destructive font-bold">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={emailField.id}
|
||||
name={emailField.name}
|
||||
value={emailField.value}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
onBlur={emailField.onBlur}
|
||||
type="email"
|
||||
autoFocus
|
||||
required
|
||||
fullWidth
|
||||
defaultValue={initialEmail}
|
||||
aria-invalid={Boolean(emailField.error)}
|
||||
/>
|
||||
{emailField.error && (
|
||||
<span className="text-xs text-content-destructive text-left">
|
||||
{emailField.helperText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button
|
||||
disabled={isRequesting}
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
<Spinner loading={isRequesting} />
|
||||
Reset password
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline" className="w-full">
|
||||
<RouterLink to="/login">Cancel</RouterLink>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
disabled={isRequesting}
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
<Spinner loading={isRequesting} />
|
||||
Reset password
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline" className="w-full">
|
||||
<RouterLink to="/login">Cancel</RouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
@@ -114,37 +136,17 @@ const RequestOTP: FC<RequestOTPProps> = ({
|
||||
};
|
||||
|
||||
const RequestOTPSuccess: FC<{ email: string }> = ({ email }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
...styles.container,
|
||||
maxWidth: 380,
|
||||
fontWeight: 500,
|
||||
fontSize: 14,
|
||||
lineHeight: "24px",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-[380px] flex flex-col items-center font-medium text-sm leading-6">
|
||||
<div>
|
||||
<p css={{ margin: 0, marginBottom: 56 }}>
|
||||
<p className="m-0 mb-14">
|
||||
If the account{" "}
|
||||
<span css={{ fontWeight: 600, color: theme.palette.text.secondary }}>
|
||||
{email}
|
||||
</span>{" "}
|
||||
<span className="font-semibold text-content-secondary">{email}</span>{" "}
|
||||
exists, you will get an email with instructions on resetting your
|
||||
password.
|
||||
</p>
|
||||
|
||||
<p
|
||||
css={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: "16px",
|
||||
color: theme.palette.text.secondary,
|
||||
marginBottom: 48,
|
||||
}}
|
||||
>
|
||||
<p className="m-0 text-xs leading-4 text-content-secondary mb-12">
|
||||
Contact your deployment administrator if you encounter issues.
|
||||
</p>
|
||||
|
||||
@@ -156,34 +158,4 @@ const RequestOTPSuccess: FC<{ email: string }> = ({ email }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
logo: {
|
||||
marginBottom: 40,
|
||||
},
|
||||
root: {
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
minHeight: "100%",
|
||||
textAlign: "center",
|
||||
},
|
||||
container: {
|
||||
width: "100%",
|
||||
maxWidth: 320,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
icon: {
|
||||
fontSize: 64,
|
||||
},
|
||||
footer: (theme) => ({
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: 24,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export default RequestOTPPage;
|
||||
|
||||
Reference in New Issue
Block a user