diff --git a/src/components/Buttons/LoadingButton.tsx b/src/components/Buttons/LoadingButton.tsx index 11de611d3b..1f00dd5ee7 100644 --- a/src/components/Buttons/LoadingButton.tsx +++ b/src/components/Buttons/LoadingButton.tsx @@ -5,7 +5,7 @@ import React, { ReactElement } from "react"; import { themeColors } from "types/theme"; interface LoadingProps { - buttonProps?: ButtonProps; + buttonProps?: ButtonProps & { "data-testid"?: string }; children?: React.ReactNode; disabled?: boolean; loading?: boolean; diff --git a/src/components/InvalidLink/index.tsx b/src/components/InvalidLink/index.tsx index 3595a6bd3f..ee49f51945 100644 --- a/src/components/InvalidLink/index.tsx +++ b/src/components/InvalidLink/index.tsx @@ -35,7 +35,7 @@ export default function InvalidLink(props: InvalidLinkProps): ReactElement { + + + + + + + + + {t("login.login")} + + + + + {/* Login announcement banner */} + {!!banner && ( + + {banner} + + )} + + + + + ); +} diff --git a/src/components/Login/LoginPage/LoginComponent.tsx b/src/components/Login/LoginPage/LoginComponent.tsx deleted file mode 100644 index 12e3084cbc..0000000000 --- a/src/components/Login/LoginPage/LoginComponent.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import ReCaptcha from "@matt-block/react-recaptcha-v2"; -import { Help } from "@mui/icons-material"; -import { - Button, - Card, - CardContent, - Grid, - Link, - TextField, - Typography, -} from "@mui/material"; -import { Component, ReactElement } from "react"; -import { withTranslation, WithTranslation } from "react-i18next"; - -import { BannerType } from "api/models"; -import { getBannerText } from "backend"; -import router from "browserRouter"; -import { LoadingButton } from "components/Buttons"; -import { LoginStatus } from "components/Login/Redux/LoginReduxTypes"; -import { Path } from "types/path"; -import { RuntimeConfig } from "types/runtimeConfig"; -import theme from "types/theme"; -import { openUserGuide } from "utilities/pathUtilities"; - -const idAffix = "login"; - -export const captchaStyle = { - margin: "5px", -}; - -export interface LoginDispatchProps { - login?: (username: string, password: string) => void; - logout: () => void; - reset: () => void; -} - -export interface LoginStateProps { - status: LoginStatus; -} - -interface LoginProps - extends LoginDispatchProps, - LoginStateProps, - WithTranslation {} - -interface LoginState { - username: string; - password: string; - isVerified: boolean; - error: LoginError; - loginBanner: string; -} - -interface LoginError { - username: boolean; - password: boolean; -} - -/** The login page (also doubles as a logout page) */ -export class Login extends Component { - constructor(props: LoginProps) { - super(props); - this.props.logout(); // Loading this page will reset the app, both store and localStorage - - this.state = { - username: "", - password: "", - isVerified: !RuntimeConfig.getInstance().captchaRequired(), - error: { username: false, password: false }, - loginBanner: "", - }; - } - - componentDidMount(): void { - this.props.reset(); - getBannerText(BannerType.Login).then((loginBanner) => - this.setState({ loginBanner }) - ); - } - - /** Updates the state to match the value in a textbox */ - updateField( - e: React.ChangeEvent< - HTMLTextAreaElement | HTMLInputElement | HTMLSelectElement - >, - field: K - ): void { - const value = e.target.value; - - this.setState({ [field]: value } as Pick); - } - - login(e: React.FormEvent): void { - e.preventDefault(); - - const username: string = this.state.username.trim(); - const password: string = this.state.password.trim(); - const error = { ...this.state.error }; - error.username = username === ""; - error.password = password === ""; - if (error.username || error.password) { - this.setState({ error }); - } else if (this.props.login) { - this.props.login(username, password); - } - } - - render(): ReactElement { - return ( - - -
this.login(e)}> - - {/* Title */} - - {this.props.t("login.title")} - - - {/* Username field */} - this.updateField(e, "username")} - error={this.state.error["username"]} - helperText={ - this.state.error["username"] - ? this.props.t("login.required") - : undefined - } - variant="outlined" - style={{ width: "100%" }} - margin="normal" - autoFocus - inputProps={{ maxLength: 100 }} - /> - - {/* Password field */} - this.updateField(e, "password")} - error={this.state.error["password"]} - helperText={ - this.state.error["password"] - ? this.props.t("login.required") - : undefined - } - variant="outlined" - style={{ width: "100%" }} - margin="normal" - inputProps={{ maxLength: 100 }} - /> - - {/* "Forgot password?" link to reset password */} - {RuntimeConfig.getInstance().emailServicesEnabled() && ( - - router.navigate(Path.PwRequest)} - variant="subtitle2" - underline="hover" - > - {this.props.t("login.forgotPassword")} - - - )} - - {/* "Failed to log in" */} - {this.props.status === LoginStatus.Failure && ( - - {this.props.t("login.failed")} - - )} - - {RuntimeConfig.getInstance().captchaRequired() && ( -
- this.setState({ isVerified: true })} - onExpire={() => this.setState({ isVerified: false })} - onError={() => - console.error( - "Something went wrong, check your connection." - ) - } - /> -
- )} - - {/* User Guide, Sign Up, and Log In buttons */} - - - - - - - - - - - - {this.props.t("login.login")} - - - - - {/* Login announcement banner */} - {!!this.state.loginBanner && ( - - {this.state.loginBanner} - - )} -
-
-
-
- ); - } -} - -export default withTranslation()(Login); diff --git a/src/components/Login/LoginPage/index.ts b/src/components/Login/LoginPage/index.ts deleted file mode 100644 index 6cec33ab08..0000000000 --- a/src/components/Login/LoginPage/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { connect } from "react-redux"; - -import Login, { - LoginDispatchProps, - LoginStateProps, -} from "components/Login/LoginPage/LoginComponent"; -import { - asyncLogIn, - logoutAndResetStore, -} from "components/Login/Redux/LoginActions"; -import { reset } from "rootActions"; -import { StoreState } from "types"; -import { StoreStateDispatch } from "types/Redux/actions"; - -function mapStateToProps(state: StoreState): LoginStateProps { - return { status: state.loginState.loginStatus }; -} - -function mapDispatchToProps(dispatch: StoreStateDispatch): LoginDispatchProps { - return { - login: (username: string, password: string) => { - dispatch(asyncLogIn(username, password)); - }, - logout: () => { - dispatch(logoutAndResetStore()); - }, - reset: () => { - dispatch(reset()); - }, - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(Login); diff --git a/src/components/Login/LoginPage/tests/LoginComponent.test.tsx b/src/components/Login/LoginPage/tests/LoginComponent.test.tsx deleted file mode 100644 index 93d89fa73f..0000000000 --- a/src/components/Login/LoginPage/tests/LoginComponent.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { - ReactTestInstance, - ReactTestRenderer, - act, - create, -} from "react-test-renderer"; - -import "tests/reactI18nextMock"; - -import Login from "components/Login/LoginPage/LoginComponent"; -import { LoginStatus } from "components/Login/Redux/LoginReduxTypes"; - -jest.mock( - "@matt-block/react-recaptcha-v2", - () => - function MockRecaptcha() { - return
Recaptcha
; - } -); -jest.mock("backend", () => ({ - getBannerText: () => Promise.resolve(""), -})); - -jest.mock("browserRouter"); -const LOGOUT = jest.fn(); -let loginMaster: ReactTestRenderer; -let loginHandle: ReactTestInstance; - -const DATA = "stuff"; -const MOCK_EVENT = { preventDefault: jest.fn(), target: { value: DATA } }; - -describe("Testing login component", () => { - beforeEach(async () => { - await act(async () => { - loginMaster = create( - - ); - }); - loginHandle = loginMaster.root.findByType(Login); - LOGOUT.mockClear(); - }); - - // These test whether logging in with a username and password (the strings) should result in errors with the username or password (the booleans) - test("Login: no data", () => { - testLogin("", "", true, true); - }); - - test("Login: no password", () => { - testLogin("Username", "", false, true); - }); - - test("Login: no username", () => { - testLogin("", "Password", true, false); - }); - - test("Login: all fields good", () => { - testLogin("Username", "Password", false, false); - }); -}); - -function testLogin( - username: string, - password: string, - goodUsername: boolean, - goodPassword: boolean -): void { - loginHandle.instance.setState({ username, password }); - loginHandle.instance.login(MOCK_EVENT); - expect(loginHandle.instance.state.error).toEqual({ - username: goodUsername, - password: goodPassword, - }); -} diff --git a/src/components/ProjectInvite/ProjectInvite.tsx b/src/components/Login/ProjectInvite.tsx similarity index 55% rename from src/components/ProjectInvite/ProjectInvite.tsx rename to src/components/Login/ProjectInvite.tsx index ee23c4a811..5590c11874 100644 --- a/src/components/ProjectInvite/ProjectInvite.tsx +++ b/src/components/Login/ProjectInvite.tsx @@ -3,17 +3,10 @@ import { useNavigate, useParams } from "react-router-dom"; import * as backend from "backend"; import InvalidLink from "components/InvalidLink"; -import { asyncSignUp } from "components/Login/Redux/LoginActions"; -import SignUp from "components/Login/SignUpPage/SignUpComponent"; -import { reset } from "rootActions"; -import { useAppDispatch, useAppSelector } from "types/hooks"; +import Signup from "components/Login/Signup"; import { Path } from "types/path"; export default function ProjectInvite(): ReactElement { - const status = useAppSelector((state) => state.loginState.signupStatus); - const failureMessage = useAppSelector((state) => state.loginState.error); - - const dispatch = useAppDispatch(); const navigate = useNavigate(); const { token, project } = useParams(); const [isValidLink, setIsValidLink] = useState(false); @@ -34,17 +27,7 @@ export default function ProjectInvite(): ReactElement { }); return isValidLink ? ( - { - dispatch(asyncSignUp(name, user, email, password)); - }} - reset={() => { - dispatch(reset()); - }} - returnToEmailInvite={validateLink} - /> + ) : ( ); diff --git a/src/components/Login/Redux/LoginActions.ts b/src/components/Login/Redux/LoginActions.ts index 58b67658f7..2d795b2c7d 100644 --- a/src/components/Login/Redux/LoginActions.ts +++ b/src/components/Login/Redux/LoginActions.ts @@ -12,7 +12,6 @@ import { setSignupFailureAction, setSignupSuccessAction, } from "components/Login/Redux/LoginReducer"; -import { reset } from "rootActions"; import { StoreStateDispatch } from "types/Redux/actions"; import { Path } from "types/path"; import { newUser } from "types/user"; @@ -63,17 +62,12 @@ export function asyncLogIn(username: string, password: string) { }; } -export function logoutAndResetStore() { - return (dispatch: StoreStateDispatch) => { - dispatch(reset()); - }; -} - export function asyncSignUp( name: string, username: string, email: string, - password: string + password: string, + onSuccess?: () => void ) { return async (dispatch: StoreStateDispatch) => { dispatch(signupAttempt(username)); @@ -84,6 +78,9 @@ export function asyncSignUp( .addUser(user) .then(() => { dispatch(signupSuccess()); + if (onSuccess) { + onSuccess(); + } setTimeout(() => { dispatch(asyncLogIn(username, password)); }, 1000); diff --git a/src/components/Login/Redux/tests/LoginActions.test.tsx b/src/components/Login/Redux/tests/LoginActions.test.tsx index 48c79e1de4..65c90d4e08 100644 --- a/src/components/Login/Redux/tests/LoginActions.test.tsx +++ b/src/components/Login/Redux/tests/LoginActions.test.tsx @@ -1,14 +1,7 @@ -import { PreloadedState } from "redux"; - import { User } from "api/models"; -import { defaultState } from "components/App/DefaultState"; -import { - asyncLogIn, - asyncSignUp, - logoutAndResetStore, -} from "components/Login/Redux/LoginActions"; +import { asyncLogIn, asyncSignUp } from "components/Login/Redux/LoginActions"; import { LoginStatus } from "components/Login/Redux/LoginReduxTypes"; -import { RootState, setupStore } from "store"; +import { setupStore } from "store"; import { newUser } from "types/user"; jest.mock("backend", () => ({ @@ -31,12 +24,6 @@ const mockUser = { email: mockEmail, }; -// Preloaded values for store when testing -const persistedDefaultState: PreloadedState = { - ...defaultState, - _persist: { version: 1, rehydrated: false }, -}; - beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); @@ -103,25 +90,4 @@ describe("LoginAction", () => { expect(mockAuthenticateUser).toBeCalledTimes(1); }); }); - - describe("logoutAndResetStore", () => { - it("correctly affects state", async () => { - const nonDefaultState = { - error: "nonempty-string", - loginStatus: LoginStatus.Success, - signupStatus: LoginStatus.Failure, - username: "nonempty-string", - }; - const store = setupStore({ - ...persistedDefaultState, - loginState: nonDefaultState, - }); - store.dispatch(logoutAndResetStore()); - const loginState = store.getState().loginState; - expect(loginState.error).toEqual(""); - expect(loginState.loginStatus).toEqual(LoginStatus.Default); - expect(loginState.signupStatus).toEqual(LoginStatus.Default); - expect(loginState.username).toEqual(""); - }); - }); }); diff --git a/src/components/Login/SignUpPage/SignUpComponent.tsx b/src/components/Login/SignUpPage/SignUpComponent.tsx deleted file mode 100644 index c27ab63e66..0000000000 --- a/src/components/Login/SignUpPage/SignUpComponent.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import ReCaptcha from "@matt-block/react-recaptcha-v2"; -import { - Button, - Card, - CardContent, - Grid, - TextField, - Typography, -} from "@mui/material"; -import { Component, ReactElement } from "react"; -import { withTranslation, WithTranslation } from "react-i18next"; - -import router from "browserRouter"; -import { LoadingDoneButton } from "components/Buttons"; -import { captchaStyle } from "components/Login/LoginPage/LoginComponent"; -import { LoginStatus } from "components/Login/Redux/LoginReduxTypes"; -import { Path } from "types/path"; -import { RuntimeConfig } from "types/runtimeConfig"; -import { - meetsPasswordRequirements, - meetsUsernameRequirements, -} from "utilities/utilities"; - -// Chrome silently converts non-ASCII characters in a Textfield of type="email". -// Use punycode.toUnicode() to convert them from punycode back to Unicode. -// eslint-disable-next-line @typescript-eslint/no-var-requires -const punycode = require("punycode/"); - -const idAffix = "signUp"; - -interface SignUpDispatchProps { - signUp?: ( - name: string, - username: string, - email: string, - password: string - ) => void; - reset: () => void; -} - -export interface SignUpStateProps { - failureMessage: string; - status: LoginStatus; -} - -interface SignUpProps - extends SignUpDispatchProps, - SignUpStateProps, - WithTranslation { - returnToEmailInvite?: () => void; -} - -interface SignUpState { - name: string; - username: string; - email: string; - password: string; - confirmPassword: string; - isVerified: boolean; - error: { - name: boolean; - username: boolean; - email: boolean; - password: boolean; - confirmPassword: boolean; - }; -} - -export class SignUp extends Component { - constructor(props: SignUpProps) { - super(props); - this.state = { - name: "", - username: "", - email: "", - password: "", - confirmPassword: "", - isVerified: !RuntimeConfig.getInstance().captchaRequired(), - error: { - name: false, - username: false, - email: false, - password: false, - confirmPassword: false, - }, - }; - } - - componentDidMount(): void { - const search = window.location.search; - const email = new URLSearchParams(search).get("email"); - if (email) { - this.setState({ email }); - } - this.props.reset(); - } - - /** Updates the state to match the value in a textbox */ - updateField( - e: React.ChangeEvent< - HTMLTextAreaElement | HTMLInputElement | HTMLSelectElement - >, - field: K - ): void { - const value = e.target.value; - this.setState({ [field]: value } as Pick); - this.setState((prevState) => ({ - error: { ...prevState.error, [field]: false }, - })); - } - - async checkUsername(): Promise { - if (!meetsUsernameRequirements(this.state.username)) { - this.setState((prevState) => ({ - error: { ...prevState.error, username: true }, - })); - } - } - - async signUp(e: React.FormEvent): Promise { - e.preventDefault(); - const name = this.state.name.trim(); - const username = this.state.username.trim(); - const email = punycode.toUnicode(this.state.email.trim()); - const password = this.state.password.trim(); - const confirmPassword = this.state.confirmPassword.trim(); - - // Error checking. - const error = { ...this.state.error }; - error.name = name === ""; - error.username = !meetsUsernameRequirements(username); - error.email = email === ""; - error.password = !meetsPasswordRequirements(password); - error.confirmPassword = password !== confirmPassword; - - if (Object.values(error).some((e) => e)) { - this.setState({ error }); - } else if (this.props.signUp) { - this.props.signUp(name, username, email, password); - // Temporary solution - Not sure how to force sign up to finish first - setTimeout(() => { - if (this.props.returnToEmailInvite) { - this.props.returnToEmailInvite(); - } - }, 1050); - } - } - - render(): ReactElement { - return ( - - -
this.signUp(e)}> - - {/* Title */} - - {this.props.t("login.signUpNew")} - - - {/* Name field */} - this.updateField(e, "name")} - error={this.state.error["name"]} - helperText={ - this.state.error["name"] - ? this.props.t("login.required") - : undefined - } - variant="outlined" - style={{ width: "100%" }} - margin="normal" - inputProps={{ maxLength: 100 }} - /> - - {/* Username field */} - this.updateField(e, "username")} - onBlur={() => this.checkUsername()} - error={this.state.error["username"]} - helperText={this.props.t("login.usernameRequirements")} - variant="outlined" - style={{ width: "100%" }} - margin="normal" - inputProps={{ maxLength: 100 }} - /> - - {/* email field */} - this.updateField(e, "email")} - error={this.state.error["email"]} - variant="outlined" - style={{ width: "100%" }} - margin="normal" - inputProps={{ maxLength: 100 }} - /> - - {/* Password field */} - this.updateField(e, "password")} - error={this.state.error["password"]} - helperText={this.props.t("login.passwordRequirements")} - variant="outlined" - style={{ width: "100%" }} - margin="normal" - inputProps={{ maxLength: 100 }} - /> - - {/* Confirm Password field */} - this.updateField(e, "confirmPassword")} - error={this.state.error["confirmPassword"]} - helperText={ - this.state.error["confirmPassword"] - ? this.props.t("login.confirmPasswordError") - : undefined - } - variant="outlined" - style={{ width: "100%" }} - margin="normal" - inputProps={{ maxLength: 100 }} - /> - - {/* "Failed to sign up" */} - {!!this.props.failureMessage && ( - - {this.props.t(this.props.failureMessage)} - - )} - - {RuntimeConfig.getInstance().captchaRequired() && ( -
- this.setState({ isVerified: true })} - onExpire={() => this.setState({ isVerified: false })} - onError={() => - console.error( - "Something went wrong, check your connection." - ) - } - /> -
- )} - - {/* Sign Up and Login buttons */} - - - - - - - {this.props.t("login.signUp")} - - - -
-
-
-
- ); - } -} - -export default withTranslation()(SignUp); diff --git a/src/components/Login/SignUpPage/index.ts b/src/components/Login/SignUpPage/index.ts deleted file mode 100644 index b95f6daf7b..0000000000 --- a/src/components/Login/SignUpPage/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { connect } from "react-redux"; - -import { asyncSignUp } from "components/Login/Redux/LoginActions"; -import SignUp, { - SignUpStateProps, -} from "components/Login/SignUpPage/SignUpComponent"; -import { reset } from "rootActions"; -import { StoreState } from "types"; -import { StoreStateDispatch } from "types/Redux/actions"; - -function mapStateToProps(state: StoreState): SignUpStateProps { - return { - failureMessage: state.loginState.error, - status: state.loginState.signupStatus, - }; -} - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function mapDispatchToProps(dispatch: StoreStateDispatch) { - return { - signUp: ( - name: string, - username: string, - email: string, - password: string - ) => { - dispatch(asyncSignUp(name, username, email, password)); - }, - reset: () => dispatch(reset()), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(SignUp); diff --git a/src/components/Login/SignUpPage/tests/SignUpComponent.test.tsx b/src/components/Login/SignUpPage/tests/SignUpComponent.test.tsx deleted file mode 100644 index 038b49973c..0000000000 --- a/src/components/Login/SignUpPage/tests/SignUpComponent.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { - ReactTestInstance, - ReactTestRenderer, - act, - create, -} from "react-test-renderer"; - -import "tests/reactI18nextMock"; - -import { LoginStatus } from "components/Login/Redux/LoginReduxTypes"; -import SignUp from "components/Login/SignUpPage/SignUpComponent"; - -jest.mock( - "@matt-block/react-recaptcha-v2", - () => - function MockRecaptcha() { - return
Recaptcha
; - } -); -jest.mock("browserRouter"); - -const mockReset = jest.fn(); -let signUpMaster: ReactTestRenderer; -let signUpHandle: ReactTestInstance; - -const DATA = "stuff"; -const MOCK_EVENT = { preventDefault: jest.fn(), target: { value: DATA } }; - -describe("Testing sign up component", () => { - beforeEach(async () => { - await act(async () => { - signUpMaster = create( - - ); - }); - signUpHandle = signUpMaster.root.findByType(SignUp); - mockReset.mockClear(); - }); - - describe("signUp", () => { - // Test whether various combinations of sign up data should result in errors - test("no data", () => { - testSignUp("", "", "", "", "", true, true, true, true, false); - }); - - test("confirm password doesn't match password", () => { - testSignUp( - "Frodo Baggins", - "underhill", - "a@b.c", - "1234567890", - "1234567899", - false, - false, - false, - false, - true - ); - }); - - test("username too short", () => { - testSignUp( - "Samwise Gamgee", - "sg", - "a@b.c", - "12345678", - "12345678", - false, - true, - false, - false, - false - ); - }); - - test("password too short", () => { - testSignUp( - "Bilbo Baggins", - "bbb", - "a@b.c", - "sting", - "sting", - false, - false, - false, - true, - false - ); - }); - }); -}); - -async function testSignUp( - name: string, - username: string, - email: string, - password: string, - confirmPassword: string, - error_name: boolean, - error_username: boolean, - error_email: boolean, - error_password: boolean, - error_confirmPassword: boolean -): Promise { - signUpHandle.instance.setState({ - name, - username, - email, - password, - confirmPassword, - }); - await signUpHandle.instance.signUp(MOCK_EVENT); - expect(signUpHandle.instance.state.error).toEqual({ - name: error_name, - username: error_username, - email: error_email, - password: error_password, - confirmPassword: error_confirmPassword, - }); -} diff --git a/src/components/Login/Signup.tsx b/src/components/Login/Signup.tsx new file mode 100644 index 0000000000..3f20468023 --- /dev/null +++ b/src/components/Login/Signup.tsx @@ -0,0 +1,285 @@ +import { + Button, + Card, + CardContent, + Grid, + TextField, + TextFieldProps, + Typography, +} from "@mui/material"; +import { + ChangeEvent, + FormEvent, + ReactElement, + useEffect, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +import router from "browserRouter"; +import { LoadingDoneButton } from "components/Buttons"; +import Captcha from "components/Login/Captcha"; +import { asyncSignUp } from "components/Login/Redux/LoginActions"; +import { LoginStatus } from "components/Login/Redux/LoginReduxTypes"; +import { reset } from "rootActions"; +import { StoreState } from "types"; +import { useAppDispatch, useAppSelector } from "types/hooks"; +import { Path } from "types/path"; +import { RuntimeConfig } from "types/runtimeConfig"; +import { + meetsPasswordRequirements, + meetsUsernameRequirements, +} from "utilities/utilities"; + +enum SignupField { + Email, + Name, + Password1, + Password2, + Username, +} + +type SignupError = Record; +type SignupText = Record; + +const defaultSignupError: SignupError = { + [SignupField.Email]: false, + [SignupField.Name]: false, + [SignupField.Password1]: false, + [SignupField.Password2]: false, + [SignupField.Username]: false, +}; +const defaultSignupText: SignupText = { + [SignupField.Email]: "", + [SignupField.Name]: "", + [SignupField.Password1]: "", + [SignupField.Password2]: "", + [SignupField.Username]: "", +}; + +export enum SignupId { + ButtonLogIn = "signup-log-in-button", + ButtonSignUp = "signup-sign-up-button", + FieldEmail = "signup-email-field", + FieldName = "signup-name-field", + FieldPassword1 = "signup-password1-field", + FieldPassword2 = "signup-password2-field", + FieldUsername = "signup-username-field", + Form = "signup-form", +} + +// Chrome silently converts non-ASCII characters in a Textfield of type="email". +// Use punycode.toUnicode() to convert them from punycode back to Unicode. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const punycode = require("punycode/"); + +interface SignupProps { + returnToEmailInvite?: () => void; +} + +/** The Signup page (also used for ProjectInvite) */ +export default function Signup(props: SignupProps): ReactElement { + const dispatch = useAppDispatch(); + + const { error, signupStatus } = useAppSelector( + (state: StoreState) => state.loginState + ); + + const [fieldError, setFieldError] = useState(defaultSignupError); + const [fieldText, setFieldText] = useState(defaultSignupText); + const [isVerified, setIsVerified] = useState( + !RuntimeConfig.getInstance().captchaRequired() + ); + + const { t } = useTranslation(); + + useEffect(() => { + const search = window.location.search; + const email = new URLSearchParams(search).get("email"); + if (email) { + setFieldText((prev) => ({ ...prev, [SignupField.Email]: email })); + } + dispatch(reset()); + }, [dispatch]); + + const errorField = (field: SignupField): void => { + setFieldError((prev) => ({ ...prev, [field]: true })); + }; + + const checkUsername = (): void => { + if (!meetsUsernameRequirements(fieldText[SignupField.Username])) { + errorField(SignupField.Username); + } + }; + + const updateField = ( + e: ChangeEvent, + field: SignupField + ): void => { + setFieldText((prev) => ({ ...prev, [field]: e.target.value })); + }; + + const signUp = async (e: FormEvent): Promise => { + e.preventDefault(); + + // Trim whitespace off fields. + const name = fieldText[SignupField.Name].trim(); + const username = fieldText[SignupField.Username].trim(); + const email = punycode.toUnicode(fieldText[SignupField.Email].trim()); + const password1 = fieldText[SignupField.Password1].trim(); + const password2 = fieldText[SignupField.Password2].trim(); + + // Check for bad field values. + const err: SignupError = { + [SignupField.Name]: !name, + [SignupField.Username]: !meetsUsernameRequirements(username), + [SignupField.Email]: !email, + [SignupField.Password1]: !meetsPasswordRequirements(password1), + [SignupField.Password2]: password1 !== password2, + }; + + if (Object.values(err).some((e) => e)) { + setFieldError(err); + } else { + await dispatch( + asyncSignUp(name, username, email, password1, props.returnToEmailInvite) + ); + } + }; + + const defaultTextFieldProps: TextFieldProps = { + inputProps: { maxLength: 100 }, + margin: "normal", + required: true, + style: { width: "100%" }, + variant: "outlined", + }; + + return ( + + +
+ + {/* Title */} + + {t("login.signUpNew")} + + + {/* Name field */} + updateField(e, SignupField.Name)} + value={fieldText[SignupField.Name]} + /> + + {/* Username field */} + checkUsername()} + onChange={(e) => updateField(e, SignupField.Username)} + value={fieldText[SignupField.Username]} + /> + + {/* Email field */} + updateField(e, SignupField.Email)} + type="email" + value={fieldText[SignupField.Email]} + /> + + {/* Password field */} + updateField(e, SignupField.Password1)} + type="password" + value={fieldText[SignupField.Password1]} + /> + + {/* Confirm Password field */} + updateField(e, SignupField.Password2)} + type="password" + value={fieldText[SignupField.Password2]} + /> + + {/* "Failed to sign up" */} + {!!error && ( + + {t(error)} + + )} + + setIsVerified(false)} + onSuccess={() => setIsVerified(true)} + /> + + {/* Sign Up and Log In buttons */} + + + + + + + {t("login.signUp")} + + + + +
+
+
+ ); +} diff --git a/src/components/Login/tests/Login.test.tsx b/src/components/Login/tests/Login.test.tsx new file mode 100644 index 0000000000..35dc189d4c --- /dev/null +++ b/src/components/Login/tests/Login.test.tsx @@ -0,0 +1,103 @@ +import { Provider } from "react-redux"; +import { + ReactTestInstance, + ReactTestRenderer, + act, + create, +} from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import "tests/reactI18nextMock"; + +import Login, { LoginId } from "components/Login/Login"; +import { defaultState as loginState } from "components/Login/Redux/LoginReduxTypes"; + +jest.mock( + "@matt-block/react-recaptcha-v2", + () => + function MockRecaptcha() { + return
Recaptcha
; + } +); +jest.mock("backend", () => ({ + getBannerText: () => Promise.resolve(""), +})); +jest.mock("browserRouter"); +jest.mock("components/Login/Redux/LoginActions", () => ({ + asyncLogIn: (...args: any[]) => mockAsyncLogIn(...args), +})); +jest.mock("types/hooks", () => { + return { + ...jest.requireActual("types/hooks"), + useAppDispatch: () => jest.fn(), + }; +}); + +const mockAsyncLogIn = jest.fn(); +const mockEvent = { preventDefault: jest.fn(), target: { value: "nonempty" } }; +const mockStore = configureMockStore()({ loginState }); + +let loginMaster: ReactTestRenderer; +let loginHandle: ReactTestInstance; + +const renderLogin = async (): Promise => { + await act(async () => { + loginMaster = create( + + + + ); + }); + loginHandle = loginMaster.root.findByType(Login); +}; + +beforeEach(async () => { + jest.clearAllMocks(); +}); + +describe("Login", () => { + describe("submit button", () => { + it("errors when no username", async () => { + await renderLogin(); + const fieldPass = loginHandle.findByProps({ id: LoginId.FieldPassword }); + const fieldUser = loginHandle.findByProps({ id: LoginId.FieldUsername }); + const form = loginHandle.findByProps({ id: LoginId.Form }); + await act(async () => { + await fieldPass.props.onChange(mockEvent); + await form.props.onSubmit(mockEvent); + }); + expect(fieldPass.props.error).toBeFalsy(); + expect(fieldUser.props.error).toBeTruthy(); + expect(mockAsyncLogIn).not.toBeCalled(); + }); + + it("errors when no password", async () => { + await renderLogin(); + const fieldPass = loginHandle.findByProps({ id: LoginId.FieldPassword }); + const fieldUser = loginHandle.findByProps({ id: LoginId.FieldUsername }); + const form = loginHandle.findByProps({ id: LoginId.Form }); + await act(async () => { + await fieldUser.props.onChange(mockEvent); + await form.props.onSubmit(mockEvent); + }); + expect(fieldPass.props.error).toBeTruthy(); + expect(fieldUser.props.error).toBeFalsy(); + expect(mockAsyncLogIn).not.toBeCalled(); + }); + + it("submits when username and password", async () => { + await renderLogin(); + const fieldPass = loginHandle.findByProps({ id: LoginId.FieldPassword }); + const fieldUser = loginHandle.findByProps({ id: LoginId.FieldUsername }); + const form = loginHandle.findByProps({ id: LoginId.Form }); + await act(async () => { + await fieldPass.props.onChange(mockEvent); + await fieldUser.props.onChange(mockEvent); + await form.props.onSubmit(mockEvent); + }); + expect(fieldPass.props.error).toBeFalsy(); + expect(fieldUser.props.error).toBeFalsy(); + expect(mockAsyncLogIn).toBeCalled(); + }); + }); +}); diff --git a/src/components/Login/tests/Signup.test.tsx b/src/components/Login/tests/Signup.test.tsx new file mode 100644 index 0000000000..b12429a002 --- /dev/null +++ b/src/components/Login/tests/Signup.test.tsx @@ -0,0 +1,163 @@ +import { ChangeEvent, FormEvent } from "react"; +import { Provider } from "react-redux"; +import { + ReactTestInstance, + ReactTestRenderer, + act, + create, +} from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import "tests/reactI18nextMock"; + +import { defaultState as loginState } from "components/Login/Redux/LoginReduxTypes"; +import Signup, { SignupId } from "components/Login/Signup"; + +jest.mock( + "@matt-block/react-recaptcha-v2", + () => + function MockRecaptcha() { + return
Recaptcha
; + } +); +jest.mock("backend", () => ({ + getBannerText: () => Promise.resolve(""), +})); +jest.mock("browserRouter"); +jest.mock("components/Login/Redux/LoginActions", () => ({ + asyncSignUp: (...args: any[]) => mockAsyncSignUp(...args), +})); +jest.mock("types/hooks", () => { + return { + ...jest.requireActual("types/hooks"), + useAppDispatch: () => jest.fn(), + }; +}); + +const mockAsyncSignUp = jest.fn(); +const mockChangeEvent = (text: string): Partial => ({ + target: { value: text } as HTMLTextAreaElement | HTMLInputElement, +}); +const mockFormEvent: Partial = { preventDefault: jest.fn() }; +const mockStore = configureMockStore()({ loginState }); + +const emailValid = "non@empty.com"; +const nameValid = "Mr. Nonempty"; +const passInvalid = "$hort"; +const passValid = "@-least+8_chars"; +const userInvalid = "no"; +const userValid = "3+ letters long"; + +let signupMaster: ReactTestRenderer; +let signupHandle: ReactTestInstance; + +const renderSignup = async (): Promise => { + await act(async () => { + signupMaster = create( + + + + ); + }); + signupHandle = signupMaster.root.findByType(Signup); +}; + +const typeInField = async (id: SignupId, text: string): Promise => { + const field = signupHandle.findByProps({ id }); + await act(async () => { + await field.props.onChange(mockChangeEvent(text)); + }); +}; + +const submitAndCheckError = async (id?: SignupId): Promise => { + // Submit the form. + const form = signupHandle.findByProps({ id: SignupId.Form }); + await act(async () => { + await form.props.onSubmit(mockFormEvent); + }); + + // Only the specified field should error. + Object.values(SignupId).forEach((val) => { + const field = signupHandle.findByProps({ id: val }); + if (val === id) { + expect(field.props.error).toBeTruthy(); + } else { + expect(field.props.error).toBeFalsy(); + } + }); + + // Expect signUp only when no field expected to error. + if (id === undefined) { + expect(mockAsyncSignUp).toBeCalled(); + } else { + expect(mockAsyncSignUp).not.toBeCalled(); + } +}; + +beforeEach(async () => { + jest.clearAllMocks(); +}); + +describe("Signup", () => { + describe("submit button", () => { + it("errors when email blank", async () => { + await renderSignup(); + await typeInField(SignupId.FieldEmail, ""); + await typeInField(SignupId.FieldName, nameValid); + await typeInField(SignupId.FieldPassword1, passValid); + await typeInField(SignupId.FieldPassword2, passValid); + await typeInField(SignupId.FieldUsername, userValid); + await submitAndCheckError(SignupId.FieldEmail); + }); + + it("errors when name blank", async () => { + await renderSignup(); + await typeInField(SignupId.FieldEmail, emailValid); + await typeInField(SignupId.FieldName, ""); + await typeInField(SignupId.FieldPassword1, passValid); + await typeInField(SignupId.FieldPassword2, passValid); + await typeInField(SignupId.FieldUsername, userValid); + await submitAndCheckError(SignupId.FieldName); + }); + + it("errors when password too short", async () => { + await renderSignup(); + await typeInField(SignupId.FieldEmail, emailValid); + await typeInField(SignupId.FieldName, nameValid); + await typeInField(SignupId.FieldPassword1, passInvalid); + await typeInField(SignupId.FieldPassword2, passInvalid); + await typeInField(SignupId.FieldUsername, userValid); + await submitAndCheckError(SignupId.FieldPassword1); + }); + + it("errors when passwords don't match", async () => { + await renderSignup(); + await typeInField(SignupId.FieldEmail, emailValid); + await typeInField(SignupId.FieldName, nameValid); + await typeInField(SignupId.FieldPassword1, passValid); + await typeInField(SignupId.FieldPassword2, `${passValid}++`); + await typeInField(SignupId.FieldUsername, userValid); + await submitAndCheckError(SignupId.FieldPassword2); + }); + + it("errors when username too short", async () => { + await renderSignup(); + await typeInField(SignupId.FieldEmail, emailValid); + await typeInField(SignupId.FieldName, nameValid); + await typeInField(SignupId.FieldPassword1, passValid); + await typeInField(SignupId.FieldPassword2, passValid); + await typeInField(SignupId.FieldUsername, userInvalid); + await submitAndCheckError(SignupId.FieldUsername); + }); + + it("submits when all fields valid", async () => { + await renderSignup(); + await typeInField(SignupId.FieldEmail, emailValid); + await typeInField(SignupId.FieldName, nameValid); + await typeInField(SignupId.FieldPassword1, passValid); + await typeInField(SignupId.FieldPassword2, passValid); + await typeInField(SignupId.FieldUsername, userValid); + await submitAndCheckError(); + }); + }); +}); diff --git a/src/types/appRoutes.tsx b/src/types/appRoutes.tsx index a0900f515f..5ead464862 100644 --- a/src/types/appRoutes.tsx +++ b/src/types/appRoutes.tsx @@ -2,12 +2,12 @@ import loadable from "@loadable/component"; import { RouteObject } from "react-router-dom"; import LandingPage from "components/LandingPage"; -import Login from "components/Login/LoginPage"; -import SignUp from "components/Login/SignUpPage"; +import Login from "components/Login/Login"; +import ProjectInvite from "components/Login/ProjectInvite"; +import Signup from "components/Login/Signup"; import PageNotFound from "components/PageNotFound/component"; import PasswordRequest from "components/PasswordReset/Request"; import PasswordReset from "components/PasswordReset/ResetPage"; -import ProjectInvite from "components/ProjectInvite/ProjectInvite"; import RequireAuth from "components/RequireAuth"; import { Path } from "types/path"; import { routerPath } from "utilities/pathUtilities"; @@ -33,8 +33,8 @@ export const appRoutes: RouteObject[] = [ element: , }, { - path: Path.SignUp, - element: , + path: Path.Signup, + element: , }, { path: `${Path.PwReset}/:token`, diff --git a/src/types/path.ts b/src/types/path.ts index f9b81996ae..257cfdc799 100644 --- a/src/types/path.ts +++ b/src/types/path.ts @@ -11,7 +11,7 @@ export enum Path { PwRequest = "/forgot/request", PwReset = "/forgot/reset", Root = "/", - SignUp = "/sign-up", + Signup = "/signup", SiteSettings = "/app/site-settings", Statistics = "/app/statistics", UserSettings = "/app/user-settings",