Skip to content

fix(#857): auth errors now say which field they are about - #866

Merged
TortoiseWolfe merged 1 commit into
mainfrom
fix/857-auth-field-error-association
Aug 20, 2026
Merged

fix(#857): auth errors now say which field they are about#866
TortoiseWolfe merged 1 commit into
mainfrom
fix/857-auth-field-error-association

Conversation

@TortoiseWolfe

Copy link
Copy Markdown
Owner

The bug

SignInForm and SignUpForm funnelled every error — including field-specific validation — into one form-level alert with no id, and no input carried aria-invalid or aria-describedby. A screen-reader user heard "Passwords do not match" with nothing saying which of the two password boxes to fix.

SignUpForm is the sharper case: three distinct conditions shared one string.

line message field it's really about
:59 "Invalid email address" / TLD error email
:71 "Password must be at least 8 characters" password
:77 "Passwords do not match" confirm-password

So the fix isn't "add ARIA attributes" — it's to stop discarding which field the error belongs to. Both forms now carry a field-scoped error beside the form-level one, mirroring what ContactForm already does.

What deliberately stays form-level

The post-submit sign-in failure. That message is generic to avoid account enumeration; pinning it to the email input would both mislead and hint at which half of the credentials was wrong. Rate limiting and the captcha challenge aren't about one field either. There's a test asserting the sign-in failure does not mark email invalid.

The mismatch is announced against the confirmation box, not the password — that's the one the user is being asked to change.

Both tests I extended were stubs that asserted nothing

should have proper ARIA attributes in each accessibility file contained only example comments — rendering the form and asserting nothing, in the file whose job is accessibility. Two more instances of #396, found in passing.

The SignUpForm test is table-driven across all three conditions and checks the half that makes it worth having: the other two fields must stay clean. An implementation that marked every input invalid would satisfy "the error is associated" and still tell the user nothing.

Mutation-verified: routing the mismatch to password instead of confirmPassword fails exactly one case — "announces the mismatched confirmation against its own field".

Three things that cost me time

  1. a@b is the load-bearing test value. type="email" accepts it, so native validation doesn't block submission, but validateEmail rejects it (needs a dot in the domain and a 2+ alpha TLD). A value the browser itself rejects never reaches React, and the test would assert against a form that never ran its own validation.
  2. findByRole('alert') resolves to an empty live region on these forms, so the assertion reads Received: <empty> — the same trap that left accessibility.spec.ts:437 unable to see anything (Work queue: E2E tests that finish having run zero assertions (#396) — 6 auth-gated defects left #850). Follow the association instead.
  3. getByLabelText(/^password/i) matches more than one control (the strength indicator contributes text). Selecting by id is unambiguous and is what the wiring refers to anyway.

I also asserted the message matched /email/i at first. It doesn't — for a@b it reads "Invalid or missing top-level domain (TLD)". It asserts non-emptiness now; pinning the copy would break on any wording change while proving nothing extra.

Scope and pre-commitment

ForgotPasswordForm:145 and ResetPasswordForm:97 have the identical shape and are not touched here — a follow-up, rather than silently widening this.

On #856: hand-rolling ContactForm's pattern here rather than adopting FormField is a vote to delete FormField. That's where the evidence already pointed — it has never rendered, and its help text is gated {helpText && !error}, hiding the hint exactly when it's needed. Say the word if you'd rather adopt and I'll revisit.

lint clean, 4733 vitest tests, tsc --noEmit clean.

Closes #857

🤖 Generated with Claude Code

SignInForm and SignUpForm funnelled every error — including field-specific
validation — into one form-level alert with no `id`, and no input carried
`aria-invalid` or `aria-describedby`. A screen-reader user heard "Passwords do
not match" with nothing saying which of the two password boxes to fix.

SignUpForm was the sharper case: THREE distinct conditions shared one string —
an invalid email (:59), a short password (:71), and a mismatched confirmation
(:77). So the fix is not "add ARIA attributes", it is to stop DISCARDING which
field the error belongs to. Both forms now carry a field-scoped error beside the
form-level one, mirroring the pattern ContactForm already uses.

WHAT DELIBERATELY STAYS FORM-LEVEL

The post-submit sign-in failure. That message is generic to avoid account
enumeration, and pinning it to the email input would both mislead and hint at
which half of the credentials was wrong. Rate limiting and the captcha challenge
are not about one field either. Only client-side validation is field-scoped, and
there is a test asserting the sign-in failure does NOT mark the email invalid.

The mismatch is announced against the CONFIRMATION box rather than the password:
that is the one the user is being asked to change.

BOTH TESTS I EXTENDED WERE GENERATOR STUBS THAT ASSERTED NOTHING

`should have proper ARIA attributes` in each accessibility test file contained
only example comments — rendering the form and asserting nothing, in the file
whose job is accessibility. Two more instances of #396, found in passing.

The SignUpForm test is table-driven over all three conditions and checks the
half that makes it worth having: the OTHER two fields must stay clean. An
implementation that marked every input invalid would satisfy "the error is
associated" and still tell the user nothing.

MUTATION-VERIFIED: routing the mismatch to `password` instead of
`confirmPassword` fails exactly one case, "announces the mismatched confirmation
against its own field".

THREE THINGS THAT COST TIME, RECORDED SO THEY DO NOT AGAIN

1. `a@b` is the load-bearing test value. `type="email"` ACCEPTS it, so native
   validation does not block submission, but `validateEmail` rejects it
   (email-validator.ts wants a dot in the domain and a 2+ alpha TLD). A value the
   browser itself rejects never reaches React, and the test would assert against
   a form that never ran its own validation.

2. `findByRole('alert')` resolves to an EMPTY live region on these forms, so the
   assertion reads `Received: <empty>` — the same trap that left
   accessibility.spec.ts:437 unable to see anything (#850). Follow the
   association instead: read `aria-describedby`, then look up that id.

3. `getByLabelText(/^password/i)` matches more than one control on SignUpForm
   (the strength indicator contributes text). Selecting by id is unambiguous and
   is what the aria-describedby wiring refers to anyway.

I also asserted the message matched /email/i at first. It does not — for `a@b`
it reads "Invalid or missing top-level domain (TLD)". The assertion is
non-emptiness now; pinning the copy would break on any wording change while
proving nothing extra.

SCOPE: SignInForm and SignUpForm, the two named in the issue.
ForgotPasswordForm:145 and ResetPasswordForm:97 have the identical shape and are
NOT touched here — filed as a follow-up rather than silently widened.

Pre-commitment on #856: hand-rolling ContactForm's pattern here rather than
adopting FormField is a vote to DELETE FormField. That is where the evidence
already pointed — it has never rendered, and its help text is gated
`{helpText && !error}`, which hides the hint exactly when it is needed.

lint clean, 4733 vitest tests, tsc clean.

Closes #857

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TortoiseWolfe
TortoiseWolfe merged commit e4cb312 into main Aug 20, 2026
39 of 40 checks passed
@TortoiseWolfe
TortoiseWolfe deleted the fix/857-auth-field-error-association branch August 20, 2026 20:46
TortoiseWolfe added a commit that referenced this pull request Aug 21, 2026
…#871)

ForgotPasswordForm and ResetPasswordForm had the identical shape #866 fixed in
SignInForm and SignUpForm: field-specific validation funnelled into a single
form-level alert with no `id`, and no input carrying `aria-invalid` or
`aria-describedby`. Leaving two of four forms behind was worse than doing all
four or none.

ResetPasswordForm is the sharper case — TWO conditions shared one string, a
password that fails validatePassword and a confirmation that does not match, so
"Passwords do not match" was announced with nothing saying which of the two
boxes to fix. It is announced against the CONFIRMATION now: that is the one the
user is being asked to change.

Deliberately still form-level: the rate limit, the captcha challenge, the
Supabase update failure, and the password-reset response — the last of those is
generic on purpose so it cannot be used to confirm whether an address is
registered.

BOTH ACCESSIBILITY TEST FILES WERE GENERATOR STUBS THAT ASSERTED NOTHING

`should have proper ARIA attributes` in each contained only example comments —
rendering the form and asserting nothing, in the file whose job is
accessibility. Two more instances of #396 on top of the two found in #866, which
makes four of four auth forms shipped that way.

The ResetPasswordForm test is table-driven over both conditions and checks the
half that makes it worth having: the OTHER field must stay clean. An
implementation that marked every input invalid would satisfy "the error is
associated" and still tell the user nothing.

MUTATION-VERIFIED, both directions:
  - routing the mismatch to `password` instead of `confirmPassword` fails
    exactly the mismatch case
  - removing `aria-describedby` while still rendering the message fails with
    "the email input must point at its error: expected null to be truthy"

THREE THINGS THAT COST TIME, ALL THE SAME SHAPE

1. My first test password for the mismatch case was `LongEnough123`, which has
   no special character — so validatePassword rejected it FIRST, the mismatch
   branch never ran, and the test asserted against a state it never reached.
   Both values must pass validation for the mismatch to be reachable at all.
2. `passwordValidation.error` is `string | null`. The old `setError` accepted
   null; a typed field message does not. It falls back to a real sentence rather
   than a cast, so an invalid password without a message still says something.
3. My first patch attempt matched three of four anchors silently and wrote the
   file anyway. Each edit now asserts its own anchor AND that the anchor is
   unique — a patch that half-applies is the same defect class as a guard that
   half-matches.

lint clean, 4734 vitest tests, tsc --noEmit clean.

Closes #867

Co-authored-by: TurtleWolfe <TurtleWolfe@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sign-in and sign-up errors are never associated with the field they describe

2 participants