fix(auth): iOS keyboard loss on password reset + render login for stale reset links#16
Merged
Merged
Conversation
…e reset links Two bugs in the password-reset flow: 1. iPhone Chrome: typing the first character of the new password dismissed the soft keyboard. The set-password view is SSR-rendered, so the password inputs were focusable before hydration. A pre-hydration keystroke is wiped when React hydrates the controlled input (initial value ''), and the focus reconciliation dismisses the iOS keyboard. Gate the controls behind a disabled <fieldset> until a post-hydration flag flips, so they are never focusable before React owns them. SSR and first client render both emit disabled controls (no hydration mismatch). 2. Refreshing the reset landing threw a hard error: GET /auth/email/reset-password re-validates a single-use/expiring token on every load and had no graceful fallback. Mirror commit 89ef6f7 (registration link route): on a recognised token error, render login instead of erroring; re-throw everything else. Adds unit tests for the reset route fallback (used/expired -> login, infra error -> not swallowed) and an SSR test asserting the set-password form renders a disabled fieldset pre-hydration. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes two bugs in the password-reset flow, reported on iPhone 13 / Chrome.
Bug 1 — soft keyboard disappears after the first character
The
set-passwordview is server-rendered, so the password<input>s are real, focusable HTML before the JS bundle hydrates. On mobile the user can tap and type the first character into a not-yet-hydrated input; whenhydrateRootthen attaches the controlled input (initial state''), React resets the DOM value (dropping the char) and the focus reconciliation dismisses the iOS soft keyboard.Fix: gate the controls behind a
<fieldset disabled={!hydrated}>wherehydratedflips totruein auseEffect. The controls are non-focusable until React owns them, so the pre-hydration typing race is structurally impossible. SSR and the first client render both emit disabled controls → no hydration mismatch. (Auth/src/components/form/SetPasswordForm.tsx)Bug 2 — refreshing the reset page throws a hard error
GET /auth/email/reset-passwordre-validates a single-use, 30-min-expiry token on every load and had no graceful fallback, so a used/expired token threwAppError→ hard error page. Commit 89ef6f7 already added a "render login for stale email links" fallback to the registration link route, but the same fix was never applied to the reset route.Fix: mirror 89ef6f7 —
try/catcharoundvalidatePasswordResetToken; on a recognised token error (INVALID_TOKEN/INVALID_TOKEN_CONFIG_URL/INVALID_TOKEN_TYPE/TOKEN_ALREADY_USED/TOKEN_EXPIRED) render login instead of erroring; re-throw everything else. (API/src/routes/auth/email-reset-password.ts)Tests
API/tests/unit/email-reset-password.route.test.ts— used→login, expired→login (the refresh case), and infra error (DATABASE_DISABLED, 500) is not swallowed.Auth/src/components/form/SetPasswordForm.test.tsx— SSR renders the form inside a disabled,aria-busyfieldset (pre-hydration gate).Process
Designed by two independent architect passes (Codex + Claude), reviewed by two independent reviewers (Codex + Claude).
Known limitation (by design, not a regression)
The stale-link fallback renders login without PKCE, because reset email links deliberately carry no PKCE/
redirect_urland the existing post-reset success path already returns to a PKCE-less login (setView('login')). Actual password login from this view needs PKCE, but the relevant recovery path — "forgot password" → request a new reset link (POST /auth/reset-password/request, email-only, no PKCE) — works. Threading PKCE through the whole reset flow would redesign the reset link and is out of scope for these bug fixes. Before this change a refresh produced a hard error; after it, login with a working recovery path — strictly better.🤖 Generated with Claude Code