From 690a5b12649ebb75ff31f7e5c2ba9f2b7cacc41d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 31 Jul 2026 10:44:45 -0300 Subject: [PATCH 01/10] fix(forms): colour a control when it has an error A field with a red message under it kept its normal border. InputGroup fed hasError into aria-invalid and nothing else, so assistive tech was told the field was invalid and sighted users were not. isValid={false} was not enough: Input gates that behind shouldValidate, which only becomes true once the field has been blurred, so submitting with Enter left the border neutral. That gate is deliberate, it stops a pristine form going red before anyone has typed, so Input takes isInvalid for the case where the answer is already known. InputGroup forwards its existing isInvalid prop rather than deriving it from any error, so the other 106 call sites keep the appearance they have today. That prop only ever reached the wrapper before, where no styling reads it, so FeatureValueTab starts working as a side effect. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/web/components/base/forms/Input.tsx | 9 +++++++-- frontend/web/components/base/forms/InputGroup.tsx | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/base/forms/Input.tsx b/frontend/web/components/base/forms/Input.tsx index bfbbda3587cc..6cd5d3c4881a 100644 --- a/frontend/web/components/base/forms/Input.tsx +++ b/frontend/web/components/base/forms/Input.tsx @@ -24,6 +24,10 @@ export interface InputProps autoValidate?: boolean centered?: boolean inputClassName?: string + // Already known to be wrong, e.g. the API rejected it. Shows immediately, + // unlike isValid, which waits until the field has been touched so a pristine + // form is not red before anyone has typed. + isInvalid?: boolean isValid?: boolean ref?: Ref search?: boolean @@ -53,6 +57,7 @@ const Input: React.FC = ({ className = '', disabled, inputClassName, + isInvalid = false, isValid = true, onBlur: onBlurProp, onChange, @@ -101,8 +106,8 @@ const Input: React.FC = ({ onKeyDownProp?.(e) } - const invalid = shouldValidate && !isValid - const success = isValid && showSuccess + const invalid = isInvalid || (shouldValidate && !isValid) + const success = isValid && !invalid && showSuccess const sizeClassName = size ? sizeClassNames[size] : '' const containerClassName = cn( { diff --git a/frontend/web/components/base/forms/InputGroup.tsx b/frontend/web/components/base/forms/InputGroup.tsx index cb2c6d71a6fd..e5492fb78e6d 100644 --- a/frontend/web/components/base/forms/InputGroup.tsx +++ b/frontend/web/components/base/forms/InputGroup.tsx @@ -152,6 +152,10 @@ const InputGroup: FC = ({ inputRef.current = c }} {...restInputProps} + // Forwarded so the control can be coloured immediately, without + // waiting for the field to be touched. Only reached the wrapper + // before, where no styling picks it up. + isInvalid={!!isInvalid} isValid={ isValid === null || isValid === undefined ? undefined From 95685478ac77479c2d7bddbff068982a6cbf91c0 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 31 Jul 2026 10:45:30 -0300 Subject: [PATCH 02/10] fix(signup): give the signup form a route to login Submitting with an address that already has an account produced "Email already exists. Please log in." and nothing on the page that logs you in. /signup had no link to login at all, and on the invite screen the only one sat above the first field, three fields from the error. #5077 moved it into the invite-only branch in February 2025 and plain signup lost it. - A route back to login under Create Account, on every signup form. - Once the address is known to be taken: the row reads "You already have an account. Log in", highlights twice, and Create Account is disabled since submitting again can only fail. - One message per failure. The generic "Please check your details and try again" banner is suppressed when the error belongs to a field. - The email field is coloured, and the message, border and disabled state all clear on the first keystroke, because the error object never clears itself. - The invite notification uses our own Icon, sized to its text, and the block keeps its spacing. The highlight is finite and settles to a static tint under prefers-reduced-motion, since a continuous blink cannot be dismissed. HomePage moves into its own folder, having picked up a stylesheet. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/e2e/tests/invite-test.pw.ts | 26 ++++ .../components/pages/home-page/HomePage.scss | 30 +++++ .../pages/{ => home-page}/HomePage.tsx | 120 +++++++++++++----- .../web/components/pages/home-page/index.ts | 1 + frontend/web/routes.js | 2 +- frontend/web/styles/project/_forms.scss | 1 - 6 files changed, 143 insertions(+), 37 deletions(-) create mode 100644 frontend/web/components/pages/home-page/HomePage.scss rename frontend/web/components/pages/{ => home-page}/HomePage.tsx (87%) create mode 100644 frontend/web/components/pages/home-page/index.ts diff --git a/frontend/e2e/tests/invite-test.pw.ts b/frontend/e2e/tests/invite-test.pw.ts index bfe0fe1a721e..c4f05e780865 100644 --- a/frontend/e2e/tests/invite-test.pw.ts +++ b/frontend/e2e/tests/invite-test.pw.ts @@ -39,6 +39,8 @@ test.describe('Invite Tests', () => { await page.goto(inviteLink) // Wait for the form to load await waitForElementVisible(byId('firstName')) + // Invitees who already have an account can only get in by logging in. + 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) @@ -62,4 +64,28 @@ test.describe('Invite Tests', () => { await setText("[name='currentPassword']", PASSWORD) await click(byId('delete-account')) }); + + test('Signup points users at 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 waitForElementVisible(byId('signup-btn')) + // Wait for form validation to complete before clicking + await page.waitForTimeout(500) + await click(byId('signup-btn')) + + log('Error explains why, and the way out is still on the page') + await expect(page.getByText('Email already exists')).toBeVisible() + await expect(page.getByText('Please check your details and try again')).toHaveCount(0) + await expect(page.getByRole('link', { name: 'Log in', exact: true })).toBeVisible() + }); }); diff --git a/frontend/web/components/pages/home-page/HomePage.scss b/frontend/web/components/pages/home-page/HomePage.scss new file mode 100644 index 000000000000..f9ab5b4d2c69 --- /dev/null +++ b/frontend/web/components/pages/home-page/HomePage.scss @@ -0,0 +1,30 @@ +// Draws the eye to the way out once the form knows the address already has an +// account. Finite on purpose: something that blinks until you act on it cannot +// be dismissed, which is what WCAG 2.2.2 asks for. +@keyframes login-prompt-highlight { + 0%, + 100% { + background-color: transparent; + } + 50% { + background-color: var(--color-surface-action-subtle); + } +} + +.login-prompt { + padding: 8px 12px; + border-radius: var(--radius-md); + + &--highlight { + animation: login-prompt-highlight 0.7s ease-in-out 2; + } +} + +// The signal still needs to land, so it stays as a static tint rather than +// disappearing along with the motion. +@media (prefers-reduced-motion: reduce) { + .login-prompt--highlight { + animation: none; + background-color: var(--color-surface-action-subtle); + } +} diff --git a/frontend/web/components/pages/HomePage.tsx b/frontend/web/components/pages/home-page/HomePage.tsx similarity index 87% rename from frontend/web/components/pages/HomePage.tsx rename to frontend/web/components/pages/home-page/HomePage.tsx index d6b19592cc38..6dcc450938bf 100644 --- a/frontend/web/components/pages/HomePage.tsx +++ b/frontend/web/components/pages/home-page/HomePage.tsx @@ -9,12 +9,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' @@ -32,6 +30,37 @@ import { LoginRequest, RegisterRequest } from 'common/types/requests' import { useGetBuildVersionQuery } from 'common/services/useBuildVersion' import { useUTMs } from 'common/useUTMs' import useSignupExperiment from 'common/useSignupExperiment' +import './HomePage.scss' + +type EmailError = { email?: string | string[] } | undefined + +// The error object never clears itself, and it describes the address that was +// submitted, so it stops applying the moment the field holds something else. +const currentEmailError = ( + error: EmailError, + email: string, + submittedEmail: string, +) => (email === submittedEmail ? 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, +) => { + const current = currentEmailError(error, email, submittedEmail) + const messages = Array.isArray(current) ? current : [current] + return messages.some((message) => + message?.toLowerCase().includes('already exists'), + ) +} + +// The banner is for errors with no field to attach to. Anything belonging to a +// field is shown under that field instead, so one failure gives one message. +const SIGNUP_FIELDS = ['email', 'first_name', 'last_name', 'password'] +const hasFieldError = (error?: Record) => + SIGNUP_FIELDS.some((field) => !!error?.[field]) const HomePage: React.FC = () => { const history = useHistory() @@ -43,6 +72,9 @@ const HomePage: React.FC = () => { const [lastName, setLastName] = useState('') const [marketingConsentGiven] = useState(true) const [password, setPassword] = useState('') + // The error object never clears on its own, so remember which address it was + // about. + const [submittedEmail, setSubmittedEmail] = useState('') const [samlError, setLocalError] = useState(false) const [samlLoading, setSamlLoading] = useState(false) @@ -390,13 +422,11 @@ const HomePage: React.FC = () => { }} > {isInvite && ( -
- - +
+ + -

