feat(auth): forgot-password wizard on the sign-in screen - #103
Merged
Conversation
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
requested review from
anton-karlovskiy,
chmm195,
kevinkamto and
mchl7852
August 19, 2026 14:34
… 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>
Code Review ✅ ApprovedAdds a forgot-password wizard to the sign-in screen with a secure email verification flow and test coverage. No issues found. OptionsAuto-apply is off → Gitar will not commit updates to this branch. Comment with these commands to change the behavior for this request:
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 |
This was referenced Aug 19, 2026
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.
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-passwordidentically 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.forgotPasswordresolvingtruemeans 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.resetPasswordrewrites the stored password whenrememberMeis on, guarded exactly the waychangePasswordguards 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.mjspins both directions, plus that a rejected reset leaves the store alone - the old password is still the live one in that case.Smaller calls
PASSWORD_RESET_CODE_EXPIRE_MINUTES./auth/login, the same as signup does.Verification
pnpm lint, bothtscconfigs,pnpm build, andpnpm test:mainall pass locally (the same set CI runs). 12 new checks intest/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