Add stage 7: email verification magic links and a profile page
Backend - New Mail namespace: a Mailer interface with SMTP (phpmailer), PHP mail() (the default fallback), and log-to-file transports, selected by MAIL_TRANSPORT. EmailVerifier issues a hashed, 15-minute magic-link token and sends the link (APP_URL/verify-email?token=...). - Migration 005: email_verifications table + users.verification_email_sent_at. - Registration now emails a verification link (best effort — a send failure doesn't fail registration). - POST /api/auth/verify-email consumes a token and returns a session, so opening the link verifies the address (or applies a pending email change) and logs the user in. Single-use; distinct 400s for invalid/used/expired. - POST /api/email/verification resends; POST /api/email/change requests a deferred change (current password required; link goes to the new address; users.email only updates when that link is opened). Both throttled to once per 60s, returning 429 + retry_after. - GET /api/me and every session payload now include pending_email. Shared SessionPayload builds the user/session JSON for all entry points. Frontend - /verify-email view: posts the token, adopts the returned session, redirects. - /profile view: shows address + status, a resend button with a live cooldown (driven by retry_after / 429), and a change-email form (new address + current password) that surfaces the pending change. - Header shows a "verify email" badge linking to the profile. Tests: 9 new (EmailVerificationTest) covering the link lifecycle, throttle, and deferred change; AuthTest folded into ApiTestCase, which now routes mail to a per-test log. Suite: 32 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,10 +12,12 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
|
||||
| 3 | Todo list + item CRUD API | ✅ done |
|
||||
| 4 | Frontend lists view — list index + create form | ✅ done |
|
||||
| 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 |
|
||||
|
||||
Registration signs the user in immediately, with the account's email marked
|
||||
unverified (`user.email_verified` is `false` until a future stage adds a
|
||||
verification endpoint).
|
||||
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
|
||||
[Email verification](#email-verification--profile).
|
||||
|
||||
## Run with Docker
|
||||
|
||||
@@ -99,6 +101,15 @@ environment). See [.env.example](.env.example).
|
||||
| `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location |
|
||||
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
|
||||
| `JWT_TTL` | `86400` | Token lifetime in seconds |
|
||||
| `APP_URL` | `http://localhost:5173` | Frontend base URL used to build magic links |
|
||||
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) |
|
||||
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@localhost` / `Todo List` | Envelope sender |
|
||||
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
|
||||
| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` |
|
||||
|
||||
SMTP is opt-in; without it the API falls back to PHP's `mail()`. The Docker
|
||||
Compose setup sets `MAIL_TRANSPORT=log` (the container has no MTA) — read the
|
||||
links with `docker compose exec app cat /var/www/storage/mail.log`.
|
||||
|
||||
## API
|
||||
|
||||
@@ -128,6 +139,7 @@ Request:
|
||||
"email": "ada@example.com",
|
||||
"email_verified": false,
|
||||
"email_verified_at": null,
|
||||
"pending_email": null,
|
||||
"created_at": "2026-09-03T12:00:00Z"
|
||||
},
|
||||
"token": "<jwt>",
|
||||
@@ -166,13 +178,43 @@ Requires `Authorization: Bearer <jwt>`.
|
||||
"email": "ada@example.com",
|
||||
"email_verified": false,
|
||||
"email_verified_at": null,
|
||||
"pending_email": null,
|
||||
"created_at": "2026-09-03T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`pending_email` is the address a still-valid email-change link is waiting on, or
|
||||
`null`.
|
||||
|
||||
`401` if the header is missing, malformed, or the token is invalid/expired.
|
||||
|
||||
### 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.
|
||||
|
||||
| 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/email/verification` | ✔ | resend the verification email; `409` if already verified |
|
||||
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change |
|
||||
|
||||
`POST /api/auth/verify-email` body: `{ "token": "..." }`. Success returns the
|
||||
same `{ user, token, expires_at }` envelope as login. A missing/invalid, already
|
||||
used, or expired token is `400` (distinct messages).
|
||||
|
||||
`POST /api/email/verification` and `/api/email/change` are throttled to **once
|
||||
per 60 seconds** per user (shared window). When throttled they return `429` with
|
||||
`error.details.retry_after` (seconds). On success they return `202` with
|
||||
`retry_after`, and `/api/email/change` also returns `pending_email`.
|
||||
|
||||
`POST /api/email/change` body: `{ "email": "new@example.com", "password": "<current>" }`.
|
||||
The current password is required (`422` if wrong). The address must be free
|
||||
(`409`) and different from the current one (`422`). The change is **not applied
|
||||
until** the magic link sent to the new address is opened — until then `GET
|
||||
/api/me` shows the old address with `pending_email` set.
|
||||
|
||||
### Todo lists
|
||||
|
||||
All routes below require `Authorization: Bearer <jwt>`. A list belongs to one
|
||||
@@ -308,9 +350,11 @@ src/Support/Config.php Environment-driven configuration
|
||||
src/Support/Database.php PDO/SQLite connection
|
||||
src/Auth/JwtService.php Issue/verify JWTs
|
||||
src/Auth/AuthMiddleware.php Bearer-token authentication
|
||||
src/Auth/SessionPayload.php Shared user + session JSON shape
|
||||
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
|
||||
src/Http/JsonErrorHandler.php Uniform JSON error envelope
|
||||
src/Http/Controllers/ Request handlers (Auth, TodoList, TodoItem)
|
||||
src/Repository/ Database access (User, TodoList, TodoItem)
|
||||
src/Http/Controllers/ Request handlers (Auth, EmailVerification, TodoList, TodoItem)
|
||||
src/Repository/ Database access (User, EmailVerification, TodoList, TodoItem)
|
||||
src/Support/Validator.php Request-body validation helper
|
||||
migrations/*.sql Schema, applied by bin/migrate.php
|
||||
Dockerfile PHP 8.3 + Apache image
|
||||
|
||||
Reference in New Issue
Block a user