Skip to content

feat: password recovery, and finish the journey an emailed link starts - #54

Open
Amayyas wants to merge 6 commits into
mainfrom
feat/password-reset
Open

feat: password recovery, and finish the journey an emailed link starts#54
Amayyas wants to merge 6 commits into
mainfrom
feat/password-reset

Conversation

@Amayyas

@Amayyas Amayyas commented Aug 21, 2026

Copy link
Copy Markdown
Owner

There was no password recovery path at all: a player who forgot their password
was locked out permanently unless they had signed in through Google, and the
login screen offered no way to ask. Testing that flow then turned up two more
faults in the journey around it, and both are fixed here.

What this adds

  • /mot-de-passe-oublie — asks for an address, sends the link.
  • /nouveau-mot-de-passe — where the link lands, sets the new password.
  • A confirmation screen after registering, instead of a silent redirect.
  • A lander that carries any emailed auth link to the screen it was for.

The two faults found by using it

Registering dropped the new player on the guest profile. The page navigated
there whatever came back, but with email confirmation enabled a sign-up returns
no session — the account exists and nobody is signed in until the link is
followed. So a player who had just registered was looking at "Vous jouez en
invité" seconds later. signUp now reports which of the two happened, and the
page either goes to the profile or explains that a message is waiting, naming
the address it went to.

A recovery link did nothing. Opened from a deploy preview, it landed on the
production home page with the token already spent. Supabase only redirects to
addresses on its allow list and silently falls back to the project's Site URL
otherwise.

Rather than depend on that list ever being complete, the link's own type is read
from the URL fragment — before the Supabase client is created, since it consumes
and clears it — and a lander routes to the matching screen. The flow now
finishes wherever the visitor was dropped, which covers the confirmation link
landing on the home page too.

Three decisions worth stating

The recovery confirmation never says whether the address is known. Reporting
"no account with this email" would turn the form into a way to test which
addresses are registered here, and every account carries a public pseudonym and
a place in the ranking. The store treats a rejected address as success for the
same reason; only a transport failure surfaces.

The reset screen waits for the recovery event, not for a session. Everyone
signed in has a session, so gating on that would have published a
change-password page to every visitor — including Google accounts, which would
have quietly gained a password they never had. The flag is cleared once the
password is set, so a spent link leaves nothing open.

Neither recovery screen shows the Google button. It cannot help a password
account, and pressing it there would sign the player into a different account
from the one they are trying to recover.

Tests

Nineteen across the auth feature. Each fix was verified against broken code:

Mutation Result
confirmation reveals that the address is known enumeration test fails
reset screen gated on a session again 2 tests fail
register navigates whatever the outcome confirmation test fails
lander ignores the link type 2 tests fail

One thing to change outside this PR

