From ceacb1e61ef1745a0dc355eb9cac722764fe53c0 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Mon, 9 Feb 2026 13:47:57 +1100 Subject: [PATCH] feat: remove mui components from `` 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 `` does. As an added touch we've applied Formik to `` so that we can render an invalid email easily. --- site/src/components/Input/Input.tsx | 1 + site/src/pages/LoginPage/LoginPage.test.tsx | 4 +- .../pages/LoginPage/LoginPageView.stories.tsx | 25 +++ site/src/pages/LoginPage/LoginPageView.tsx | 37 +--- .../pages/LoginPage/PasswordSignInForm.tsx | 90 ++++++---- .../pages/LoginPage/SignInForm.stories.tsx | 19 ++ site/src/pages/LoginPage/SignInForm.tsx | 64 ++----- .../ChangePasswordPage.stories.tsx | 8 + .../ResetPasswordPage/ChangePasswordPage.tsx | 148 ++++++++------- .../RequestOTPPage.stories.tsx | 18 ++ .../ResetPasswordPage/RequestOTPPage.tsx | 170 ++++++++---------- 11 files changed, 290 insertions(+), 294 deletions(-) diff --git a/site/src/components/Input/Input.tsx b/site/src/components/Input/Input.tsx index accdf2c6f3..2f361794e3 100644 --- a/site/src/components/Input/Input.tsx +++ b/site/src/components/Input/Input.tsx @@ -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, )} diff --git a/site/src/pages/LoginPage/LoginPage.test.tsx b/site/src/pages/LoginPage/LoginPage.test.tsx index f43578aecf..554a4be49f 100644 --- a/site/src/pages/LoginPage/LoginPage.test.tsx +++ b/site/src/pages/LoginPage/LoginPage.test.tsx @@ -34,8 +34,8 @@ describe("LoginPage", () => { // When render(); 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 diff --git a/site/src/pages/LoginPage/LoginPageView.stories.tsx b/site/src/pages/LoginPage/LoginPageView.stories.tsx index f4cb1eb0b0..7ffc43bb09 100644 --- a/site/src/pages/LoginPage/LoginPageView.stories.tsx +++ b/site/src/pages/LoginPage/LoginPageView.stories.tsx @@ -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 = { @@ -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 })); + }, +}; diff --git a/site/src/pages/LoginPage/LoginPageView.tsx b/site/src/pages/LoginPage/LoginPageView.tsx index 7f87a8d9fa..dcfb88a1de 100644 --- a/site/src/pages/LoginPage/LoginPageView.tsx +++ b/site/src/pages/LoginPage/LoginPageView.tsx @@ -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 = ({ authMethods?.terms_of_service_url && !tosAccepted; return ( -
-
+
+
{isLoading ? ( @@ -62,7 +61,7 @@ export const LoginPageView: FC = ({ onSubmit={onSignIn} /> )} -
+
Copyright © {new Date().getFullYear()} Coder Technologies, Inc.
@@ -75,33 +74,3 @@ export const LoginPageView: FC = ({
); }; - -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>; diff --git a/site/src/pages/LoginPage/PasswordSignInForm.tsx b/site/src/pages/LoginPage/PasswordSignInForm.tsx index cdf5de3e88..829ce32fca 100644 --- a/site/src/pages/LoginPage/PasswordSignInForm.tsx +++ b/site/src/pages/LoginPage/PasswordSignInForm.tsx @@ -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 = ({ validateOnBlur: false, }); const getFieldHelpers = getFormHelpers(form); + const emailField = getFieldHelpers("email"); + const passwordField = getFieldHelpers("password"); return ( -
- - +
+ + - + {emailField.helperText} + + )} +
+ +
+ + - - + {passwordField.helperText} + + )} +
+ + + + + Forgot password? - -
+ +
); }; diff --git a/site/src/pages/LoginPage/SignInForm.stories.tsx b/site/src/pages/LoginPage/SignInForm.stories.tsx index f839af4e2a..af35e952a4 100644 --- a/site/src/pages/LoginPage/SignInForm.stories.tsx +++ b/site/src/pages/LoginPage/SignInForm.stories.tsx @@ -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 = { @@ -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: { diff --git a/site/src/pages/LoginPage/SignInForm.tsx b/site/src/pages/LoginPage/SignInForm.tsx index c70873586d..5df7206d9e 100644 --- a/site/src/pages/LoginPage/SignInForm.tsx +++ b/site/src/pages/LoginPage/SignInForm.tsx @@ -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>; - interface SignInFormProps { isSigningIn: boolean; redirectTo: string; @@ -74,17 +30,19 @@ export const SignInForm: FC = ({ const applicationName = getApplicationName(); return ( -
-

{applicationName}

+
+

+ {applicationName} +

{Boolean(error) && ( -
+
)} {message && ( -
+
{message}
)} @@ -98,10 +56,12 @@ export const SignInForm: FC = ({ )} {passwordEnabled && oAuthEnabled && ( -
-
-
or
-
+
+
+
+ or +
+
)} diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx index 359f7df665..83e506ae41 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx @@ -17,6 +17,14 @@ type Story = StoryObj; 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(); diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx index 9ec361a399..e789142133 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx @@ -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 = ({ redirect }) => { }, }); const getFieldHelpers = getFormHelpers(form, changePasswordMutation.error); + const passwordField = getFieldHelpers("password"); + const confirmPasswordField = getFieldHelpers("confirmPassword"); return ( <> {pageTitle("Reset Password", applicationName)} -
-
- -

+
+
+
+ +
+

Choose a new password

{changePasswordMutation.error && !isApiValidationError(changePasswordMutation.error) ? ( - + ) : null} -
-
- - +
+
+ + + {passwordField.error && ( + + {passwordField.helperText} + + )} +
- + + + {confirmPasswordField.error && ( + + {confirmPasswordField.helperText} + + )} +
- - - - - +
+ + +

@@ -131,34 +159,4 @@ const ChangePasswordPage: FC = ({ 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>; - export default ChangePasswordPage; diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx index 130d6013ce..4ec83d881e 100644 --- a/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx @@ -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( diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx index 04a43e527a..4ad4257e2c 100644 --- a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx @@ -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 = () => { <> {pageTitle("Reset Password", applicationName)} -
- +
+
+ +
{requestOTPMutation.isSuccess ? ( = ({ 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 ( -
+
-

+

Enter your email to reset the password

- {error ? : null} + {error ? : null}
{ - e.preventDefault(); - const email = e.currentTarget.email.value; - onRequest(email); - }} + className="flex flex-col gap-5 w-full" + onSubmit={form.handleSubmit} > -
- - +
+ + + {emailField.error && ( + + {emailField.helperText} + + )} +
- - - - -
+
+ + +
@@ -114,37 +136,17 @@ const RequestOTP: FC = ({ }; const RequestOTPSuccess: FC<{ email: string }> = ({ email }) => { - const theme = useTheme(); - return ( -
+
-

+

If the account{" "} - - {email} - {" "} + {email}{" "} exists, you will get an email with instructions on resetting your password.

-

+

Contact your deployment administrator if you encounter issues.

@@ -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>; - export default RequestOTPPage;