Skip to content

feat(auth): forgot-password wizard on the sign-in screen - #103

Merged
alpha5611331 merged 6 commits into
mainfrom
feat/forgot-password
Aug 19, 2026
Merged

feat(auth): forgot-password wizard on the sign-in screen#103
alpha5611331 merged 6 commits into
mainfrom
feat/forgot-password

Conversation

@alpha5611331

Copy link
Copy Markdown
Member

Closes #102
Backend counterpart: PowerInterviewAI/backend#54 (land that first - this is written against its API shape)

A three-step wizard at /auth/forgot-password (email -> code -> new password), shaped like the signup one, reached from a "Forgot your password?" link on the sign-in screen.

Why code-based and not an emailed link

A reset link opens the system browser, which has no way to hand a token back to an Electron app without a registered deep-link protocol handler. The code flow keeps the whole interaction in the renderer and reuses the plumbing signup already has.

The part that is easy to get wrong

Step one advances on success alone and never reports "no such account". The backend answers forgot-password identically for a registered and an unregistered address, specifically so the endpoint cannot be used to test who has one. A UI that reported the difference would hand that oracle straight back over the front door, so the copy on step two is conditional: "If an account exists for x@y.z, we sent a reset code to it."

AuthService.forgotPassword resolving true means the request went through, nothing more, and that is stated at every layer it passes through so nobody later "improves" it into an existence check.

Remember-me

AuthService.resetPassword rewrites the stored password when rememberMe is on, guarded exactly the way changePassword guards it. The login form pre-fills from that store, so skipping the write leaves the user staring at a filled-in password that has just stopped working, with nothing on screen explaining why; writing it unguarded puts credentials on disk for someone who did not opt in, which login and logout both take care to avoid.

test/password-reset.test.mjs pins both directions, plus that a rejected reset leaves the store alone - the old password is still the live one in that case.

Smaller calls

  • The code step exists so a mistyped code fails there rather than after the user has typed a new password twice.
  • The password step says up front that the reset signs them out on every device, because it does.
  • The expiry is not restated in the UI. The email carries it from server config, and a number hardcoded in the renderer would silently drift from PASSWORD_RESET_CODE_EXPIRE_MINUTES.
  • No auto-login after reset: it lands on /auth/login, the same as signup does.

Verification

pnpm lint, both tsc configs, pnpm build, and pnpm test:main all pass locally (the same set CI runs). 12 new checks in test/password-reset.test.mjs.

Not covered: the wizard has not been driven end to end against a live backend, since the endpoints it calls are still in review on the backend PR.

Base

Cut from origin/main (e4797bb, v1.6.6). Nothing else outstanding against it.

🤖 Generated with Claude Code

alpha5611331 and others added 2 commits August 19, 2026 10:26
Adds forgotPassword, verifyPasswordResetCode, and resetPassword through the
whole main-process path: API client, service, IPC handlers, preload, and the
renderer type declarations.

forgotPassword resolving true means the request went through, never that the
address is registered. The backend answers identically either way so that the
endpoint cannot be used to test who has an account, and reporting anything
more specific up to the renderer would put that enumeration back over the UI.

resetPassword rewrites the stored password when rememberMe is on, guarded the
same way changePassword guards it. The login form pre-fills from that store,
and the password sitting in it after a reset is the one that just stopped
working.

Refs #102

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A three-step wizard at /auth/forgot-password (email -> code -> new
password), shaped like the signup one, reached from a link on the login
page. Code-based rather than an emailed link: a link opens the system
browser, which has no way to hand a token back to the app without a
registered deep-link protocol handler.

Step one advances on success alone and never says whether the address has
an account, and the copy on step two is conditional for the same reason.
The backend answers that endpoint identically either way so it cannot be
used to test who is registered, and a UI reporting the difference would
hand the oracle straight back.

The code step exists so a mistyped code fails there rather than after the
user has typed a new password twice, and the password step says up front
that the reset signs them out everywhere, since it does.

test/password-reset.test.mjs pins the remember-me store writes in both
directions and the failed-reset case.

Closes #102

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha5611331 and others added 4 commits August 19, 2026 10:47
… write

Two side effects found reviewing the reset flow, both invisible until the
exact case that triggers them.

The final step left its button live after a successful reset. `loading` is
back to false while the two-second redirect runs, so a second click resent a
code the backend had just spent: a guaranteed 401, toasting a failure over
the success the user was still reading. It now latches on success, disables
the button, and clears the redirect timer on unmount.

`resetPassword` rewrote the remembered email and password whenever
rememberMe was on, without checking whose account was being reset. Reset is
the only password flow that runs while signed out, so it can be run for an
account other than the remembered one, and on a shared machine that replaced
someone else's remembered login with this one. It now writes only when the
address matches what is stored (case-insensitively, since the two were typed
on different screens), and writes only the password, leaving the email as it
was.

Refs #102

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`AuthLayout` renders the wizard card and nothing else, so the password step had
no navigation at all: no back link, no way to ask for another code. That is the
one step where being stuck is likely rather than theoretical. The reset code
expires on `PASSWORD_RESET_CODE_EXPIRE_MINUTES` (15) and the user spends that
window choosing and confirming a password, so the code going stale between step
two and step three is an ordinary outcome, not an edge case. When it happened
the only thing on screen was "Please try again", which is advice that cannot
work: the code is spent or expired, and resubmitting it is a guaranteed 401.

So the step now offers `Start over`, which returns to step one with the address
kept and the stale code dropped, plus a link back to sign in. Both are hidden
once the reset succeeds, alongside the existing `succeeded` latch, so they
cannot compete with the redirect. The failure copy sends the user for a new code
instead of telling them to retry the dead one.

`Change email` on the code step becomes the same handler, which also clears the
password fields it was leaving behind, and picks up the `disabled={loading}` its
neighbour already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code step's failure toast said "Invalid or expired reset code" for every
rejection, and that step has three. A 429 from the reset code limiter and a
deactivated account both land here too, and in both cases the toast told the
user their code was wrong while the inline error, which carries what the server
actually said, sat directly above it saying something else.

The rate limit case is the one that bites: the user rereads and retypes a code
that was never the problem, and every retry pushes the limit further out.

The toast is now causally neutral and the inline error carries the reason. The
password step already hedges ("may have expired"), so it stays as it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… threw

The remember-me write sat inside the same try as the request, so an
electron-store failure (disk full, permissions, a corrupted file) returned
`success: false` from a reset the server had already carried out. The password
was live, the code was spent, and the user was told it failed - and every route
out of that message asks them to retry with the one code that can no longer
work. It is the failure mode this flow is otherwise careful about, arriving
through the one line that is not a network call.

The write now has its own try and logs on failure. `login.tsx` already draws
this line for the same reason ("Persisting the remember-me choice must never
block the sign-in attempt"); the reset path just did not.

The new check makes `updateConfig` throw and asserts the reset still reports
success, which fails against the old shape.

Note for a separate change: `changePassword` has the same structure, and the
same disk failure there reports a change that happened as failed. Left alone
because it is outside this PR, but it is the same trap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gitar-bot

gitar-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown
Code Review ✅ Approved

Adds a forgot-password wizard to the sign-in screen with a secure email verification flow and test coverage. No issues found.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Important

Your trial ends in 5 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

@alpha5611331
alpha5611331 merged commit 7128884 into main Aug 19, 2026
2 checks passed
@alpha5611331
alpha5611331 deleted the feat/forgot-password branch August 19, 2026 17:42
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.

feat(auth): forgot-password wizard on the sign-in screen

1 participant