Email confirmation, change email/password, show/hide passwords
CI / check (push) Canceled after 0s

- Sign-up emails a confirmation link (24h); unconfirmed accounts see a
  banner on the web with "send it again", and status in Settings.
- Change email (needs the password): the new address gets a confirmation
  link and only replaces the old one once clicked; the old address gets a
  heads-up with a reset-password link. Pending changes can be cancelled.
- Change password (needs the current one): signs out other sessions.
- Password fields have Show/Hide on web and mobile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-06 13:05:26 -04:00
co-authored by Claude Opus 5.5
parent 45a7bd190a
commit 85e1643c29
25 changed files with 2077 additions and 19 deletions
+5 -4
View File
@@ -7,10 +7,11 @@ beforeEach(() => {
outbox.length = 0;
});
/** The email is sent in the background; give it a moment. */
/** The reset email is sent in the background; give it a moment. (Sign-up sends its own confirmation email.) */
async function nextMail() {
for (let i = 0; i < 50 && outbox.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
return outbox.at(-1);
const reset = () => outbox.filter((m) => m.subject.startsWith('Reset')).at(-1);
for (let i = 0; i < 50 && !reset(); i++) await new Promise((r) => setTimeout(r, 10));
return reset();
}
const tokenFrom = (text: string) => new URL(text.match(/http\S+/)![0]).searchParams.get('token')!;
@@ -50,7 +51,7 @@ describe('password reset', () => {
const res = await requestReset('nobody@example.com');
expect(res.json()).toEqual({ ok: true });
await new Promise((r) => setTimeout(r, 100));
expect(outbox).toHaveLength(0);
expect(outbox.filter((m) => m.subject.startsWith('Reset'))).toHaveLength(0);
});
it('links work once, and a new request replaces the old link', async () => {