Supabase's Redirect URLs should include the deploy preview pattern
(https://deploy-preview-*--chesstrainer-ai.netlify.app/**). Without it,
previews will keep bouncing auth links to production. The lander makes that
survivable rather than silent, but it is still the wrong destination.

Stale copy, fixed here too

The home page still advertised "cinq niveaux, de 800 à 2200 Elo" after #53 had
grown the ladder to six levels with measured figures, and the README carried the
same claim. Nothing failed, because the numbers were prose — and prose that
restates data drifts from it in silence.

Both the count and the range are now read from ENGINE_LEVELS, and a test fails
if anyone writes them back in by hand. The README now also records how those
figures were measured and what they are worth.

Deploy cost

One production deploy, 15 credits. Batched deliberately.

There was no recovery path at all. A player who forgot their password
was locked out permanently unless they had signed in through Google —
and nothing in the interface said so, since the login screen offered no
way to ask.

Two screens: one asks for an address and sends a link, the other is
where that link lands and sets the new password.

The confirmation is identical whether or not the address has an account.
Reporting "no account with this email" would turn the form into a way to
test which addresses are registered here, and every account carries a
public pseudonym and a place in the ranking.

The landing screen checks for the session the recovery token creates and
says the link has expired when it is missing, rather than letting
someone type a password twice and only then fail. Links are valid for an
hour and single use.

Neither screen offers the Google button: it cannot help a password
account, and pressing it there would sign the player into a different
account than the one they are trying to recover.
@netlify

netlify Bot commented Aug 21, 2026

Copy link
Copy Markdown

Deploy Preview for chesstrainer-ai ready!

Name Link
🔨 Latest commit a7b75d2
🔍 Latest deploy log https://app.netlify.com/projects/chesstrainer-ai/deploys/6a8cbb4a917a260008c60fb2
😎 Deploy Preview https://deploy-preview-54--chesstrainer-ai.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add Supabase password recovery + reset flow (no enumeration)

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Add "forgot password" and "reset password" routes and screens for Supabase recovery emails.
• Prevent account enumeration by confirming reset requests without revealing email existence.
• Add tests covering non-enumeration, expired-link handling, and client-side password validation.
Diagram

graph TD
LP["Login page"] -->|"Forgot password link"| FP["Forgot password page"] -->|"request reset"| AS["Auth store"] -->|"Supabase reset email"| SA["Supabase Auth"] -->|"redirect w/ recovery token"| RP["Reset password page"] -->|"update password"| AS -->|"navigate"| PR["Profile page"]
AL["AuthLayout"] -->|"shared shell"| LP
AL -->|"showGoogle=false"| FP
AL -->|"showGoogle=false"| RP
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Edge Function wrapper for password reset request
  • ➕ Avoids client-side parsing of Supabase error message strings (more robust).
  • ➕ Central place for rate limiting / abuse controls.
  • ➕ Can always return 200 while still logging real delivery errors.
  • ➖ Adds backend/infra and secrets management.
  • ➖ More moving parts than calling Supabase directly from the client.
2. Always treat all resetPasswordForEmail errors as success (client-only)
  • ➕ Simplest way to eliminate any possibility of leaking account existence.
  • ➕ No reliance on Supabase error message content.
  • ➖ Masks real transport/config failures that users could act on.
  • ➖ Harder to debug production issues without additional logging/telemetry.

Recommendation: Current approach is solid for a client-only app: UX is clear, avoids enumeration in UI, and checks for an expired/missing recovery session before showing the form. The main risk is relying on regex matching of Supabase error messages; if this becomes brittle or you need rate limiting, consider moving the reset request behind an Edge Function wrapper.

Files changed (9) +337 / -12

Enhancement (7) +252 / -9
App.tsxRegister forgot/reset password routes with lazy-loaded pages +18/-0

Register forgot/reset password routes with lazy-loaded pages

• Adds lazy imports for the new recovery pages and registers two new routes in the main router. Ensures both pages are loaded behind the existing Suspense fallback behavior.

src/App.tsx

AuthLayout.tsxMake Google sign-in optional in AuthLayout +21/-9

Make Google sign-in optional in AuthLayout

• Introduces a showGoogle prop (default true) and conditionally renders the Google separator and button. Allows recovery screens to hide Google sign-in to avoid confusing cross-account flows.

src/features/auth/AuthLayout.tsx

ForgotPasswordPage.tsxAdd forgot-password screen with non-enumerating confirmation +66/-0

Add forgot-password screen with non-enumerating confirmation

• Implements an email collection form that calls requestPasswordReset and then displays a generic success message regardless of account existence. Uses AuthLayout with showGoogle disabled and links back to login.

src/features/auth/ForgotPasswordPage.tsx

LoginPage.tsxAdd "Mot de passe oublié ?" link to login form +6/-0

Add "Mot de passe oublié ?" link to login form

• Adds a route link to the forgot-password page below the submit button, making recovery discoverable from the login screen.

src/features/auth/LoginPage.tsx

ResetPasswordPage.tsxAdd reset-password landing screen with session checks +104/-0

Add reset-password landing screen with session checks

• Implements the recovery landing page: validates password length and confirmation locally, disables submission until auth is ready, and shows an explicit expired-link message when no recovery session exists. On success, navigates to the profile page.

src/features/auth/ResetPasswordPage.tsx

routes.tsDefine French recovery route paths +2/-0

Define French recovery route paths

• Adds ROUTES entries for forgotPassword and resetPassword, matching the new screens’ URLs.

src/routes.ts

useAuthStore.tsAdd auth-store actions for password reset email and password update +35/-0

Add auth-store actions for password reset email and password update

• Adds requestPasswordReset(email) calling supabase.auth.resetPasswordForEmail with redirectTo set to the reset-password route, suppressing user-not-found style errors to prevent enumeration. Adds updatePassword(password) using supabase.auth.updateUser to set the new password and surface errors via friendlyError.

src/store/useAuthStore.ts

Tests (2) +85 / -3
navigation.test.tsMark recovery routes as off-menu navigation +10/-3

Mark recovery routes as off-menu navigation

• Updates the navigation invariant test to treat the recovery routes like other auth/legal pages: reachable via links/guards/email, but not shown in the menu.

src/components/Layout/navigation.test.ts

passwordReset.test.tsxAdd tests for recovery UX and security decisions +75/-0

Add tests for recovery UX and security decisions

• Adds tests ensuring the forgot-password confirmation does not reveal whether an email exists and that Google sign-in is hidden on recovery pages. Verifies reset page rejects mismatched passwords without calling update, and shows an expired-link message when no session is present.

src/features/auth/passwordReset.test.tsx

@Amayyas Amayyas self-assigned this Aug 21, 2026
@qodo-code-review

qodo-code-review Bot commented Aug 21, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Reset errors falsely suppressed ✓ Resolved 🐞 Bug ≡ Correctness
Description
requestPasswordReset treats any Supabase error whose message contains "invalid" as success, so
real failures (e.g., invalid email/redirect/config) can show the user a success confirmation while
no email was sent. This makes the recovery flow silently fail and is hard to diagnose because the UI
never surfaces the underlying error.
Code

src/store/useAuthStore.ts[R170-173]

+    // A rejected address is reported as success on purpose; only a transport
+    // failure is worth telling the player about.
+    if (error && !/user not found|invalid/i.test(error.message)) {
+      set({ error: friendlyError(error.message) })
Relevance

●●● Strong

Recent accepted auth-store findings address stale or incorrect auth behavior; suppressing
configuration errors creates a clear recovery-flow correctness failure.

PR-#9

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The store returns success for any error message containing "invalid". The page uses the boolean
return to switch into a success-only confirmation state, so suppressed errors become silent
failures. The codebase already has a user-facing mapping for invalid email errors, but this path
bypasses it by returning success instead of setting error.

src/store/useAuthStore.ts[164-177]
src/features/auth/ForgotPasswordPage.tsx[22-28]
src/features/auth/ForgotPasswordPage.tsx[41-46]
src/store/useAuthStore.ts[49-58]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`requestPasswordReset` currently suppresses errors that match `/user not found|invalid/i`, returning `true` and showing the “email sent” confirmation even when Supabase rejected the request for real reasons (e.g., invalid email format, invalid redirect URL, invalid configuration).

### Issue Context
The UI relies on the boolean return value to decide whether to show the success state. Suppressing broad classes of errors breaks the feature while hiding the root cause.

### Fix Focus Areas
- src/store/useAuthStore.ts[164-176]

### What to change
- Only suppress the specific “user not found” (or equivalent) condition.
- Do **not** suppress generic “invalid*” errors; instead set `error: friendlyError(...)` so the player gets actionable feedback.
- Prefer a structured discriminator if available (e.g., error `status` / `code`) over regex on `error.message`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Reset page not recovery-gated ✓ Resolved 🐞 Bug ⛨ Security
Description
ResetPasswordPage only checks for the presence of any authenticated session, so any
already-signed-in user (including OAuth users) can visit /nouveau-mot-de-passe and set a password
without going through the recovery flow. This unintentionally creates a hidden “change password”
screen and can lead to unexpected password state changes for accounts not meant to use passwords.
Code

src/features/auth/ResetPasswordPage.tsx[R57-60]

+      {isReady && !session ? (
+        <p role="alert" className="rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
+          Ce lien n'est plus valide. Les liens de récupération expirent au bout d'une heure et ne
+          servent qu'une fois&nbsp;:{' '}
Relevance

●●● Strong

Recent repository history accepts auth-state correctness fixes; this security issue directly
contradicts recovery-only password-reset intent.

PR-#9

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The page gates on isReady && !session only; if session exists it renders the form and calls
updatePassword, which updates the current session’s password. Supabase’s reference flow shows
updating the password specifically in response to the PASSWORD_RECOVERY auth event.

src/features/auth/ResetPasswordPage.tsx[19-44]
src/features/auth/ResetPasswordPage.tsx[57-101]
src/store/useAuthStore.ts[179-188]
🌐 The reference flow updates the password inside onAuthStateChange only when the event is PASSWORD_RECOVERY, implying the UI should distinguish recovery sessions from normal sessions before calling updateUser({ password }).

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The reset-password route currently renders the password form for any authenticated session, not specifically a recovery session. That means a regular logged-in user can set a new password just by opening the URL.

### Issue Context
Supabase’s documented flow distinguishes password recovery via the `PASSWORD_RECOVERY` auth event; the UI can use this signal (or a dedicated store flag set by `onAuthStateChange`) to ensure the page is only usable for genuine recovery sessions.

### Fix Focus Areas
- src/features/auth/ResetPasswordPage.tsx[19-67]
- src/store/useAuthStore.ts[111-121]

### What to change
- Track whether the current session originated from a recovery link (e.g., in `useAuthStore`, set a `isPasswordRecovery` flag when `onAuthStateChange` fires `PASSWORD_RECOVERY`).
- In `ResetPasswordPage`, show the form only when `isPasswordRecovery` is true; otherwise show an explanatory message and a link back to login/forgot-password.
- Clear the recovery flag after a successful password update (and/or on sign-out).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. Store mocks leak across tests ✓ Resolved 🐞 Bug ☼ Reliability
Description
passwordReset.test.tsx mutates the singleton Zustand store (e.g., requestPasswordReset,
updatePassword) but the beforeEach only resets error/session/isReady, so mocked functions can
persist into later tests and create order-dependent failures. This will get worse as more auth tests
are added to the same file.
Code

src/features/auth/passwordReset.test.tsx[R17-19]

+beforeEach(() => {
+  useAuthStore.setState({ error: null, session: null, isReady: true })
+})
Relevance

●●● Strong

Recent accepted findings prioritize preventing stale shared state and order-dependent behavior;
resetting mocked store functions is a deterministic test fix.

PR-#41
PR-#9

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The file overwrites store functions via useAuthStore.setState({ requestPasswordReset: request }) /
setState({ updatePassword: update }), but the shared beforeEach does not restore those fields,
so they remain mutated for subsequent tests.

src/features/auth/passwordReset.test.tsx[17-19]
src/features/auth/passwordReset.test.tsx[21-53]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The test suite overwrites store methods but does not restore them between tests, which can cause cross-test contamination.

### Issue Context
Zustand stores are singletons across the test file/process; `setState` changes persist unless explicitly reverted.

### Fix Focus Areas
- src/features/auth/passwordReset.test.tsx[17-53]

### What to change
- Capture the original `requestPasswordReset` / `updatePassword` implementations once (before tests), and restore them in `beforeEach`, or
- Replace the whole store state in `beforeEach` (using Zustand’s `setState(..., true)` replace-mode if available in this project) to a known baseline that includes the real methods.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Web pages:
  +4 more
Review mode: ⚖️ Balanced: This security-sensitive authentication flow spans routing, UI, session handling, and Supabase error semantics, so a careful single-pass review is warranted.

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/store/useAuthStore.ts Outdated
Comment thread src/features/auth/ResetPasswordPage.tsx Outdated
Comment thread src/features/auth/passwordReset.test.tsx
Three defects from review, all real.

The reset screen admitted anyone with a session. Everyone signed in has
one, so it was a change-password page published to every visitor —
including accounts that sign in through Google and have no password,
which would have quietly gained one. It now waits for the
PASSWORD_RECOVERY event the recovery link raises, and the flag is
cleared once the password is set so a spent link leaves nothing open.

requestPasswordReset treated any error mentioning "invalid" as success.
That was meant to hide unknown addresses, but Supabase already answers
those with success, so the filter only ever swallowed real failures — a
bad redirect URL, a misconfigured key — and showed a confirmation for an
email that never left. All errors surface now.

The tests mutated the store singleton and restored three fields, so a
mocked action outlived its test and made the suite order-dependent. The
whole state is snapshotted and replaced before each test instead.
Two faults found by using the thing.

Registering dropped the new player on the guest profile. The page
navigated there whatever came back, but with email confirmation enabled
a sign-up returns no session — the account exists and nobody is signed
in until the link is followed. signUp now reports which of the two
happened, and the page either goes to the profile or explains that a
message is waiting, naming the address it went to.

A recovery link opened from a deploy preview landed on the production
home page and did nothing at all. Supabase only redirects to addresses
on its allow list and falls back to the project's Site URL otherwise, so
the token was spent on a page with no way to use it.

Rather than depend on that list being complete, the link's own type is
read from the URL fragment — before the client is created, since it
consumes and clears it — and a lander routes to the matching screen. The
flow now finishes wherever the visitor was dropped, which also covers
the confirmation link landing on the home page.
@Amayyas Amayyas changed the title feat: password recovery flow feat: password recovery, and finish the journey an emailed link starts Aug 22, 2026
The home page still advertised "cinq niveaux, de 800 à 2200 Elo" after
the ladder had grown to six levels with measured figures. Nothing failed:
the numbers were prose, and prose that restates data drifts from it in
silence.

Both the count and the range now come from ENGINE_LEVELS, and a test
fails if anyone writes them back in by hand.

The README carried the same stale claim and now describes the six
measured levels, including how they were measured and what the figures
are worth.
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.

1 participant