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:
@@ -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 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user