Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ Hash-based router (required for Electron `file://` protocol). Routes: `/` (index

**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 so that the endpoint cannot be used to test who has one, and a UI that reported the difference would hand that oracle straight back - which is why the copy on step two is conditional ("if an account exists for..."). `AuthService.forgotPassword` resolving true means the request went through, nothing more.

**The signup wizard works the same way, for the same reason.** `send-verification-code` used to answer 409 for an address that already had an account, so signup reported it inline and immediately - which made the care taken in the reset wizard pointless, since the same question was answerable one screen over. The backend now answers 200 either way and mails a code to a free address or a "you already have an account" notice to a taken one, so signup's step two copy is conditional in the same shape ("if x@y.z does not already have an account...") and mentions the notice, because for that user the code they are waiting to paste is never coming. `AuthService.sendVerificationCode` resolving true means the request went through, not that the address is free.

`AuthService.resetPassword` rewrites the stored password behind **two** guards, `rememberMe` and the address matching the remembered one. The login form pre-fills from that store, so skipping the write leaves a filled-in password that has just stopped working; writing it on `rememberMe` alone puts credentials on disk for a user who did not opt in. The address check is specific to reset, the only password flow that runs while signed out and therefore the only one that can be run for an account other than the remembered one - on a shared machine, writing unconditionally would replace someone else's remembered login with this one. That write is wrapped in its own `try`, separate from the request. By the time it runs the password has already changed and the code is spent, so letting a disk failure decide the return value would report a failure for a reset that succeeded and send the user to retry with a code that can no longer work - the same trap the login form avoids when it persists remember-me. `test/password-reset.test.mjs` pins all of it, including the failed-reset case and a store that throws.

The final step latches on success. `loading` is already back to false while the two-second redirect runs, so a live button there would let a second click resend a code the backend has just spent, toasting a guaranteed failure over the success still on screen.
Expand Down
6 changes: 6 additions & 0 deletions src/main/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export class AuthService {

/**
* Send an email verification code to a prospective user.
*
* The backend answers the same whether or not the address already has an account, so a
* `true` here means "the request went through", never "this address is free". It mails a
* code to a free address and a "you already have an account" notice to a taken one.
* Reporting anything more specific to the renderer would put back over the UI the
* enumeration the endpoint was changed to remove.
*/
async sendVerificationCode(email: string): Promise<{ success: boolean; error?: string }> {
try {
Expand Down
31 changes: 28 additions & 3 deletions src/renderer/pages/auth/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,17 @@ export default function SignupPage() {
<form onSubmit={submitCode} className="space-y-4">
<div>
<label className="text-sm block mb-1">Verification code</label>
{/*
Conditional, like the reset wizard's step two, because the backend now answers
the same whether or not the address already has an account. It sends a code to
a free address and a "you already have one" notice to a taken one, so a flat
"we sent a code" is wrong half the time - and stating which happened would put
back over the UI the enumeration the endpoint was changed to remove.
*/}
<p className="text-sm text-muted-foreground mb-2">
We sent a verification code to {email}. Paste it below.
If {email} does not already have an account, we sent a verification code to it.
Paste the code below. If it does, we sent a note explaining how to sign in
instead.
</p>
<Textarea value={code} onChange={(e) => setCode(e.target.value)} rows={4} required />
</div>
Expand Down Expand Up @@ -130,15 +139,31 @@ export default function SignupPage() {
onClick={async () => {
setError(null);
if (await sendVerificationCode(email.trim())) {
toast.success('Verification code resent.');
// Not "Verification code resent": a taken address gets the "you already
// have an account" notice resent instead, and this toast can no longer
// tell which one happened - see sendVerificationCode's docstring.
toast.success('Request sent again.');
} else {
toast.error('Failed to resend verification code.');
toast.error('Failed to resend.');
}
}}
>
Resend code
</button>
</div>

{/*
The copy above tells a user whose address is already registered to go and sign
in, and this is the only step that had no way to do it - step one carries the
same link. That did not matter while a taken address was stopped at step one by
a 409; now it reaches this screen instead, so the link has to be here too, or
the advice lands somewhere the user cannot act on it.
*/}
<div className="text-center">
<Link to="/auth/login" className="text-sm underline">
Already have account? Just login
</Link>
</div>
</form>
)}

Expand Down