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:
Jake Howell
2026-02-09 13:47:57 +11:00
committed by GitHub
parent 7ca6c77d22
commit ceacb1e61e
11 changed files with 290 additions and 294 deletions
+1
View File
@@ -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,
)}
+2 -2
View File
@@ -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 }));
},
};
+3 -34
View File
@@ -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 &copy; {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>>;
+58 -32
View File
@@ -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: {
+12 -52
View File
@@ -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;