diff --git a/frontend/e2e/tests/invite-test.pw.ts b/frontend/e2e/tests/invite-test.pw.ts index bfe0fe1a721e..c6c51b508498 100644 --- a/frontend/e2e/tests/invite-test.pw.ts +++ b/frontend/e2e/tests/invite-test.pw.ts @@ -39,13 +39,12 @@ test.describe('Invite Tests', () => { await page.goto(inviteLink) // Wait for the form to load await waitForElementVisible(byId('firstName')) + await expect(page.getByRole('link', { name: 'Log in', exact: true })).toBeVisible() await setText(byId('firstName'), 'Bullet') await setText(byId('lastName'), 'Train') await setText(byId('email'), inviteEmail) await setText(byId('password'), PASSWORD) - await waitForElementVisible(byId('signup-btn')) - // Wait for form validation to complete before clicking - await page.waitForTimeout(500) + await expect(page.locator(byId('signup-btn'))).toBeEnabled() await click(byId('signup-btn')) log('Change email') await gotoAccountSettings() @@ -62,4 +61,26 @@ test.describe('Invite Tests', () => { await setText("[name='currentPassword']", PASSWORD) await click(byId('delete-account')) }); + + test('Signup sends users to login when their email already has an account @oss', async ({ page }) => { + const { click, setText, waitForElementVisible } = createHelpers(page); + + log('Open signup') + await page.goto('/signup') + await waitForElementVisible(byId('firstName')) + await expect(page.getByRole('link', { name: 'Log in', exact: true })).toBeVisible() + + log('Sign up with an email that already has an account') + await setText(byId('firstName'), 'Existing') + await setText(byId('lastName'), 'User') + await setText(byId('email'), E2E_USER) + await setText(byId('password'), PASSWORD) + await expect(page.locator(byId('signup-btn'))).toBeEnabled() + await click(byId('signup-btn')) + + log('Sent to login, prefilled, with the reason at the top') + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText('You already have an account')).toBeVisible() + await expect(page.locator(byId('email'))).toHaveValue(E2E_USER) + }); }); diff --git a/frontend/web/components/pages/HomePage.tsx b/frontend/web/components/pages/home-page/HomePage.tsx similarity index 82% rename from frontend/web/components/pages/HomePage.tsx rename to frontend/web/components/pages/home-page/HomePage.tsx index d6b19592cc38..1dafc2cc6dbc 100644 --- a/frontend/web/components/pages/HomePage.tsx +++ b/frontend/web/components/pages/home-page/HomePage.tsx @@ -1,4 +1,11 @@ -import React, { ChangeEvent, MouseEvent, useEffect, useState } from 'react' +import React, { + ChangeEvent, + FC, + MouseEvent, + useCallback, + useEffect, + useState, +} from 'react' import { useHistory, useLocation, withRouter } from 'react-router-dom' import { GoogleOAuthProvider } from '@react-oauth/google' import ForgotPasswordModal from 'components/modals/ForgotPasswordModal' @@ -9,12 +16,10 @@ import Constants from 'common/constants' import ErrorMessage from 'components/ErrorMessage' import Button from 'components/base/forms/Button' import PasswordRequirements from 'components/PasswordRequirements' -import { informationCircleOutline } from 'ionicons/icons' -import { IonIcon } from '@ionic/react' import { Icon } from 'components/icons' import classNames from 'classnames' import InfoMessage from 'components/InfoMessage' -import OnboardingPage from './OnboardingPage' +import OnboardingPage from 'components/pages/OnboardingPage' import isFreeEmailDomain from 'common/utils/isFreeEmailDomain' import InputGroup from 'components/base/forms/InputGroup' import { Link } from 'react-router-dom' @@ -33,6 +38,57 @@ import { useGetBuildVersionQuery } from 'common/services/useBuildVersion' import { useUTMs } from 'common/useUTMs' import useSignupExperiment from 'common/useSignupExperiment' +type EmailFieldError = string | string[] +type EmailError = { email?: EmailFieldError } | undefined + +// The error object never clears itself, so it only applies while the field still +// holds the address it was about. Case-insensitive to match the API's iexact. +const currentEmailError = ( + error: EmailError, + email: string, + submittedEmail: string | null, + isSaving: boolean, +) => + !isSaving && + submittedEmail !== null && + email.toLowerCase() === submittedEmail.toLowerCase() + ? error?.email + : undefined + +// Matched on the message because the API sends no error code for this. Raised +// by CustomUserCreateSerializer.validate. +const isEmailTaken = ( + error: EmailError, + email: string, + submittedEmail: string | null, + isSaving: boolean, +) => { + const current = currentEmailError(error, email, submittedEmail, isSaving) + const messages = Array.isArray(current) ? current : [current] + return messages.some((message) => + message?.toLowerCase().includes('already exists'), + ) +} + +// The banner is only for errors with no field to attach to. +const SIGNUP_FIELDS = ['email', 'first_name', 'last_name', 'password'] +const hasFieldError = (error?: Record) => + SIGNUP_FIELDS.some((field) => !!error?.[field]) + +// A component, not an effect in the page, because the error only exists inside +// the provider's render prop. +const RedirectWhenTaken: FC<{ taken: boolean; onTaken: () => void }> = ({ + onTaken, + taken, +}) => { + useEffect(() => { + if (taken) { + onTaken() + } + }, [taken, onTaken]) + return null +} + const HomePage: React.FC = () => { const history = useHistory() const location = useLocation() @@ -43,6 +99,10 @@ const HomePage: React.FC = () => { const [lastName, setLastName] = useState('') const [marketingConsentGiven] = useState(true) const [password, setPassword] = useState('') + // Null until a signup has been attempted. /login and /signup are the same + // component, so '' would match a login error that arrived before any signup. + const [submittedEmail, setSubmittedEmail] = useState(null) + const [emailAlreadyRegistered, setEmailAlreadyRegistered] = useState(false) const [samlError, setLocalError] = useState(false) const [samlLoading, setSamlLoading] = useState(false) @@ -171,6 +231,13 @@ const HomePage: React.FC = () => { const redirect = Utils.fromParam().redirect ? `?redirect=${Utils.fromParam().redirect}` : '' + // Pushed rather than replaced, so Back returns to the signup form with what + // was typed still in it. + const goToLoginAsRegistered = useCallback(() => { + setEmailAlreadyRegistered(true) + history.push(`/login${redirect}`) + }, [history, redirect]) + const currentLocation = `${document.location.pathname}${ document.location.search || '' }` @@ -386,17 +453,27 @@ const HomePage: React.FC = () => { name='form' onSubmit={(e) => { e.preventDefault() + setEmailAlreadyRegistered(false) login({ email, password }) }} > - {isInvite && ( -
- - + {emailAlreadyRegistered && ( +
+ + + +

+ You already have an account, log in to + continue +

+
+ )} + {isInvite && !emailAlreadyRegistered && ( +
+ + -

+

Log in to accept your invite

@@ -405,9 +482,14 @@ const HomePage: React.FC = () => { {
- {(AccountStore.error || samlError) && ( -
- -
- )} + {!emailAlreadyRegistered && + (AccountStore.error || samlError) && ( +
+ +
+ )} )} @@ -525,6 +608,7 @@ const HomePage: React.FC = () => { const isInvite = document.location.href.indexOf('invite') !== -1 + setSubmittedEmail(email) register( { email, @@ -539,7 +623,16 @@ const HomePage: React.FC = () => { ) }} > - {error && ( + + {error && !hasFieldError(error) && (
{ )} {isInvite && ( -
-
- - - -

- Create an account to accept your invite -

-
- - Have an account?{' '} - - +
+ + + +

+ Create an account to accept your invite +

)}
@@ -623,7 +700,12 @@ const HomePage: React.FC = () => { inputProps={{ autoComplete: 'on', className: 'full-width', - error: error && error.email, + error: currentEmailError( + error, + email, + submittedEmail, + isSaving, + ), name: 'email', }} onChange={( @@ -687,6 +769,16 @@ const HomePage: React.FC = () => { )} + + Have an account?{' '} + + )}
diff --git a/frontend/web/components/pages/home-page/index.ts b/frontend/web/components/pages/home-page/index.ts new file mode 100644 index 000000000000..80f77988ea0f --- /dev/null +++ b/frontend/web/components/pages/home-page/index.ts @@ -0,0 +1 @@ +export { default } from './HomePage' diff --git a/frontend/web/routes.js b/frontend/web/routes.js index 8621d9c1fa25..7486352dd8c5 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -2,7 +2,7 @@ import React from 'react' import { Route, Switch } from 'react-router-dom' import App from './components/App' // App Wrapper -import HomePage from './components/pages/HomePage' +import HomePage from './components/pages/home-page' import Maintenance from './components/Maintenance' import CreateOrganisationPage from './components/pages/CreateOrganisationPage' import CreateEnvironmentPage from './components/pages/CreateEnvironmentPage' diff --git a/frontend/web/styles/project/_forms.scss b/frontend/web/styles/project/_forms.scss index a7c6f7caa560..9cf77673df9c 100644 --- a/frontend/web/styles/project/_forms.scss +++ b/frontend/web/styles/project/_forms.scss @@ -171,7 +171,6 @@ label { align-self: center; &__icon { color: $success; - font-size: 2em; } &__text { color: $success;