diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 759ed34594..34cf6e7386 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -82,6 +82,7 @@ "resetRequestInstructions": "We will send a one-time reset link for your account to your email.", "submit": "Submit", "resetFail": "Password reset error", + "resetSuccess": "Password reset successful", "resetDone": "If you have correctly entered your email or username, a password reset link has been sent to your email address.", "backToLogin": "Back To Login" }, diff --git a/src/components/InvalidLink/index.tsx b/src/components/InvalidLink/index.tsx index 2ba1c9402e..5ce22d8e5b 100644 --- a/src/components/InvalidLink/index.tsx +++ b/src/components/InvalidLink/index.tsx @@ -1,5 +1,12 @@ import { Help } from "@mui/icons-material"; -import { Button, Card, CardContent, Grid, Typography } from "@mui/material"; +import { + Button, + Card, + CardContent, + CardHeader, + Grid2, + Typography, +} from "@mui/material"; import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; @@ -17,44 +24,47 @@ export default function InvalidLink(props: InvalidLinkProps): ReactElement { const idAffix = "invalid-link"; return ( - - + + + + {t(props.textId)} + + } + /> + - - {t(props.textId)} - {/* User Guide, Sign Up, and Log In buttons */} - - + + - + - - - + - - - - + + - + ); } diff --git a/src/components/Login/Captcha.tsx b/src/components/Login/Captcha.tsx index b1b03a5ed4..30ed3979b9 100644 --- a/src/components/Login/Captcha.tsx +++ b/src/components/Login/Captcha.tsx @@ -5,7 +5,6 @@ import { toast } from "react-toastify"; import { verifyCaptchaToken } from "backend"; import { RuntimeConfig } from "types/runtimeConfig"; -import theme from "types/theme"; export interface CaptchaProps { /** Parent function to call when CAPTCHA succeeds or fails. */ @@ -42,7 +41,6 @@ export default function Captcha(props: CaptchaProps): ReactElement { onSuccess={verify} options={{ language: i18n.resolvedLanguage, theme: "light" }} siteKey={RuntimeConfig.getInstance().captchaSiteKey()} - style={{ marginBottom: theme.spacing(1) }} /> ) : ( diff --git a/src/components/Login/Login.tsx b/src/components/Login/Login.tsx index bb5d43ce84..d4b1af267e 100644 --- a/src/components/Login/Login.tsx +++ b/src/components/Login/Login.tsx @@ -3,8 +3,10 @@ import { Button, Card, CardContent, - Grid, + CardHeader, + Grid2, Link, + Stack, TextFieldProps, Typography, } from "@mui/material"; @@ -29,9 +31,9 @@ import { type StoreState } from "rootRedux/types"; import router from "router/browserRouter"; import { Path } from "types/path"; import { RuntimeConfig } from "types/runtimeConfig"; -import theme from "types/theme"; import { NormalizedTextField } from "utilities/fontComponents"; import { openUserGuide } from "utilities/pathUtilities"; +import { meetsPasswordRequirements } from "utilities/utilities"; export enum LoginId { ButtonLogIn = "login-log-in-button", @@ -46,6 +48,7 @@ export enum LoginTextId { ButtonLogin = "login.login", ButtonSignUp = "login.signUp", Error401 = "login.failed", + ErrorPassword = "login.passwordRequirements", ErrorUnknown = "login.failedUnknownReason", FieldError = "login.required", LabelPassword = "login.password", @@ -90,10 +93,11 @@ export default function Login(): ReactElement { const logIn = (e: FormEvent): void => { e.preventDefault(); const p = password.trim(); + const pOk = meetsPasswordRequirements(p); const u = username.trim(); - setPasswordError(!p); + setPasswordError(!pOk); setUsernameError(!u); - if (p && u) { + if (pOk && u) { dispatch(asyncLogIn(u, p)); } }; @@ -108,129 +112,119 @@ export default function Login(): ReactElement { }); return ( - - -
- - {/* Title */} - + + + {/* Title */} + {t(LoginTextId.Title)} + } + /> + + + + + {/* Username field */} + + + {/* Password field */} + + + {/* "Forgot password?" link to reset password */} + {RuntimeConfig.getInstance().emailServicesEnabled() && ( + + router.navigate(Path.PwRequest)} + underline="hover" + variant="subtitle2" + > + {t(LoginTextId.LinkForgotPassword)} + + + )} + + {/* "Failed to log in" */} + {status === LoginStatus.Failure && ( + + {t( + loginError.includes("401") + ? LoginTextId.Error401 + : LoginTextId.ErrorUnknown + )} + + )} + + + + {/* User Guide, Sign Up, and Log In buttons */} + + + + - {/* Username field */} - - - {/* Password field */} - - - {/* "Forgot password?" link to reset password */} - {RuntimeConfig.getInstance().emailServicesEnabled() && ( - - router.navigate(Path.PwRequest)} - underline="hover" - variant="subtitle2" - > - {t(LoginTextId.LinkForgotPassword)} - - - )} - - {/* "Failed to log in" */} - {status === LoginStatus.Failure && ( - - {t( - loginError.includes("401") - ? LoginTextId.Error401 - : LoginTextId.ErrorUnknown - )} - - )} - - - - {/* User Guide, Sign Up, and Log In buttons */} - - - - - - - - - - - - {t(LoginTextId.ButtonLogin)} - - - - - - {/* Login announcement banner */} - {!!banner && ( - - {banner} - - )} - - + + + {t(LoginTextId.ButtonLogin)} + + + + {/* Login announcement banner */} + {!!banner && {banner}} + + +
-
+ ); } diff --git a/src/components/Login/Signup.tsx b/src/components/Login/Signup.tsx index 35ff3f1b3e..f491a57124 100644 --- a/src/components/Login/Signup.tsx +++ b/src/components/Login/Signup.tsx @@ -2,7 +2,9 @@ import { Button, Card, CardContent, - Grid, + CardHeader, + Grid2, + Stack, TextField, TextFieldProps, Typography, @@ -187,95 +189,94 @@ export default function Signup(props: SignupProps): ReactElement { }); return ( - - -
- - {/* Title */} - + + + {/* Title */} + {t("login.signUpNew")} - - {/* Name field */} - - - {/* Username field */} - checkUsername()} - /> - - {/* Email field */} - {/* Don't use NormalizedTextField for type="email". - At best, it doesn't normalize, because of the punycode. */} - - - {/* Password field */} - checkPassword1()} - type="password" - /> - - {/* Confirm Password field */} - checkPassword2()} - type="password" - /> - - {/* "Failed to sign up" */} - {!!error && ( - - {t(error)} - - )} - - - - {/* Sign Up and Log In buttons */} - - + } + /> + + + + + {/* Name field */} + + + {/* Username field */} + checkUsername()} + /> + + {/* Email field */} + {/* Don't use NormalizedTextField for type="email". + At best, it doesn't normalize, because of the punycode. */} + + + {/* Password field */} + checkPassword1()} + type="password" + /> + + {/* Confirm Password field */} + checkPassword2()} + type="password" + /> + + {/* "Failed to sign up" */} + {!!error && ( + + {t(error)} + + )} + + + + {/* Back-to-login and Sign-up buttons */} + - - + {t("login.signUp")} - - - -
+ + + +
-
+ ); } diff --git a/src/components/PasswordReset/Request.tsx b/src/components/PasswordReset/Request.tsx index f8acf7d088..d17062d288 100644 --- a/src/components/PasswordReset/Request.tsx +++ b/src/components/PasswordReset/Request.tsx @@ -1,4 +1,12 @@ -import { Button, Card, Grid, Typography } from "@mui/material"; +import { + Button, + Card, + CardContent, + CardHeader, + Grid2, + Stack, + Typography, +} from "@mui/material"; import { FormEvent, ReactElement, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; @@ -40,71 +48,81 @@ export default function ResetRequest(): ReactElement { }; return ( - - - - {t("passwordReset.resetRequestTitle")} - - {isDone ? ( - <> - {t("passwordReset.resetDone")} - + + + + {t("passwordReset.resetRequestTitle")} + + } + /> + + + {isDone ? ( + + {t("passwordReset.resetDone")} + - - - ) : ( - <> - - {t("passwordReset.resetRequestInstructions")} - + + ) : (
- + + + {t("passwordReset.resetRequestInstructions")} + + setEmailOrUsername(e.target.value)} required - type="text" - style={{ width: "100%" }} value={emailOrUsername} - variant="outlined" /> - - + - - - onSubmit, - variant: "contained", - }} - disabled={!emailOrUsername || !isVerified} - loading={isLoading} - > - {t("passwordReset.submit")} - - + + {/* Back-to-login and Submit buttons */} + + + + + {t("passwordReset.submit")} + + +
- - )} + )} +
-
+ ); } diff --git a/src/components/PasswordReset/ResetPage.tsx b/src/components/PasswordReset/ResetPage.tsx index ae4f6955de..fca0f30ad0 100644 --- a/src/components/PasswordReset/ResetPage.tsx +++ b/src/components/PasswordReset/ResetPage.tsx @@ -1,14 +1,16 @@ -import ExitToAppIcon from "@mui/icons-material/ExitToApp"; -import { Button, Card, Grid, Typography } from "@mui/material"; import { - type FormEvent, - type ReactElement, - useCallback, - useEffect, - useState, -} from "react"; + Button, + Card, + CardContent, + CardHeader, + Grid2, + Stack, + Typography, +} from "@mui/material"; +import { type FormEvent, type ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router"; +import { toast } from "react-toastify"; import { resetPassword, validateResetToken } from "backend"; import InvalidLink from "components/InvalidLink"; @@ -16,23 +18,12 @@ import { Path } from "types/path"; import { NormalizedTextField } from "utilities/fontComponents"; import { meetsPasswordRequirements } from "utilities/utilities"; -export enum PasswordResetTestIds { +export enum PasswordResetIds { Password = "PasswordReset.password", - PasswordReqError = "PasswordReset.requirements-error", ConfirmPassword = "PasswordReset.confirm-password", - PasswordMatchError = "PasswordReset.match-error", - PasswordResetFail = "PasswordReset.reset-fail", - BackToLoginButton = "PasswordReset.button.back-to-login", SubmitButton = "PasswordReset.button.submit", } -enum RequestState { - None, - Attempt, - Fail, - Success, -} - export default function PasswordReset(): ReactElement { const navigate = useNavigate(); const { token } = useParams(); @@ -44,26 +35,15 @@ export default function PasswordReset(): ReactElement { const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordFitsRequirements, setPasswordFitsRequirements] = useState(false); - const [requestState, setRequestState] = useState(RequestState.None); - const validateLink = useCallback(async (): Promise => { + useEffect(() => { if (token) { - setIsValidLink(await validateResetToken(token)); + validateResetToken(token).then(setIsValidLink); } }, [token]); - useEffect(() => { - validateLink(); - }); - - const backToLogin = (e: FormEvent): void => { - e.preventDefault(); - navigate(Path.Login); - }; - const onSubmit = async (e: FormEvent): Promise => { if (token) { - setRequestState(RequestState.Attempt); await asyncReset(token, password); e.preventDefault(); } @@ -81,114 +61,71 @@ export default function PasswordReset(): ReactElement { const asyncReset = async (token: string, password: string): Promise => { if (await resetPassword(token, password)) { - setRequestState(RequestState.Success); - navigate(Path.Login); + toast.success(t("passwordReset.resetSuccess")); } else { - setRequestState(RequestState.Fail); + toast.error(t("passwordReset.resetFail")); } + navigate(Path.Login); }; return isValidLink ? ( - - -
- - {t("passwordReset.resetTitle")} - - + + + + {t("passwordReset.resetTitle")} + + } + /> + + + onChangePassword(e.target.value, passwordConfirm) } + type="password" + value={password} /> - {!passwordFitsRequirements && ( - - {t("login.passwordRequirements")} - - )} - - + 0} + fullWidth + helperText={ + !isPasswordConfirmed && + passwordConfirm.length > 0 && + t("login.confirmPasswordError") + } + id={PasswordResetIds.ConfirmPassword} + inputProps={{ "data-testid": PasswordResetIds.ConfirmPassword }} label={t("login.confirmPassword")} + onChange={(e) => onChangePassword(password, e.target.value)} type="password" value={passwordConfirm} - style={{ width: "100%" }} - margin="normal" - error={!isPasswordConfirmed && passwordConfirm.length > 0} - onChange={(e) => onChangePassword(password, e.target.value)} /> - {!isPasswordConfirmed && passwordConfirm.length > 0 && ( - - {t("login.confirmPasswordError")} - - )} - - - - {requestState === RequestState.Fail ? ( - <> - - {t("passwordReset.resetFail")} - - - - ) : ( - - )} - - -
+ + +
-
+ ) : ( ); diff --git a/src/components/PasswordReset/tests/Request.test.tsx b/src/components/PasswordReset/tests/Request.test.tsx index 44f4d16359..dd8a43479e 100644 --- a/src/components/PasswordReset/tests/Request.test.tsx +++ b/src/components/PasswordReset/tests/Request.test.tsx @@ -46,18 +46,21 @@ describe("ResetRequest", () => { await renderUserSettings(); // Before - const button = screen.getByRole("button"); - expect(button).toBeDisabled(); + const login = screen.getByTestId(PasswordRequestIds.ButtonLogin); + const submit = screen.getByTestId(PasswordRequestIds.ButtonSubmit); + expect(login).toBeEnabled(); + expect(submit).toBeDisabled(); // Agent const field = screen.getByTestId(PasswordRequestIds.FieldEmailOrUsername); await agent.type(field, "a"); // After - expect(button).toBeEnabled(); + expect(login).toBeEnabled(); + expect(submit).toBeEnabled(); }); - it("after submit, removes text field and submit button and reveals login button", async () => { + it("after submit, removes text field and submit button", async () => { // Setup const agent = userEvent.setup(); await renderUserSettings(); @@ -66,19 +69,19 @@ describe("ResetRequest", () => { expect( screen.queryByTestId(PasswordRequestIds.FieldEmailOrUsername) ).toBeTruthy(); + expect(screen.queryByTestId(PasswordRequestIds.ButtonLogin)).toBeTruthy(); expect(screen.queryByTestId(PasswordRequestIds.ButtonSubmit)).toBeTruthy(); - expect(screen.queryByTestId(PasswordRequestIds.ButtonLogin)).toBeNull(); // Agent const field = screen.getByTestId(PasswordRequestIds.FieldEmailOrUsername); await agent.type(field, "a"); - await agent.click(screen.getByRole("button")); + await agent.click(screen.getByTestId(PasswordRequestIds.ButtonSubmit)); // After expect( screen.queryByTestId(PasswordRequestIds.FieldEmailOrUsername) ).toBeNull(); - expect(screen.queryByTestId(PasswordRequestIds.ButtonSubmit)).toBeNull(); expect(screen.queryByTestId(PasswordRequestIds.ButtonLogin)).toBeTruthy(); + expect(screen.queryByTestId(PasswordRequestIds.ButtonSubmit)).toBeNull(); }); }); diff --git a/src/components/PasswordReset/tests/ResetPage.test.tsx b/src/components/PasswordReset/tests/ResetPage.test.tsx index c75acf6967..23a31a5d9e 100644 --- a/src/components/PasswordReset/tests/ResetPage.test.tsx +++ b/src/components/PasswordReset/tests/ResetPage.test.tsx @@ -13,7 +13,7 @@ import { MemoryRouter, Route, Routes } from "react-router"; import configureMockStore from "redux-mock-store"; import PasswordReset, { - PasswordResetTestIds, + PasswordResetIds, } from "components/PasswordReset/ResetPage"; import { Path } from "types/path"; @@ -65,115 +65,56 @@ const customRender = async ( }; describe("PasswordReset", () => { - it("renders with password length error", async () => { + it("disables button when password too short", async () => { const user = userEvent.setup(); await customRender(); const shortPassword = "foo"; - const passwdField = screen.getByTestId(PasswordResetTestIds.Password); - const passwdConfirm = screen.getByTestId( - PasswordResetTestIds.ConfirmPassword - ); + const passwdField = screen.getByTestId(PasswordResetIds.Password); + const passwdConfirm = screen.getByTestId(PasswordResetIds.ConfirmPassword); await user.type(passwdField, shortPassword); await user.type(passwdConfirm, shortPassword); - const reqErrors = screen.queryAllByTestId( - PasswordResetTestIds.PasswordReqError - ); - const confirmErrors = screen.queryAllByTestId( - PasswordResetTestIds.PasswordMatchError - ); - const submitButton = screen.getByTestId(PasswordResetTestIds.SubmitButton); - - expect(reqErrors.length).toBeGreaterThan(0); - expect(confirmErrors.length).toBe(0); + const submitButton = screen.getByTestId(PasswordResetIds.SubmitButton); expect(submitButton.closest("button")).toBeDisabled(); }); - it("renders with password match error", async () => { + it("disables button when passwords don't match", async () => { const user = userEvent.setup(); await customRender(); const passwordEntry = "password"; const confirmEntry = "passward"; - const passwdField = screen.getByTestId(PasswordResetTestIds.Password); - const passwdConfirm = screen.getByTestId( - PasswordResetTestIds.ConfirmPassword - ); + const passwdField = screen.getByTestId(PasswordResetIds.Password); + const passwdConfirm = screen.getByTestId(PasswordResetIds.ConfirmPassword); await user.type(passwdField, passwordEntry); await user.type(passwdConfirm, confirmEntry); - const reqErrors = screen.queryAllByTestId( - PasswordResetTestIds.PasswordReqError - ); - const confirmErrors = screen.queryAllByTestId( - PasswordResetTestIds.PasswordMatchError - ); - const submitButton = screen.getByTestId(PasswordResetTestIds.SubmitButton); - - expect(reqErrors.length).toBe(0); - expect(confirmErrors.length).toBeGreaterThan(0); + const submitButton = screen.getByTestId(PasswordResetIds.SubmitButton); expect(submitButton.closest("button")).toBeDisabled(); }); - it("renders with no password errors", async () => { + it("enables button when passwords are long enough and match", async () => { const user = userEvent.setup(); await customRender(); const passwordEntry = "password"; - const confirmEntry = "password"; - const passwdField = screen.getByTestId(PasswordResetTestIds.Password); - const passwdConfirm = screen.getByTestId( - PasswordResetTestIds.ConfirmPassword - ); + const passwdField = screen.getByTestId(PasswordResetIds.Password); + const passwdConfirm = screen.getByTestId(PasswordResetIds.ConfirmPassword); await user.type(passwdField, passwordEntry); - await user.type(passwdConfirm, confirmEntry); - - const reqErrors = screen.queryAllByTestId( - PasswordResetTestIds.PasswordReqError - ); - const confirmErrors = screen.queryAllByTestId( - PasswordResetTestIds.PasswordMatchError - ); - const submitButton = screen.getByTestId(PasswordResetTestIds.SubmitButton); + await user.type(passwdConfirm, passwordEntry); - expect(reqErrors.length).toBe(0); - expect(confirmErrors.length).toBe(0); + const submitButton = screen.getByTestId(PasswordResetIds.SubmitButton); expect(submitButton.closest("button")).toBeEnabled(); }); - it("renders with expire error", async () => { - // rerender the component with the resetFailure prop set. - const user = userEvent.setup(); - await customRender(); - - const passwordEntry = "password"; - const confirmEntry = "password"; - const passwdField = screen.getByTestId(PasswordResetTestIds.Password); - const passwdConfirm = screen.getByTestId( - PasswordResetTestIds.ConfirmPassword - ); - - await user.type(passwdField, passwordEntry); - await user.type(passwdConfirm, confirmEntry); - - const submitButton = screen.getByTestId(PasswordResetTestIds.SubmitButton); - mockPasswordReset.mockResolvedValueOnce(false); - await user.click(submitButton); - - const resetErrors = screen.queryAllByTestId( - PasswordResetTestIds.PasswordResetFail - ); - expect(resetErrors.length).toBeGreaterThan(0); - }); - it("renders the InvalidLink component if token not valid", async () => { mockValidateResetToken.mockResolvedValueOnce(false); await customRender(); - for (const id of Object.values(PasswordResetTestIds)) { + for (const id of Object.values(PasswordResetIds)) { expect(screen.queryAllByTestId(id)).toHaveLength(0); } // The textId will show up as text because t() is mocked to return its input.