Add stage 8: passwordless magic-link login

Backend
- POST /api/auth/magic-link (public): emails a one-time login link for an
  address. Always 202 with the same body so accounts can't be enumerated; a
  link is sent only when the account exists and wasn't emailed in the last
  60s. Opening it (existing verify-email endpoint) returns a session and, as a
  side effect, verifies the address. New EmailVerifier::sendLoginLink; the
  60s interval is now EmailVerifier::RESEND_INTERVAL_SECONDS, shared.

Frontend
- LoginView defaults to magic-link mode: email only, "Log in with email". A
  "Log in with password" link reveals the password field, changes the button
  to "Log in", and itself becomes "Get a magic link" to switch back.
- VerifyEmailView copy is now login-neutral ("Signing you in").

Tests: 5 new (magic-link login, implicit verification, enumeration-safety,
throttle, validation). Suite: 37 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:29:13 +01:00
co-authored by Claude Sonnet 5
parent 64ba21795b
commit be592f38fc
10 changed files with 195 additions and 21 deletions
+17 -2
View File
@@ -14,6 +14,7 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
| 5 | Frontend list detail — items UI with drag-and-drop reorder | ✅ done |
| 6 | List view — inline title/description editing, delete via a Manage menu | ✅ done |
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done |
| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done |
Registration signs the user in immediately and emails a magic link that verifies
the address; `user.email_verified` stays `false` until the link is opened. See
@@ -170,6 +171,17 @@ Request:
`200 OK`: same shape as register. `401` on bad credentials (the message does not
say whether it was the email or the password that was wrong).
### `POST /api/auth/magic-link`
Request: `{ "email": "ada@example.com" }`.
Emails a one-time login link (`<APP_URL>/verify-email?token=…`, 15-minute
expiry). Always returns `202` with the same message regardless of whether the
address is registered, so accounts can't be enumerated; a link is only actually
sent when the account exists and hasn't been emailed in the last 60 seconds.
Opening the link (`POST /api/auth/verify-email`) signs the user in and verifies
the address if it wasn't already. `422` if the address is malformed.
### `GET /api/me`
Requires `Authorization: Bearer <jwt>`.
@@ -196,12 +208,15 @@ Requires `Authorization: Bearer <jwt>`.
### Email verification & profile
Registration emails a magic link — `<APP_URL>/verify-email?token=<opaque>`that
expires **15 minutes** after it is sent. Only a hash of the token is stored.
Magic links`<APP_URL>/verify-email?token=<opaque>`expire **15 minutes**
after they are sent; only a hash of the token is stored. The same link/route
backs three things: verifying a new account, [passwordless
login](#post-apiauthmagic-link), and confirming an email change.
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/auth/verify-email` | — | consume a token: verify the address (or apply a pending change), then return a session so the caller is logged in |
| `POST` | `/api/auth/magic-link` | — | email a passwordless login link (see above) |
| `POST` | `/api/email/verification` | ✔ | resend the verification email; `409` if already verified |
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change |