+

Log in to accept your invite

@@ -525,6 +555,7 @@ const HomePage: React.FC = () => { const isInvite = document.location.href.indexOf('invite') !== -1 + setSubmittedEmail(email) register( { email, @@ -539,7 +570,7 @@ 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 +

)}
@@ -620,10 +635,21 @@ const HomePage: React.FC = () => { { !allRequirementsMet || !firstName.trim() || !lastName.trim() || - blockGenericEmailDomain + blockGenericEmailDomain || + isEmailTaken(error, email, submittedEmail) } className='px-4 mt-3 full-width' type='submit' @@ -687,6 +714,29 @@ const HomePage: React.FC = () => { )} + + {isEmailTaken(error, email, submittedEmail) + ? 'You already have an account.' + : '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; From d849dfe3923f907dc3c9e35a8f119384ba362375 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 31 Jul 2026 13:18:26 -0300 Subject: [PATCH 03/10] fix(signup): wait for the button to be enabled, name the email error union The E2E slept 500ms and clicked. The button stays disabled until the password requirements pass, so it now asserts enabled instead. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/e2e/tests/invite-test.pw.ts | 12 ++++++------ frontend/web/components/pages/home-page/HomePage.tsx | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/e2e/tests/invite-test.pw.ts b/frontend/e2e/tests/invite-test.pw.ts index c4f05e780865..3c213c80e4dc 100644 --- a/frontend/e2e/tests/invite-test.pw.ts +++ b/frontend/e2e/tests/invite-test.pw.ts @@ -45,9 +45,9 @@ test.describe('Invite Tests', () => { 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) + // Enabled, not just visible: the button stays disabled until the password + // requirements pass. + await expect(page.locator(byId('signup-btn'))).toBeEnabled() await click(byId('signup-btn')) log('Change email') await gotoAccountSettings() @@ -78,9 +78,9 @@ test.describe('Invite Tests', () => { await setText(byId('lastName'), 'User') await setText(byId('email'), E2E_USER) await setText(byId('password'), PASSWORD) - await waitForElementVisible(byId('signup-btn')) - // Wait for form validation to complete before clicking - await page.waitForTimeout(500) + // Enabled, not just visible: the button stays disabled until the password + // requirements pass. + await expect(page.locator(byId('signup-btn'))).toBeEnabled() await click(byId('signup-btn')) log('Error explains why, and the way out is still on the page') diff --git a/frontend/web/components/pages/home-page/HomePage.tsx b/frontend/web/components/pages/home-page/HomePage.tsx index 6dcc450938bf..b2d3395ce3c6 100644 --- a/frontend/web/components/pages/home-page/HomePage.tsx +++ b/frontend/web/components/pages/home-page/HomePage.tsx @@ -32,7 +32,8 @@ import { useUTMs } from 'common/useUTMs' import useSignupExperiment from 'common/useSignupExperiment' import './HomePage.scss' -type EmailError = { email?: string | string[] } | undefined +type EmailFieldError = string | string[] +type EmailError = { email?: EmailFieldError } | undefined // The error object never clears itself, and it describes the address that was // submitted, so it stops applying the moment the field holds something else. From 696949ff28c5d886b717b642c2eaf76e9d5ce114 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 3 Aug 2026 16:25:08 -0300 Subject: [PATCH 04/10] fix(signup): match the email case-insensitively and hide the error mid-request The comparison was exact while the API looks the address up with iexact, so changing only the casing cleared the message and re-enabled the button on an address the API would still reject. The error also survived into the next attempt. Submitting a corrected address set submittedEmail to it while the old error was still in the store, so for the length of the request the form claimed the new address was taken. Both spotted by Wadii in review. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/pages/home-page/HomePage.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/web/components/pages/home-page/HomePage.tsx b/frontend/web/components/pages/home-page/HomePage.tsx index b2d3395ce3c6..d90a1e8f65f9 100644 --- a/frontend/web/components/pages/home-page/HomePage.tsx +++ b/frontend/web/components/pages/home-page/HomePage.tsx @@ -36,12 +36,19 @@ type EmailFieldError = string | string[] type EmailError = { email?: EmailFieldError } | undefined // The error object never clears itself, and it describes the address that was -// submitted, so it stops applying the moment the field holds something else. +// submitted, so it stops applying the moment the field holds something else, or +// while a new attempt is in flight and has not answered yet. +// +// Case-insensitive to match the API, which looks the address up with iexact. const currentEmailError = ( error: EmailError, email: string, submittedEmail: string, -) => (email === submittedEmail ? error?.email : undefined) + isSaving: boolean, +) => + !isSaving && email.toLowerCase() === submittedEmail.toLowerCase() + ? error?.email + : undefined // Matched on the message because the API sends no error code for this. Raised // by CustomUserCreateSerializer.validate. @@ -49,8 +56,9 @@ const isEmailTaken = ( error: EmailError, email: string, submittedEmail: string, + isSaving: boolean, ) => { - const current = currentEmailError(error, email, submittedEmail) + const current = currentEmailError(error, email, submittedEmail, isSaving) const messages = Array.isArray(current) ? current : [current] return messages.some((message) => message?.toLowerCase().includes('already exists'), @@ -641,6 +649,7 @@ const HomePage: React.FC = () => { error, email, submittedEmail, + isSaving, ) } inputProps={{ @@ -650,6 +659,7 @@ const HomePage: React.FC = () => { error, email, submittedEmail, + isSaving, ), name: 'email', }} @@ -703,7 +713,12 @@ const HomePage: React.FC = () => { !firstName.trim() || !lastName.trim() || blockGenericEmailDomain || - isEmailTaken(error, email, submittedEmail) + isEmailTaken( + error, + email, + submittedEmail, + isSaving, + ) } className='px-4 mt-3 full-width' type='submit' @@ -723,11 +738,12 @@ const HomePage: React.FC = () => { error, email, submittedEmail, + isSaving, ), }, )} > - {isEmailTaken(error, email, submittedEmail) + {isEmailTaken(error, email, submittedEmail, isSaving) ? 'You already have an account.' : 'Have an account?'}{' '}
- {(AccountStore.error || samlError) && ( -
- -
- )} + {!emailAlreadyRegistered && + (AccountStore.error || samlError) && ( +
+ +
+ )} )} From 9c9063e0a01f9db15cda9985105ba45dcf890169 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 4 Aug 2026 08:49:54 -0300 Subject: [PATCH 08/10] chore(signup): keep only the comments that carry a reason Cut the three in the E2E that restated their assertions, the InputGroup one that duplicated the prop doc on Input, and the state one that restated its variable name. Trimmed four others. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/e2e/tests/invite-test.pw.ts | 5 ---- .../web/components/base/forms/InputGroup.tsx | 3 --- .../components/pages/home-page/HomePage.tsx | 26 +++++++------------ 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/frontend/e2e/tests/invite-test.pw.ts b/frontend/e2e/tests/invite-test.pw.ts index 37ef2d4bde2a..c6c51b508498 100644 --- a/frontend/e2e/tests/invite-test.pw.ts +++ b/frontend/e2e/tests/invite-test.pw.ts @@ -39,14 +39,11 @@ test.describe('Invite Tests', () => { await page.goto(inviteLink) // Wait for the form to load await waitForElementVisible(byId('firstName')) - // Invitees who already have an account can only get in by logging in. 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) - // Enabled, not just visible: the button stays disabled until the password - // requirements pass. await expect(page.locator(byId('signup-btn'))).toBeEnabled() await click(byId('signup-btn')) log('Change email') @@ -78,8 +75,6 @@ test.describe('Invite Tests', () => { await setText(byId('lastName'), 'User') await setText(byId('email'), E2E_USER) await setText(byId('password'), PASSWORD) - // Enabled, not just visible: the button stays disabled until the password - // requirements pass. await expect(page.locator(byId('signup-btn'))).toBeEnabled() await click(byId('signup-btn')) diff --git a/frontend/web/components/base/forms/InputGroup.tsx b/frontend/web/components/base/forms/InputGroup.tsx index e5492fb78e6d..95d713dd74f7 100644 --- a/frontend/web/components/base/forms/InputGroup.tsx +++ b/frontend/web/components/base/forms/InputGroup.tsx @@ -152,9 +152,6 @@ const InputGroup: FC = ({ inputRef.current = c }} {...restInputProps} - // Forwarded so the control can be coloured immediately, without - // waiting for the field to be touched. Only reached the wrapper - // before, where no styling picks it up. isInvalid={!!isInvalid} isValid={ isValid === null || isValid === undefined diff --git a/frontend/web/components/pages/home-page/HomePage.tsx b/frontend/web/components/pages/home-page/HomePage.tsx index 39fe8f930ce3..5c9bee076280 100644 --- a/frontend/web/components/pages/home-page/HomePage.tsx +++ b/frontend/web/components/pages/home-page/HomePage.tsx @@ -41,11 +41,8 @@ import useSignupExperiment from 'common/useSignupExperiment' type EmailFieldError = string | string[] type EmailError = { email?: EmailFieldError } | undefined -// The error object never clears itself, and it describes the address that was -// submitted, so it stops applying the moment the field holds something else, or -// while a new attempt is in flight and has not answered yet. -// -// Case-insensitive to match the API, which looks the address up with iexact. +// 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, @@ -73,15 +70,13 @@ const isEmailTaken = ( ) } -// The banner is for errors with no field to attach to. Anything belonging to a -// field is shown under that field instead, so one failure gives one message. +// 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]) -// Runs the redirect once signup reports the address is taken. A component -// because the error only exists inside the provider's render prop, and a side -// effect does not belong in render. +// 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, @@ -104,12 +99,9 @@ const HomePage: React.FC = () => { const [lastName, setLastName] = useState('') const [marketingConsentGiven] = useState(true) const [password, setPassword] = useState('') - // Which address the last signup attempt used, null until there has been one. - // Both /login and /signup render this component, so an empty string would - // match a login error that arrived before anyone submitted a signup. + // 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) - // Set when signup sent us to login because the address already had an - // account, so login can say why you are there. const [emailAlreadyRegistered, setEmailAlreadyRegistered] = useState(false) const [samlError, setLocalError] = useState(false) @@ -490,8 +482,8 @@ const HomePage: React.FC = () => { Date: Tue, 4 Aug 2026 09:04:45 -0300 Subject: [PATCH 09/10] refactor: reuse autoValidate rather than add a second validity prop isInvalid and isValid read as opposites but were not, so the pair was easy to misuse. autoValidate already meant "do not wait for a touch", it was just read once at mount and so could not react to an error arriving later. Deriving it instead covers the API-rejected case, and InputGroup keeps its own isInvalid prop as the caller-facing name. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/web/components/base/forms/Input.tsx | 17 ++++++----------- .../web/components/base/forms/InputGroup.tsx | 16 ++++++++++------ 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/frontend/web/components/base/forms/Input.tsx b/frontend/web/components/base/forms/Input.tsx index 6cd5d3c4881a..2208333e981c 100644 --- a/frontend/web/components/base/forms/Input.tsx +++ b/frontend/web/components/base/forms/Input.tsx @@ -21,13 +21,11 @@ export interface InputMethods { export interface InputProps extends Omit, 'size'> { + // Skips the wait for the field to be touched, so a validity known up front, + // e.g. the API rejected the value, shows straight away. autoValidate?: boolean centered?: boolean inputClassName?: string - // Already known to be wrong, e.g. the API rejected it. Shows immediately, - // unlike isValid, which waits until the field has been touched so a pristine - // form is not red before anyone has typed. - isInvalid?: boolean isValid?: boolean ref?: Ref search?: boolean @@ -57,7 +55,6 @@ const Input: React.FC = ({ className = '', disabled, inputClassName, - isInvalid = false, isValid = true, onBlur: onBlurProp, onChange, @@ -74,9 +71,7 @@ const Input: React.FC = ({ }) => { const inputRef = useRef(null) const [isFocused, setIsFocused] = useState(false) - const [shouldValidate, setShouldValidate] = useState( - !!value || !!autoValidate, - ) + const [hasBeenTouched, setHasBeenTouched] = useState(!!value) const [type, setType] = useState(typeProp) // No-op under E2E to avoid programmatic focus stealing during tests; native @@ -95,7 +90,7 @@ const Input: React.FC = ({ const onBlur = (e: FocusEvent) => { setIsFocused(false) - setShouldValidate(true) + setHasBeenTouched(true) onBlurProp?.(e) } @@ -106,8 +101,8 @@ const Input: React.FC = ({ onKeyDownProp?.(e) } - const invalid = isInvalid || (shouldValidate && !isValid) - const success = isValid && !invalid && showSuccess + const invalid = (hasBeenTouched || !!autoValidate) && !isValid + const success = isValid && showSuccess const sizeClassName = size ? sizeClassNames[size] : '' const containerClassName = cn( { diff --git a/frontend/web/components/base/forms/InputGroup.tsx b/frontend/web/components/base/forms/InputGroup.tsx index 95d713dd74f7..3f72752241d2 100644 --- a/frontend/web/components/base/forms/InputGroup.tsx +++ b/frontend/web/components/base/forms/InputGroup.tsx @@ -94,6 +94,14 @@ const InputGroup: FC = ({ // the message for this field (htmlFor/id/aria-describedby all share `id`). const errorId = `${id}-error` const hasError = Array.isArray(error) ? error.length > 0 : !!error + // isInvalid is the caller stating the value is wrong, so it wins over the + // computed validity and turns off the wait for a blur. + let inputIsValid: boolean | undefined + if (isInvalid) { + inputIsValid = false + } else if (isValid !== null && isValid !== undefined) { + inputIsValid = !!isValid + } let errorContent: ReactNode = null if (typeof error === 'string') { errorContent = error @@ -152,12 +160,8 @@ const InputGroup: FC = ({ inputRef.current = c }} {...restInputProps} - isInvalid={!!isInvalid} - isValid={ - isValid === null || isValid === undefined - ? undefined - : !!isValid - } + autoValidate={!!isInvalid} + isValid={inputIsValid} disabled={disabled} defaultValue={defaultValue} value={value} From 10417c6fad423053c9f5f1878636d833ca8f661e Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 4 Aug 2026 09:10:54 -0300 Subject: [PATCH 10/10] revert: drop the Input validity change from this PR The redirect means an already-registered email never dwells on the signup form, so colouring its border bought nothing here. Splitting it out keeps a shared control used across the app out of a signup fix. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/web/components/base/forms/Input.tsx | 10 +++++----- frontend/web/components/base/forms/InputGroup.tsx | 15 +++++---------- .../web/components/pages/home-page/HomePage.tsx | 8 -------- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/frontend/web/components/base/forms/Input.tsx b/frontend/web/components/base/forms/Input.tsx index 2208333e981c..bfbbda3587cc 100644 --- a/frontend/web/components/base/forms/Input.tsx +++ b/frontend/web/components/base/forms/Input.tsx @@ -21,8 +21,6 @@ export interface InputMethods { export interface InputProps extends Omit, 'size'> { - // Skips the wait for the field to be touched, so a validity known up front, - // e.g. the API rejected the value, shows straight away. autoValidate?: boolean centered?: boolean inputClassName?: string @@ -71,7 +69,9 @@ const Input: React.FC = ({ }) => { const inputRef = useRef(null) const [isFocused, setIsFocused] = useState(false) - const [hasBeenTouched, setHasBeenTouched] = useState(!!value) + const [shouldValidate, setShouldValidate] = useState( + !!value || !!autoValidate, + ) const [type, setType] = useState(typeProp) // No-op under E2E to avoid programmatic focus stealing during tests; native @@ -90,7 +90,7 @@ const Input: React.FC = ({ const onBlur = (e: FocusEvent) => { setIsFocused(false) - setHasBeenTouched(true) + setShouldValidate(true) onBlurProp?.(e) } @@ -101,7 +101,7 @@ const Input: React.FC = ({ onKeyDownProp?.(e) } - const invalid = (hasBeenTouched || !!autoValidate) && !isValid + const invalid = shouldValidate && !isValid const success = isValid && showSuccess const sizeClassName = size ? sizeClassNames[size] : '' const containerClassName = cn( diff --git a/frontend/web/components/base/forms/InputGroup.tsx b/frontend/web/components/base/forms/InputGroup.tsx index 3f72752241d2..cb2c6d71a6fd 100644 --- a/frontend/web/components/base/forms/InputGroup.tsx +++ b/frontend/web/components/base/forms/InputGroup.tsx @@ -94,14 +94,6 @@ const InputGroup: FC = ({ // the message for this field (htmlFor/id/aria-describedby all share `id`). const errorId = `${id}-error` const hasError = Array.isArray(error) ? error.length > 0 : !!error - // isInvalid is the caller stating the value is wrong, so it wins over the - // computed validity and turns off the wait for a blur. - let inputIsValid: boolean | undefined - if (isInvalid) { - inputIsValid = false - } else if (isValid !== null && isValid !== undefined) { - inputIsValid = !!isValid - } let errorContent: ReactNode = null if (typeof error === 'string') { errorContent = error @@ -160,8 +152,11 @@ const InputGroup: FC = ({ inputRef.current = c }} {...restInputProps} - autoValidate={!!isInvalid} - isValid={inputIsValid} + isValid={ + isValid === null || isValid === undefined + ? undefined + : !!isValid + } disabled={disabled} defaultValue={defaultValue} value={value} diff --git a/frontend/web/components/pages/home-page/HomePage.tsx b/frontend/web/components/pages/home-page/HomePage.tsx index 5c9bee076280..1dafc2cc6dbc 100644 --- a/frontend/web/components/pages/home-page/HomePage.tsx +++ b/frontend/web/components/pages/home-page/HomePage.tsx @@ -697,14 +697,6 @@ const HomePage: React.FC = () => {