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:
@@ -18,3 +18,23 @@ JWT_SECRET=
|
|||||||
|
|
||||||
# How long an issued token stays valid, in seconds (default: 86400 = 24h).
|
# How long an issued token stays valid, in seconds (default: 86400 = 24h).
|
||||||
JWT_TTL=86400
|
JWT_TTL=86400
|
||||||
|
|
||||||
|
# Base URL of the frontend. Verification magic links point here, e.g.
|
||||||
|
# <APP_URL>/verify-email?token=... (default: http://localhost:5173).
|
||||||
|
APP_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Email delivery.
|
||||||
|
# mail — PHP's built-in mail() function (default)
|
||||||
|
# smtp — the SMTP server configured below
|
||||||
|
# log — append messages to MAIL_LOG_PATH instead of sending (dev/test)
|
||||||
|
MAIL_TRANSPORT=mail
|
||||||
|
MAIL_FROM=no-reply@localhost
|
||||||
|
MAIL_FROM_NAME=Todo List
|
||||||
|
MAIL_LOG_PATH=storage/mail.log
|
||||||
|
|
||||||
|
# Only used when MAIL_TRANSPORT=smtp.
|
||||||
|
MAIL_SMTP_HOST=
|
||||||
|
MAIL_SMTP_PORT=587
|
||||||
|
MAIL_SMTP_USERNAME=
|
||||||
|
MAIL_SMTP_PASSWORD=
|
||||||
|
MAIL_SMTP_ENCRYPTION=tls # tls | ssl | none
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
|
|||||||
| 3 | Todo list + item CRUD API | ✅ done |
|
| 3 | Todo list + item CRUD API | ✅ done |
|
||||||
| 4 | Frontend lists view — list index + create form | ✅ done |
|
| 4 | Frontend lists view — list index + create form | ✅ done |
|
||||||
| 5 | Frontend list detail — items UI with drag-and-drop reorder | ✅ 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
|
Registration signs the user in immediately and emails a magic link that verifies
|
||||||
unverified (`user.email_verified` is `false` until a future stage adds a
|
the address; `user.email_verified` stays `false` until the link is opened. See
|
||||||
verification endpoint).
|
[Email verification](#email-verification--profile).
|
||||||
|
|
||||||
## Run with Docker
|
## Run with Docker
|
||||||
|
|
||||||
@@ -99,6 +101,15 @@ environment). See [.env.example](.env.example).
|
|||||||
| `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location |
|
| `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location |
|
||||||
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
|
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
|
||||||
| `JWT_TTL` | `86400` | Token lifetime in seconds |
|
| `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
|
## API
|
||||||
|
|
||||||
@@ -128,6 +139,7 @@ Request:
|
|||||||
"email": "ada@example.com",
|
"email": "ada@example.com",
|
||||||
"email_verified": false,
|
"email_verified": false,
|
||||||
"email_verified_at": null,
|
"email_verified_at": null,
|
||||||
|
"pending_email": null,
|
||||||
"created_at": "2026-09-03T12:00:00Z"
|
"created_at": "2026-09-03T12:00:00Z"
|
||||||
},
|
},
|
||||||
"token": "<jwt>",
|
"token": "<jwt>",
|
||||||
@@ -166,13 +178,43 @@ Requires `Authorization: Bearer <jwt>`.
|
|||||||
"email": "ada@example.com",
|
"email": "ada@example.com",
|
||||||
"email_verified": false,
|
"email_verified": false,
|
||||||
"email_verified_at": null,
|
"email_verified_at": null,
|
||||||
|
"pending_email": null,
|
||||||
"created_at": "2026-09-03T12:00:00Z"
|
"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.
|
`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
|
### Todo lists
|
||||||
|
|
||||||
All routes below require `Authorization: Bearer <jwt>`. A list belongs to one
|
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/Support/Database.php PDO/SQLite connection
|
||||||
src/Auth/JwtService.php Issue/verify JWTs
|
src/Auth/JwtService.php Issue/verify JWTs
|
||||||
src/Auth/AuthMiddleware.php Bearer-token authentication
|
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/JsonErrorHandler.php Uniform JSON error envelope
|
||||||
src/Http/Controllers/ Request handlers (Auth, TodoList, TodoItem)
|
src/Http/Controllers/ Request handlers (Auth, EmailVerification, TodoList, TodoItem)
|
||||||
src/Repository/ Database access (User, TodoList, TodoItem)
|
src/Repository/ Database access (User, EmailVerification, TodoList, TodoItem)
|
||||||
src/Support/Validator.php Request-body validation helper
|
src/Support/Validator.php Request-body validation helper
|
||||||
migrations/*.sql Schema, applied by bin/migrate.php
|
migrations/*.sql Schema, applied by bin/migrate.php
|
||||||
Dockerfile PHP 8.3 + Apache image
|
Dockerfile PHP 8.3 + Apache image
|
||||||
|
|||||||
+3
-2
@@ -9,9 +9,10 @@
|
|||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
"ext-pdo_sqlite": "*",
|
"ext-pdo_sqlite": "*",
|
||||||
"slim/slim": "^4.12",
|
|
||||||
"slim/psr7": "^1.6",
|
|
||||||
"firebase/php-jwt": "^7.0",
|
"firebase/php-jwt": "^7.0",
|
||||||
|
"phpmailer/phpmailer": "^7.1",
|
||||||
|
"slim/psr7": "^1.6",
|
||||||
|
"slim/slim": "^4.12",
|
||||||
"vlucas/phpdotenv": "^5.6"
|
"vlucas/phpdotenv": "^5.6"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
Generated
+83
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "4cafeb9bb67033ef6767999616374842",
|
"content-hash": "0ee5526493b6ed24fc6d7f3a68af11cc",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "fig/http-message-util",
|
"name": "fig/http-message-util",
|
||||||
@@ -240,6 +240,88 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-07-09T19:38:47+00:00"
|
"time": "2026-07-09T19:38:47+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpmailer/phpmailer",
|
||||||
|
"version": "v7.1.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPMailer/PHPMailer.git",
|
||||||
|
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
|
||||||
|
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-hash": "*",
|
||||||
|
"php": ">=5.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||||
|
"doctrine/annotations": "^1.2.6 || ^1.13.3",
|
||||||
|
"php-parallel-lint/php-console-highlighter": "^1.0.0",
|
||||||
|
"php-parallel-lint/php-parallel-lint": "^1.3.2",
|
||||||
|
"phpcompatibility/php-compatibility": "^10.0.0@dev",
|
||||||
|
"squizlabs/php_codesniffer": "^3.13.5",
|
||||||
|
"yoast/phpunit-polyfills": "^1.0.4"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
|
||||||
|
"directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example",
|
||||||
|
"ext-imap": "Needed to support advanced email address parsing according to RFC822",
|
||||||
|
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
|
||||||
|
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
|
||||||
|
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
|
||||||
|
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
|
||||||
|
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
|
||||||
|
"psr/log": "For optional PSR-3 debug logging",
|
||||||
|
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
|
||||||
|
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PHPMailer\\PHPMailer\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"LGPL-2.1-only"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Marcus Bointon",
|
||||||
|
"email": "phpmailer@synchromedia.co.uk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jim Jagielski",
|
||||||
|
"email": "jimjag@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Andy Prevost",
|
||||||
|
"email": "codeworxtech@users.sourceforge.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brent R. Matzelle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
|
||||||
|
"source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Synchro",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-18T08:06:14+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoption/phpoption",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ services:
|
|||||||
# Leave blank to auto-generate a secret into the storage volume on first run.
|
# Leave blank to auto-generate a secret into the storage volume on first run.
|
||||||
JWT_SECRET: "${JWT_SECRET:-}"
|
JWT_SECRET: "${JWT_SECRET:-}"
|
||||||
JWT_TTL: "${JWT_TTL:-86400}"
|
JWT_TTL: "${JWT_TTL:-86400}"
|
||||||
|
# Magic links point at the frontend dev server.
|
||||||
|
APP_URL: "${APP_URL:-http://localhost:5173}"
|
||||||
|
# The container has no MTA, so log emails to a file by default. View them with:
|
||||||
|
# docker compose exec app cat /var/www/storage/mail.log
|
||||||
|
# Set to "smtp" (with MAIL_SMTP_*) or "mail" to actually send.
|
||||||
|
MAIL_TRANSPORT: "${MAIL_TRANSPORT:-log}"
|
||||||
|
MAIL_FROM: "${MAIL_FROM:-no-reply@localhost}"
|
||||||
|
MAIL_SMTP_HOST: "${MAIL_SMTP_HOST:-}"
|
||||||
|
MAIL_SMTP_PORT: "${MAIL_SMTP_PORT:-587}"
|
||||||
|
MAIL_SMTP_USERNAME: "${MAIL_SMTP_USERNAME:-}"
|
||||||
|
MAIL_SMTP_PASSWORD: "${MAIL_SMTP_PASSWORD:-}"
|
||||||
|
MAIL_SMTP_ENCRYPTION: "${MAIL_SMTP_ENCRYPTION:-tls}"
|
||||||
volumes:
|
volumes:
|
||||||
# Live source: edit on the host, no image rebuild needed.
|
# Live source: edit on the host, no image rebuild needed.
|
||||||
- .:/var/www/html
|
- .:/var/www/html
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Magic-link tokens for verifying an email address. `new_email` is null for a
|
||||||
|
-- plain "verify your current address" link, or the requested address for a
|
||||||
|
-- deferred email change (applied only when the link is opened).
|
||||||
|
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
new_email TEXT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
consumed_at TEXT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications (user_id);
|
||||||
|
|
||||||
|
-- When the last verification / change email was sent to this user, for the
|
||||||
|
-- once-per-minute resend throttle.
|
||||||
|
ALTER TABLE users ADD COLUMN verification_email_sent_at TEXT NULL;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Repository\EmailVerificationRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the JSON representation of a user and the session envelope returned by
|
||||||
|
* register / login / email verification. Shared so every entry point agrees on
|
||||||
|
* the shape.
|
||||||
|
*/
|
||||||
|
final class SessionPayload
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly JwtService $jwt,
|
||||||
|
private readonly EmailVerificationRepository $tokens,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: int, email: string, email_verified_at: string|null, created_at: string} $user
|
||||||
|
* @return array{user: array<string, mixed>, token: string, expires_at: string}
|
||||||
|
*/
|
||||||
|
public function forUser(array $user): array
|
||||||
|
{
|
||||||
|
$issued = $this->jwt->issue($user);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user' => $this->present($user),
|
||||||
|
'token' => $issued['token'],
|
||||||
|
'expires_at' => $issued['expires_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: int, email: string, email_verified_at?: string|null, created_at?: string} $user
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function present(array $user): array
|
||||||
|
{
|
||||||
|
$verifiedAt = $user['email_verified_at'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $user['id'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'email_verified' => $verifiedAt !== null,
|
||||||
|
'email_verified_at' => $verifiedAt,
|
||||||
|
'pending_email' => $this->tokens->pendingEmailFor((int) $user['id']),
|
||||||
|
'created_at' => $user['created_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Auth\JwtService;
|
use App\Auth\SessionPayload;
|
||||||
use App\Exception\ApiException;
|
use App\Exception\ApiException;
|
||||||
use App\Exception\ValidationException;
|
use App\Exception\ValidationException;
|
||||||
|
use App\Mail\EmailVerifier;
|
||||||
|
use App\Mail\MailException;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
@@ -22,7 +24,8 @@ final class AuthController extends Controller
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserRepository $users,
|
private readonly UserRepository $users,
|
||||||
private readonly JwtService $jwt,
|
private readonly SessionPayload $session,
|
||||||
|
private readonly EmailVerifier $verifier,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +42,15 @@ final class AuthController extends Controller
|
|||||||
|
|
||||||
$user = $this->users->create($email, password_hash($password, PASSWORD_DEFAULT));
|
$user = $this->users->create($email, password_hash($password, PASSWORD_DEFAULT));
|
||||||
|
|
||||||
return $this->json($response, $this->session($user), 201);
|
// Best effort: a failed send must not fail registration — the user can
|
||||||
|
// resend from their profile.
|
||||||
|
try {
|
||||||
|
$this->verifier->sendVerification($user);
|
||||||
|
} catch (MailException $e) {
|
||||||
|
error_log('Verification email failed for user ' . $user['id'] . ': ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($response, $this->session->forUser($user), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +67,7 @@ final class AuthController extends Controller
|
|||||||
throw new ApiException('Invalid email or password.', 401);
|
throw new ApiException('Invalid email or password.', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->json($response, $this->session($user));
|
return $this->json($response, $this->session->forUser($user));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,10 +75,7 @@ final class AuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function me(Request $request, Response $response): Response
|
public function me(Request $request, Response $response): Response
|
||||||
{
|
{
|
||||||
/** @var array{id: int, email: string, email_verified_at: string|null, created_at: string} $user */
|
return $this->json($response, ['user' => $this->session->present($this->user($request))]);
|
||||||
$user = $request->getAttribute('user');
|
|
||||||
|
|
||||||
return $this->json($response, ['user' => $this->presentUser($user)]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,38 +114,4 @@ final class AuthController extends Controller
|
|||||||
|
|
||||||
return [mb_strtolower($email), $password];
|
return [mb_strtolower($email), $password];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the standard authentication payload returned by register and login.
|
|
||||||
*
|
|
||||||
* @param array{id: int, email: string, email_verified_at: string|null, created_at: string} $user
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function session(array $user): array
|
|
||||||
{
|
|
||||||
$token = $this->jwt->issue($user);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'user' => $this->presentUser($user),
|
|
||||||
'token' => $token['token'],
|
|
||||||
'expires_at' => $token['expires_at'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{id: int, email: string, email_verified_at?: string|null, created_at?: string} $user
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function presentUser(array $user): array
|
|
||||||
{
|
|
||||||
$verifiedAt = $user['email_verified_at'] ?? null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => (int) $user['id'],
|
|
||||||
'email' => $user['email'],
|
|
||||||
'email_verified' => $verifiedAt !== null,
|
|
||||||
'email_verified_at' => $verifiedAt,
|
|
||||||
'created_at' => $user['created_at'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ abstract class Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The authenticated user attached by AuthMiddleware.
|
* The authenticated user row attached by AuthMiddleware.
|
||||||
*
|
*
|
||||||
* @return array{id: int, email: string, email_verified_at: string|null, created_at: string}
|
* @return array{id: int, email: string, password_hash: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string}
|
||||||
*/
|
*/
|
||||||
protected function user(Request $request): array
|
protected function user(Request $request): array
|
||||||
{
|
{
|
||||||
/** @var array{id: int, email: string, email_verified_at: string|null, created_at: string} $user */
|
/** @var array{id: int, email: string, password_hash: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string} $user */
|
||||||
$user = $request->getAttribute('user');
|
$user = $request->getAttribute('user');
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Auth\SessionPayload;
|
||||||
|
use App\Exception\ApiException;
|
||||||
|
use App\Exception\ValidationException;
|
||||||
|
use App\Mail\EmailVerifier;
|
||||||
|
use App\Mail\MailException;
|
||||||
|
use App\Repository\EmailVerificationRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
final class EmailVerificationController extends Controller
|
||||||
|
{
|
||||||
|
private const RESEND_INTERVAL_SECONDS = 60;
|
||||||
|
private const EMAIL_MAX = 255;
|
||||||
|
private const PASSWORD_MAX = 72;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepository $users,
|
||||||
|
private readonly EmailVerificationRepository $tokens,
|
||||||
|
private readonly EmailVerifier $verifier,
|
||||||
|
private readonly SessionPayload $session,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/verify-email (public)
|
||||||
|
*
|
||||||
|
* Consumes a magic-link token: verifies the address (or applies a pending
|
||||||
|
* email change), then returns a session so the caller is logged in.
|
||||||
|
*/
|
||||||
|
public function verify(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$token = trim((string) ($this->body($request)['token'] ?? ''));
|
||||||
|
if ($token === '') {
|
||||||
|
throw new ValidationException(['token' => ['A verification token is required.']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->tokens->findByHash(hash('sha256', $token));
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
throw new ApiException('This verification link is not valid.', 400);
|
||||||
|
}
|
||||||
|
if ($row['consumed_at'] !== null) {
|
||||||
|
throw new ApiException('This verification link has already been used.', 400);
|
||||||
|
}
|
||||||
|
if (strtotime($row['expires_at']) < time()) {
|
||||||
|
throw new ApiException('This verification link has expired. Request a new one.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($row['new_email'] !== null) {
|
||||||
|
$clash = $this->users->findByEmail($row['new_email']);
|
||||||
|
if ($clash !== null && $clash['id'] !== $row['user_id']) {
|
||||||
|
throw new ApiException('That email address is now in use by another account.', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->tokens->consume($row['id'])) {
|
||||||
|
throw new ApiException('This verification link has already been used.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($row['new_email'] !== null) {
|
||||||
|
$this->users->updateEmail($row['user_id'], $row['new_email']);
|
||||||
|
} else {
|
||||||
|
$this->users->markEmailVerified($row['user_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->users->findById($row['user_id']);
|
||||||
|
if ($user === null) {
|
||||||
|
throw new ApiException('The account for this link no longer exists.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($response, $this->session->forUser($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/email/verification (auth) — resend the verification email.
|
||||||
|
*/
|
||||||
|
public function resend(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$user = $this->user($request);
|
||||||
|
|
||||||
|
if (($user['email_verified_at'] ?? null) !== null) {
|
||||||
|
throw new ApiException('Your email address is already verified.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->guardResendInterval($user);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->verifier->sendVerification($user);
|
||||||
|
} catch (MailException $e) {
|
||||||
|
throw new ApiException('Could not send the email right now. Please try again shortly.', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($response, [
|
||||||
|
'message' => 'Verification email sent.',
|
||||||
|
'retry_after' => self::RESEND_INTERVAL_SECONDS,
|
||||||
|
], 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/email/change (auth) — request a deferred email change. The new
|
||||||
|
* address only takes effect once its magic link is opened.
|
||||||
|
*/
|
||||||
|
public function requestChange(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$user = $this->user($request);
|
||||||
|
$body = $this->body($request);
|
||||||
|
|
||||||
|
$newEmail = is_string($body['email'] ?? null) ? mb_strtolower(trim($body['email'])) : '';
|
||||||
|
$password = is_string($body['password'] ?? null) ? $body['password'] : '';
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
if ($newEmail === '') {
|
||||||
|
$errors['email'][] = 'Email is required.';
|
||||||
|
} elseif (!filter_var($newEmail, FILTER_VALIDATE_EMAIL) || strlen($newEmail) > self::EMAIL_MAX) {
|
||||||
|
$errors['email'][] = 'Enter a valid email address.';
|
||||||
|
} elseif ($newEmail === mb_strtolower($user['email'])) {
|
||||||
|
$errors['email'][] = 'That is already your email address.';
|
||||||
|
}
|
||||||
|
if ($password === '' || strlen($password) > self::PASSWORD_MAX) {
|
||||||
|
$errors['password'][] = 'Your current password is required.';
|
||||||
|
}
|
||||||
|
if ($errors !== []) {
|
||||||
|
throw new ValidationException($errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$full = $this->users->findById($user['id']);
|
||||||
|
if ($full === null || !password_verify($password, $full['password_hash'])) {
|
||||||
|
throw new ValidationException(['password' => ['That password is incorrect.']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->users->findByEmail($newEmail) !== null) {
|
||||||
|
throw new ApiException('That email address is already in use.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->guardResendInterval($user);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->verifier->sendEmailChange($user, $newEmail);
|
||||||
|
} catch (MailException $e) {
|
||||||
|
throw new ApiException('Could not send the email right now. Please try again shortly.', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($response, [
|
||||||
|
'message' => 'Confirmation email sent to the new address.',
|
||||||
|
'pending_email' => $newEmail,
|
||||||
|
'retry_after' => self::RESEND_INTERVAL_SECONDS,
|
||||||
|
], 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{verification_email_sent_at?: string|null} $user
|
||||||
|
*/
|
||||||
|
private function guardResendInterval(array $user): void
|
||||||
|
{
|
||||||
|
$lastSent = $user['verification_email_sent_at'] ?? null;
|
||||||
|
if ($lastSent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsed = time() - (int) strtotime($lastSent);
|
||||||
|
if ($elapsed < self::RESEND_INTERVAL_SECONDS) {
|
||||||
|
throw new ApiException(
|
||||||
|
'Please wait a moment before requesting another email.',
|
||||||
|
429,
|
||||||
|
['retry_after' => self::RESEND_INTERVAL_SECONDS - $elapsed],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Repository\EmailVerificationRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues a magic-link token and emails it, for both "verify your address" and
|
||||||
|
* "confirm your new address" flows.
|
||||||
|
*/
|
||||||
|
final class EmailVerifier
|
||||||
|
{
|
||||||
|
public const TOKEN_TTL_SECONDS = 900; // 15 minutes
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EmailVerificationRepository $tokens,
|
||||||
|
private readonly UserRepository $users,
|
||||||
|
private readonly Mailer $mailer,
|
||||||
|
private readonly string $appUrl,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a link that verifies the user's current address.
|
||||||
|
*
|
||||||
|
* @param array{id: int, email: string} $user
|
||||||
|
*/
|
||||||
|
public function sendVerification(array $user): void
|
||||||
|
{
|
||||||
|
$link = $this->issue((int) $user['id'], null);
|
||||||
|
|
||||||
|
$this->mailer->send(
|
||||||
|
$user['email'],
|
||||||
|
'Verify your email address',
|
||||||
|
"Welcome!\n\n"
|
||||||
|
. "Confirm this email address by opening the link below. It expires in 15 minutes.\n\n"
|
||||||
|
. $link . "\n\n"
|
||||||
|
. "If you didn't create an account, you can ignore this message.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a link (to the new address) that, once opened, changes the user's
|
||||||
|
* email to $newEmail.
|
||||||
|
*
|
||||||
|
* @param array{id: int, email: string} $user
|
||||||
|
*/
|
||||||
|
public function sendEmailChange(array $user, string $newEmail): void
|
||||||
|
{
|
||||||
|
$link = $this->issue((int) $user['id'], $newEmail);
|
||||||
|
|
||||||
|
$this->mailer->send(
|
||||||
|
$newEmail,
|
||||||
|
'Confirm your new email address',
|
||||||
|
"A request was made to change the email address on an account to this one.\n\n"
|
||||||
|
. "Confirm the change by opening the link below. It expires in 15 minutes.\n\n"
|
||||||
|
. $link . "\n\n"
|
||||||
|
. "If this wasn't you, you can ignore this message; nothing will change.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issue(int $userId, ?string $newEmail): string
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
$this->tokens->issue($userId, hash('sha256', $token), $newEmail, self::TOKEN_TTL_SECONDS);
|
||||||
|
$this->users->markVerificationEmailSent($userId);
|
||||||
|
|
||||||
|
return $this->appUrl . '/verify-email?token=' . $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends each message as a JSON line to a file instead of sending it. Handy for
|
||||||
|
* local development and used by the test suite. Enable with MAIL_TRANSPORT=log.
|
||||||
|
*/
|
||||||
|
final class LogMailer implements Mailer
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $path)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $to, string $subject, string $body): void
|
||||||
|
{
|
||||||
|
$dir = dirname($this->path);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$line = json_encode([
|
||||||
|
'sent_at' => gmdate('c'),
|
||||||
|
'to' => $to,
|
||||||
|
'subject' => $subject,
|
||||||
|
'body' => $body,
|
||||||
|
], JSON_UNESCAPED_SLASHES) . "\n";
|
||||||
|
|
||||||
|
if (file_put_contents($this->path, $line, FILE_APPEND | LOCK_EX) === false) {
|
||||||
|
throw new MailException("Could not write to the mail log at {$this->path}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class MailException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
interface Mailer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a plain-text email.
|
||||||
|
*
|
||||||
|
* @throws MailException when delivery fails.
|
||||||
|
*/
|
||||||
|
public function send(string $to, string $subject, string $body): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Support\MailConfig;
|
||||||
|
use PHPMailer\PHPMailer\Exception as PhpMailerException;
|
||||||
|
use PHPMailer\PHPMailer\PHPMailer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends via SMTP when MAIL_TRANSPORT=smtp, otherwise via PHP's mail() function.
|
||||||
|
*/
|
||||||
|
final class PhpMailerMailer implements Mailer
|
||||||
|
{
|
||||||
|
public function __construct(private readonly MailConfig $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $to, string $subject, string $body): void
|
||||||
|
{
|
||||||
|
$mail = new PHPMailer(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($this->config->transport === 'smtp') {
|
||||||
|
$this->configureSmtp($mail);
|
||||||
|
} else {
|
||||||
|
$mail->isMail();
|
||||||
|
}
|
||||||
|
|
||||||
|
$mail->setFrom($this->config->fromAddress, $this->config->fromName);
|
||||||
|
$mail->addAddress($to);
|
||||||
|
$mail->Subject = $subject;
|
||||||
|
$mail->isHTML(false);
|
||||||
|
$mail->Body = $body;
|
||||||
|
|
||||||
|
$mail->send();
|
||||||
|
} catch (PhpMailerException $e) {
|
||||||
|
throw new MailException('Failed to send email: ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configureSmtp(PHPMailer $mail): void
|
||||||
|
{
|
||||||
|
$mail->isSMTP();
|
||||||
|
$mail->Host = (string) $this->config->smtpHost;
|
||||||
|
$mail->Port = $this->config->smtpPort;
|
||||||
|
|
||||||
|
if ($this->config->smtpUsername !== null) {
|
||||||
|
$mail->SMTPAuth = true;
|
||||||
|
$mail->Username = $this->config->smtpUsername;
|
||||||
|
$mail->Password = (string) $this->config->smtpPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($this->config->smtpEncryption) {
|
||||||
|
case 'ssl':
|
||||||
|
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||||
|
break;
|
||||||
|
case 'none':
|
||||||
|
$mail->SMTPSecure = '';
|
||||||
|
$mail->SMTPAutoTLS = false;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data access for `email_verifications` (magic-link tokens).
|
||||||
|
*
|
||||||
|
* @phpstan-type TokenRow array{
|
||||||
|
* id: int, user_id: int, token_hash: string, new_email: string|null,
|
||||||
|
* expires_at: string, consumed_at: string|null, created_at: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
final class EmailVerificationRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace any outstanding (unconsumed) tokens of the same kind for the user,
|
||||||
|
* then store a new one.
|
||||||
|
*/
|
||||||
|
public function issue(int $userId, string $tokenHash, ?string $newEmail, int $ttlSeconds): void
|
||||||
|
{
|
||||||
|
// Drop the user's outstanding tokens of the same kind (verify vs. change).
|
||||||
|
$nullClause = $newEmail === null ? 'new_email IS NULL' : 'new_email IS NOT NULL';
|
||||||
|
$this->pdo
|
||||||
|
->prepare("DELETE FROM email_verifications WHERE user_id = :user AND consumed_at IS NULL AND {$nullClause}")
|
||||||
|
->execute(['user' => $userId]);
|
||||||
|
|
||||||
|
$insert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO email_verifications (user_id, token_hash, new_email, expires_at)
|
||||||
|
VALUES (:user, :hash, :new_email, :expires_at)'
|
||||||
|
);
|
||||||
|
$insert->execute([
|
||||||
|
'user' => $userId,
|
||||||
|
'hash' => $tokenHash,
|
||||||
|
'new_email' => $newEmail,
|
||||||
|
'expires_at' => gmdate('Y-m-d\TH:i:s\Z', time() + $ttlSeconds),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return TokenRow|null
|
||||||
|
*/
|
||||||
|
public function findByHash(string $tokenHash): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT * FROM email_verifications WHERE token_hash = :hash');
|
||||||
|
$stmt->execute(['hash' => $tokenHash]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if ($row === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['id'] = (int) $row['id'];
|
||||||
|
$row['user_id'] = (int) $row['user_id'];
|
||||||
|
|
||||||
|
/** @var TokenRow $row */
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the token consumed. Returns false if it was already consumed (race).
|
||||||
|
*/
|
||||||
|
public function consume(int $id): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"UPDATE email_verifications SET consumed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
WHERE id = :id AND consumed_at IS NULL"
|
||||||
|
);
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
|
||||||
|
return $stmt->rowCount() === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address a still-valid change request is waiting on, if any.
|
||||||
|
*/
|
||||||
|
public function pendingEmailFor(int $userId): ?string
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT new_email FROM email_verifications
|
||||||
|
WHERE user_id = :user AND new_email IS NOT NULL AND consumed_at IS NULL
|
||||||
|
AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
ORDER BY id DESC LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute(['user' => $userId]);
|
||||||
|
|
||||||
|
$value = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
return $value === false ? null : (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use PDO;
|
|||||||
/**
|
/**
|
||||||
* Data access for the `users` table. Rows are returned as associative arrays.
|
* Data access for the `users` table. Rows are returned as associative arrays.
|
||||||
*
|
*
|
||||||
* @phpstan-type UserRow array{id: int, email: string, password_hash: string, email_verified_at: string|null, created_at: string, updated_at: string}
|
* @phpstan-type UserRow array{id: int, email: string, password_hash: string, email_verified_at: string|null, verification_email_sent_at: string|null, created_at: string, updated_at: string}
|
||||||
*/
|
*/
|
||||||
final class UserRepository
|
final class UserRepository
|
||||||
{
|
{
|
||||||
@@ -62,6 +62,35 @@ final class UserRepository
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function markEmailVerified(int $id): void
|
||||||
|
{
|
||||||
|
$this->pdo->prepare(
|
||||||
|
"UPDATE users SET email_verified_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
WHERE id = :id"
|
||||||
|
)->execute(['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a confirmed email change: set the address and mark it verified.
|
||||||
|
*/
|
||||||
|
public function updateEmail(int $id, string $email): void
|
||||||
|
{
|
||||||
|
$this->pdo->prepare(
|
||||||
|
"UPDATE users SET email = :email,
|
||||||
|
email_verified_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
WHERE id = :id"
|
||||||
|
)->execute(['email' => $email, 'id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markVerificationEmailSent(int $id): void
|
||||||
|
{
|
||||||
|
$this->pdo->prepare(
|
||||||
|
"UPDATE users SET verification_email_sent_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = :id"
|
||||||
|
)->execute(['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $row
|
* @param array<string, mixed> $row
|
||||||
* @return UserRow
|
* @return UserRow
|
||||||
|
|||||||
+23
-1
@@ -15,6 +15,9 @@ final class Config
|
|||||||
public readonly string $jwtSecret,
|
public readonly string $jwtSecret,
|
||||||
public readonly int $jwtTtl,
|
public readonly int $jwtTtl,
|
||||||
public readonly bool $displayErrors,
|
public readonly bool $displayErrors,
|
||||||
|
/** Base URL of the frontend, used to build magic links. */
|
||||||
|
public readonly string $appUrl,
|
||||||
|
public readonly MailConfig $mail,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +41,26 @@ final class Config
|
|||||||
$jwtTtl = (int) (self::env('JWT_TTL') ?? '86400');
|
$jwtTtl = (int) (self::env('JWT_TTL') ?? '86400');
|
||||||
$displayErrors = filter_var(self::env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL);
|
$displayErrors = filter_var(self::env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL);
|
||||||
|
|
||||||
return new self($databasePath, $jwtSecret, $jwtTtl, $displayErrors);
|
$appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/');
|
||||||
|
|
||||||
|
$mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log');
|
||||||
|
if (!self::isAbsolutePath($mailLogPath)) {
|
||||||
|
$mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mail = new MailConfig(
|
||||||
|
transport: strtolower(self::env('MAIL_TRANSPORT', 'mail')),
|
||||||
|
fromAddress: self::env('MAIL_FROM', 'no-reply@localhost'),
|
||||||
|
fromName: self::env('MAIL_FROM_NAME', 'Todo List'),
|
||||||
|
logPath: $mailLogPath,
|
||||||
|
smtpHost: self::env('MAIL_SMTP_HOST'),
|
||||||
|
smtpPort: (int) (self::env('MAIL_SMTP_PORT') ?? '587'),
|
||||||
|
smtpUsername: self::env('MAIL_SMTP_USERNAME'),
|
||||||
|
smtpPassword: self::env('MAIL_SMTP_PASSWORD'),
|
||||||
|
smtpEncryption: strtolower(self::env('MAIL_SMTP_ENCRYPTION', 'tls')),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new self($databasePath, $jwtSecret, $jwtTtl, $displayErrors, $appUrl, $mail);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function env(string $key, ?string $default = null): ?string
|
private static function env(string $key, ?string $default = null): ?string
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail delivery settings.
|
||||||
|
*
|
||||||
|
* `transport`:
|
||||||
|
* - "mail" (default) — PHP's built-in mail() function
|
||||||
|
* - "smtp" — an SMTP server (host/port/credentials below)
|
||||||
|
* - "log" — append messages to `logPath` instead of sending (dev/test)
|
||||||
|
*/
|
||||||
|
final class MailConfig
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $transport,
|
||||||
|
public readonly string $fromAddress,
|
||||||
|
public readonly string $fromName,
|
||||||
|
public readonly string $logPath,
|
||||||
|
public readonly ?string $smtpHost,
|
||||||
|
public readonly int $smtpPort,
|
||||||
|
public readonly ?string $smtpUsername,
|
||||||
|
public readonly ?string $smtpPassword,
|
||||||
|
public readonly string $smtpEncryption,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-1
@@ -4,10 +4,17 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use App\Auth\AuthMiddleware;
|
use App\Auth\AuthMiddleware;
|
||||||
use App\Auth\JwtService;
|
use App\Auth\JwtService;
|
||||||
|
use App\Auth\SessionPayload;
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Controllers\EmailVerificationController;
|
||||||
use App\Http\Controllers\TodoItemController;
|
use App\Http\Controllers\TodoItemController;
|
||||||
use App\Http\Controllers\TodoListController;
|
use App\Http\Controllers\TodoListController;
|
||||||
use App\Http\JsonErrorHandler;
|
use App\Http\JsonErrorHandler;
|
||||||
|
use App\Mail\EmailVerifier;
|
||||||
|
use App\Mail\LogMailer;
|
||||||
|
use App\Mail\Mailer;
|
||||||
|
use App\Mail\PhpMailerMailer;
|
||||||
|
use App\Repository\EmailVerificationRepository;
|
||||||
use App\Repository\TodoItemRepository;
|
use App\Repository\TodoItemRepository;
|
||||||
use App\Repository\TodoListRepository;
|
use App\Repository\TodoListRepository;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
@@ -36,9 +43,18 @@ $errorMiddleware->setDefaultErrorHandler(
|
|||||||
$users = new UserRepository($database->pdo());
|
$users = new UserRepository($database->pdo());
|
||||||
$todoLists = new TodoListRepository($database->pdo());
|
$todoLists = new TodoListRepository($database->pdo());
|
||||||
$todoItems = new TodoItemRepository($database->pdo());
|
$todoItems = new TodoItemRepository($database->pdo());
|
||||||
|
$verificationTokens = new EmailVerificationRepository($database->pdo());
|
||||||
$jwt = new JwtService($config->jwtSecret, $config->jwtTtl);
|
$jwt = new JwtService($config->jwtSecret, $config->jwtTtl);
|
||||||
|
$session = new SessionPayload($jwt, $verificationTokens);
|
||||||
|
|
||||||
$authController = new AuthController($users, $jwt);
|
/** @var Mailer $mailer */
|
||||||
|
$mailer = $config->mail->transport === 'log'
|
||||||
|
? new LogMailer($config->mail->logPath)
|
||||||
|
: new PhpMailerMailer($config->mail);
|
||||||
|
$verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->appUrl);
|
||||||
|
|
||||||
|
$authController = new AuthController($users, $session, $verifier);
|
||||||
|
$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session);
|
||||||
$listController = new TodoListController($todoLists);
|
$listController = new TodoListController($todoLists);
|
||||||
$itemController = new TodoItemController($todoLists, $todoItems);
|
$itemController = new TodoItemController($todoLists, $todoItems);
|
||||||
$authMiddleware = new AuthMiddleware($jwt, $users);
|
$authMiddleware = new AuthMiddleware($jwt, $users);
|
||||||
@@ -47,6 +63,7 @@ $authMiddleware = new AuthMiddleware($jwt, $users);
|
|||||||
|
|
||||||
$app->group('/api', function (RouteCollectorProxy $group) use (
|
$app->group('/api', function (RouteCollectorProxy $group) use (
|
||||||
$authController,
|
$authController,
|
||||||
|
$emailController,
|
||||||
$listController,
|
$listController,
|
||||||
$itemController,
|
$itemController,
|
||||||
$authMiddleware,
|
$authMiddleware,
|
||||||
@@ -58,8 +75,11 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
|
|||||||
|
|
||||||
$group->post('/auth/register', [$authController, 'register']);
|
$group->post('/auth/register', [$authController, 'register']);
|
||||||
$group->post('/auth/login', [$authController, 'login']);
|
$group->post('/auth/login', [$authController, 'login']);
|
||||||
|
$group->post('/auth/verify-email', [$emailController, 'verify']);
|
||||||
|
|
||||||
$group->get('/me', [$authController, 'me'])->add($authMiddleware);
|
$group->get('/me', [$authController, 'me'])->add($authMiddleware);
|
||||||
|
$group->post('/email/verification', [$emailController, 'resend'])->add($authMiddleware);
|
||||||
|
$group->post('/email/change', [$emailController, 'requestChange'])->add($authMiddleware);
|
||||||
|
|
||||||
$group->group('/lists', function (RouteCollectorProxy $lists) use ($listController, $itemController) {
|
$group->group('/lists', function (RouteCollectorProxy $lists) use ($listController, $itemController) {
|
||||||
$lists->get('', [$listController, 'index']);
|
$lists->get('', [$listController, 'index']);
|
||||||
|
|||||||
+82
-7
@@ -12,20 +12,32 @@ use Slim\Psr7\Factory\ServerRequestFactory;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Boots the real Slim app against a throwaway SQLite database with all
|
* Boots the real Slim app against a throwaway SQLite database with all
|
||||||
* migrations applied.
|
* migrations applied. Email goes to a per-test log file (MAIL_TRANSPORT=log).
|
||||||
*/
|
*/
|
||||||
abstract class ApiTestCase extends TestCase
|
abstract class ApiTestCase extends TestCase
|
||||||
{
|
{
|
||||||
protected App $app;
|
protected App $app;
|
||||||
private string $databasePath;
|
private string $databasePath;
|
||||||
|
private string $mailLogPath;
|
||||||
|
private ?PDO $db = null;
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $env = [];
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->databasePath = sys_get_temp_dir() . '/todo-test-' . uniqid() . '.sqlite';
|
$unique = uniqid('todo-test-', true);
|
||||||
putenv('DATABASE_PATH=' . $this->databasePath);
|
$this->databasePath = sys_get_temp_dir() . "/{$unique}.sqlite";
|
||||||
$_ENV['DATABASE_PATH'] = $this->databasePath;
|
$this->mailLogPath = sys_get_temp_dir() . "/{$unique}.mail.log";
|
||||||
|
|
||||||
$pdo = new PDO('sqlite:' . $this->databasePath);
|
$this->setEnv([
|
||||||
|
'DATABASE_PATH' => $this->databasePath,
|
||||||
|
'APP_URL' => 'https://app.test',
|
||||||
|
'MAIL_TRANSPORT' => 'log',
|
||||||
|
'MAIL_LOG_PATH' => $this->mailLogPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdo = $this->db();
|
||||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
foreach (glob(dirname(__DIR__) . '/migrations/*.sql') ?: [] as $migration) {
|
foreach (glob(dirname(__DIR__) . '/migrations/*.sql') ?: [] as $migration) {
|
||||||
$pdo->exec((string) file_get_contents($migration));
|
$pdo->exec((string) file_get_contents($migration));
|
||||||
@@ -36,9 +48,39 @@ abstract class ApiTestCase extends TestCase
|
|||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
|
$this->db = null;
|
||||||
@unlink($this->databasePath);
|
@unlink($this->databasePath);
|
||||||
putenv('DATABASE_PATH');
|
@unlink($this->mailLogPath);
|
||||||
unset($_ENV['DATABASE_PATH']);
|
|
||||||
|
foreach (array_keys($this->env) as $key) {
|
||||||
|
putenv($key);
|
||||||
|
unset($_ENV[$key], $_SERVER[$key]);
|
||||||
|
}
|
||||||
|
$this->env = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $vars */
|
||||||
|
private function setEnv(array $vars): void
|
||||||
|
{
|
||||||
|
foreach ($vars as $key => $value) {
|
||||||
|
putenv("{$key}={$value}");
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
$this->env[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A connection to the test database, for seeding rows directly. */
|
||||||
|
protected function db(): PDO
|
||||||
|
{
|
||||||
|
if ($this->db === null) {
|
||||||
|
$this->db = new PDO('sqlite:' . $this->databasePath, null, null, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +120,39 @@ abstract class ApiTestCase extends TestCase
|
|||||||
return ['Authorization' => 'Bearer ' . $token];
|
return ['Authorization' => 'Bearer ' . $token];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All emails sent so far, oldest first. @return list<array{to: string, subject: string, body: string}> */
|
||||||
|
protected function sentEmails(): array
|
||||||
|
{
|
||||||
|
if (!is_file($this->mailLogPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array_filter(explode("\n", (string) file_get_contents($this->mailLogPath)));
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR),
|
||||||
|
array_values($lines),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{to: string, subject: string, body: string} */
|
||||||
|
protected function lastEmail(): array
|
||||||
|
{
|
||||||
|
$emails = $this->sentEmails();
|
||||||
|
self::assertNotEmpty($emails, 'Expected an email to have been sent.');
|
||||||
|
|
||||||
|
return $emails[array_key_last($emails)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the magic-link token out of an email body. */
|
||||||
|
protected function tokenFromEmail(?array $email = null): string
|
||||||
|
{
|
||||||
|
$email ??= $this->lastEmail();
|
||||||
|
self::assertSame(1, preg_match('/verify-email\?token=([a-f0-9]+)/', $email['body'], $m));
|
||||||
|
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
+2
-58
@@ -4,38 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use PDO;
|
final class AuthTest extends ApiTestCase
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
|
||||||
use Slim\App;
|
|
||||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
|
||||||
|
|
||||||
final class AuthTest extends TestCase
|
|
||||||
{
|
{
|
||||||
private App $app;
|
|
||||||
private string $databasePath;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->databasePath = sys_get_temp_dir() . '/todo-test-' . uniqid() . '.sqlite';
|
|
||||||
putenv('DATABASE_PATH=' . $this->databasePath);
|
|
||||||
$_ENV['DATABASE_PATH'] = $this->databasePath;
|
|
||||||
|
|
||||||
$pdo = new PDO('sqlite:' . $this->databasePath);
|
|
||||||
foreach (glob(dirname(__DIR__) . '/migrations/*.sql') ?: [] as $migration) {
|
|
||||||
$pdo->exec((string) file_get_contents($migration));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->app = require dirname(__DIR__) . '/src/bootstrap.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
@unlink($this->databasePath);
|
|
||||||
putenv('DATABASE_PATH');
|
|
||||||
unset($_ENV['DATABASE_PATH']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_registration_returns_a_user_and_token(): void
|
public function test_registration_returns_a_user_and_token(): void
|
||||||
{
|
{
|
||||||
$response = $this->request('POST', '/api/auth/register', [
|
$response = $this->request('POST', '/api/auth/register', [
|
||||||
@@ -49,6 +19,7 @@ final class AuthTest extends TestCase
|
|||||||
self::assertSame('ada@example.com', $body['user']['email']);
|
self::assertSame('ada@example.com', $body['user']['email']);
|
||||||
self::assertFalse($body['user']['email_verified']);
|
self::assertFalse($body['user']['email_verified']);
|
||||||
self::assertNull($body['user']['email_verified_at']);
|
self::assertNull($body['user']['email_verified_at']);
|
||||||
|
self::assertNull($body['user']['pending_email']);
|
||||||
self::assertArrayNotHasKey('password_hash', $body['user']);
|
self::assertArrayNotHasKey('password_hash', $body['user']);
|
||||||
self::assertNotEmpty($body['token']);
|
self::assertNotEmpty($body['token']);
|
||||||
}
|
}
|
||||||
@@ -123,31 +94,4 @@ final class AuthTest extends TestCase
|
|||||||
self::assertSame(200, $response->getStatusCode());
|
self::assertSame(200, $response->getStatusCode());
|
||||||
self::assertSame('linus@example.com', $this->decode($response)['user']['email']);
|
self::assertSame('linus@example.com', $this->decode($response)['user']['email']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|null $body
|
|
||||||
* @param array<string, string> $headers
|
|
||||||
*/
|
|
||||||
private function request(string $method, string $path, ?array $body = null, array $headers = []): ResponseInterface
|
|
||||||
{
|
|
||||||
$request = (new ServerRequestFactory())->createServerRequest($method, $path);
|
|
||||||
|
|
||||||
foreach ($headers as $name => $value) {
|
|
||||||
$request = $request->withHeader($name, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($body !== null) {
|
|
||||||
$request = $request->withParsedBody($body)->withHeader('Content-Type', 'application/json');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->app->handle($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function decode(ResponseInterface $response): array
|
|
||||||
{
|
|
||||||
return (array) json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
final class EmailVerificationTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
public function test_registration_sends_a_verification_email(): void
|
||||||
|
{
|
||||||
|
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||||
|
|
||||||
|
$email = $this->lastEmail();
|
||||||
|
self::assertSame('ada@example.com', $email['to']);
|
||||||
|
self::assertStringContainsString('verify-email?token=', $email['body']);
|
||||||
|
self::assertStringContainsString('https://app.test/verify-email?token=', $email['body']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_opening_the_magic_link_verifies_and_logs_in(): void
|
||||||
|
{
|
||||||
|
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||||
|
|
||||||
|
$response = $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]);
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$body = $this->decode($response);
|
||||||
|
self::assertTrue($body['user']['email_verified']);
|
||||||
|
self::assertNotEmpty($body['token']);
|
||||||
|
|
||||||
|
$me = $this->decode($this->request('GET', '/api/me', null, ['Authorization' => 'Bearer ' . $body['token']]));
|
||||||
|
self::assertTrue($me['user']['email_verified']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_invalid_token_is_rejected(): void
|
||||||
|
{
|
||||||
|
$response = $this->request('POST', '/api/auth/verify-email', ['token' => 'not-a-real-token']);
|
||||||
|
self::assertSame(400, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_token_cannot_be_used_twice(): void
|
||||||
|
{
|
||||||
|
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||||
|
$token = $this->tokenFromEmail();
|
||||||
|
|
||||||
|
self::assertSame(200, $this->request('POST', '/api/auth/verify-email', ['token' => $token])->getStatusCode());
|
||||||
|
|
||||||
|
$again = $this->request('POST', '/api/auth/verify-email', ['token' => $token]);
|
||||||
|
self::assertSame(400, $again->getStatusCode());
|
||||||
|
self::assertStringContainsString('already been used', $this->decode($again)['error']['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_expired_token_is_rejected(): void
|
||||||
|
{
|
||||||
|
$this->request('POST', '/api/auth/register', ['email' => 'ada@example.com', 'password' => 'password123']);
|
||||||
|
$token = $this->tokenFromEmail();
|
||||||
|
|
||||||
|
$this->db()->prepare('UPDATE email_verifications SET expires_at = :past WHERE token_hash = :hash')->execute([
|
||||||
|
'past' => gmdate('Y-m-d\TH:i:s\Z', time() - 60),
|
||||||
|
'hash' => hash('sha256', $token),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->request('POST', '/api/auth/verify-email', ['token' => $token]);
|
||||||
|
self::assertSame(400, $response->getStatusCode());
|
||||||
|
self::assertStringContainsString('expired', $this->decode($response)['error']['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resend_is_throttled_immediately_after_registration(): void
|
||||||
|
{
|
||||||
|
$auth = $this->authHeader('ada@example.com');
|
||||||
|
|
||||||
|
$response = $this->request('POST', '/api/email/verification', null, $auth);
|
||||||
|
|
||||||
|
self::assertSame(429, $response->getStatusCode());
|
||||||
|
self::assertArrayHasKey('retry_after', $this->decode($response)['error']['details']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resend_works_once_the_interval_has_passed(): void
|
||||||
|
{
|
||||||
|
$auth = $this->authHeader('ada@example.com');
|
||||||
|
$this->cooldownElapsed('ada@example.com');
|
||||||
|
|
||||||
|
$response = $this->request('POST', '/api/email/verification', null, $auth);
|
||||||
|
|
||||||
|
self::assertSame(202, $response->getStatusCode());
|
||||||
|
self::assertCount(2, $this->sentEmails());
|
||||||
|
self::assertSame('ada@example.com', $this->lastEmail()['to']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resend_conflicts_when_already_verified(): void
|
||||||
|
{
|
||||||
|
$auth = $this->authHeader('ada@example.com');
|
||||||
|
$this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]);
|
||||||
|
$this->cooldownElapsed('ada@example.com');
|
||||||
|
|
||||||
|
$response = $this->request('POST', '/api/email/verification', null, $auth);
|
||||||
|
self::assertSame(409, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_email_change_is_deferred_until_the_new_address_is_confirmed(): void
|
||||||
|
{
|
||||||
|
$auth = $this->authHeader('old@example.com');
|
||||||
|
$this->cooldownElapsed('old@example.com');
|
||||||
|
|
||||||
|
$change = $this->request('POST', '/api/email/change', [
|
||||||
|
'email' => 'New@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
], $auth);
|
||||||
|
|
||||||
|
self::assertSame(202, $change->getStatusCode());
|
||||||
|
self::assertSame('new@example.com', $this->decode($change)['pending_email']);
|
||||||
|
self::assertSame('new@example.com', $this->lastEmail()['to']);
|
||||||
|
|
||||||
|
// Not applied yet.
|
||||||
|
$me = $this->decode($this->request('GET', '/api/me', null, $auth));
|
||||||
|
self::assertSame('old@example.com', $me['user']['email']);
|
||||||
|
self::assertSame('new@example.com', $me['user']['pending_email']);
|
||||||
|
|
||||||
|
// Open the link from the new inbox.
|
||||||
|
$verified = $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]);
|
||||||
|
self::assertSame(200, $verified->getStatusCode());
|
||||||
|
$body = $this->decode($verified);
|
||||||
|
self::assertSame('new@example.com', $body['user']['email']);
|
||||||
|
self::assertTrue($body['user']['email_verified']);
|
||||||
|
self::assertNull($body['user']['pending_email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_email_change_requires_the_current_password(): void
|
||||||
|
{
|
||||||
|
$auth = $this->authHeader('old@example.com');
|
||||||
|
$this->cooldownElapsed('old@example.com');
|
||||||
|
|
||||||
|
$response = $this->request('POST', '/api/email/change', [
|
||||||
|
'email' => 'new@example.com',
|
||||||
|
'password' => 'wrong-password',
|
||||||
|
], $auth);
|
||||||
|
|
||||||
|
self::assertSame(422, $response->getStatusCode());
|
||||||
|
self::assertArrayHasKey('password', $this->decode($response)['error']['details']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_email_change_rejects_an_address_already_in_use(): void
|
||||||
|
{
|
||||||
|
$this->authHeader('taken@example.com');
|
||||||
|
$auth = $this->authHeader('mine@example.com');
|
||||||
|
$this->cooldownElapsed('mine@example.com');
|
||||||
|
|
||||||
|
$response = $this->request('POST', '/api/email/change', [
|
||||||
|
'email' => 'taken@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
], $auth);
|
||||||
|
|
||||||
|
self::assertSame(409, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_email_change_rejects_the_current_address(): void
|
||||||
|
{
|
||||||
|
$auth = $this->authHeader('same@example.com');
|
||||||
|
$this->cooldownElapsed('same@example.com');
|
||||||
|
|
||||||
|
$response = $this->request('POST', '/api/email/change', [
|
||||||
|
'email' => 'same@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
], $auth);
|
||||||
|
|
||||||
|
self::assertSame(422, $response->getStatusCode());
|
||||||
|
self::assertArrayHasKey('email', $this->decode($response)['error']['details']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push the user's last-sent timestamp far enough back to clear the throttle. */
|
||||||
|
private function cooldownElapsed(string $email): void
|
||||||
|
{
|
||||||
|
$this->db()
|
||||||
|
->prepare('UPDATE users SET verification_email_sent_at = :ts WHERE email = :email')
|
||||||
|
->execute(['ts' => gmdate('Y-m-d\TH:i:s\Z', time() - 120), 'email' => $email]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-3
@@ -42,7 +42,8 @@ src/stores/lists.ts Pinia store: the user's lists (fetch + create)
|
|||||||
src/stores/items.ts Pinia store: one list's items (CRUD + drag reorder)
|
src/stores/items.ts Pinia store: one list's items (CRUD + drag reorder)
|
||||||
src/lib/api.ts fetch wrapper, bearer token, typed ApiError
|
src/lib/api.ts fetch wrapper, bearer token, typed ApiError
|
||||||
src/components/TodoItemRow.vue checkbox + editable text + delete, one item
|
src/components/TodoItemRow.vue checkbox + editable text + delete, one item
|
||||||
src/views/ HomeView (lists), ListView (items), LoginView, RegisterView
|
src/views/ HomeView, ListView, LoginView, RegisterView,
|
||||||
|
ProfileView, VerifyEmailView
|
||||||
```
|
```
|
||||||
|
|
||||||
## List detail
|
## List detail
|
||||||
@@ -67,5 +68,19 @@ server response replaces local state.
|
|||||||
- Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended
|
- Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended
|
||||||
path) when there is no authenticated user.
|
path) when there is no authenticated user.
|
||||||
- Registration signs the user in immediately; the new account's email is
|
- Registration signs the user in immediately; the new account's email is
|
||||||
unverified (`user.email_verified === false`), surfaced in the header and on the
|
unverified (`user.email_verified === false`). The header shows a "verify
|
||||||
home page.
|
email" badge linking to `/profile`.
|
||||||
|
|
||||||
|
## Email verification & profile
|
||||||
|
|
||||||
|
- The registration email links to `/verify-email?token=…`. `VerifyEmailView`
|
||||||
|
POSTs the token to the API, which returns a session — so opening the link both
|
||||||
|
verifies the address and signs the user in — then redirects to the lists.
|
||||||
|
- `/profile` (`ProfileView`) shows the address and verification status. When
|
||||||
|
unverified it offers a **Resend** button; the API throttles to once a minute,
|
||||||
|
and the button shows a live countdown (driven by `retry_after`, and by `429`
|
||||||
|
responses).
|
||||||
|
- The **Change email** form takes the new address and the current password.
|
||||||
|
On success the API has emailed a confirmation link to the *new* address and
|
||||||
|
set `user.pending_email`; the change only lands when that link is opened. The
|
||||||
|
resend and change actions share the one-minute cooldown.
|
||||||
|
|||||||
+5
-3
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView, useRouter } from 'vue-router'
|
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useItemsStore } from './stores/items'
|
import { useItemsStore } from './stores/items'
|
||||||
import { useListsStore } from './stores/lists'
|
import { useListsStore } from './stores/lists'
|
||||||
@@ -23,8 +23,10 @@ async function onLogout() {
|
|||||||
<span class="app__brand">Todo List</span>
|
<span class="app__brand">Todo List</span>
|
||||||
|
|
||||||
<div v-if="auth.isAuthenticated" class="app__account">
|
<div v-if="auth.isAuthenticated" class="app__account">
|
||||||
<span class="app__email">{{ auth.user?.email }}</span>
|
<RouterLink v-if="!auth.emailVerified" to="/profile" class="badge badge--warn">
|
||||||
<span v-if="!auth.emailVerified" class="badge badge--warn">email unverified</span>
|
verify email
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/profile" class="app__email">{{ auth.user?.email }}</RouterLink>
|
||||||
<button type="button" class="link" @click="onLogout">Log out</button>
|
<button type="button" class="link" @click="onLogout">Log out</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export class ApiError extends Error {
|
|||||||
fieldError(field: string): string | undefined {
|
fieldError(field: string): string | undefined {
|
||||||
return this.details[field]?.[0]
|
return this.details[field]?.[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seconds to wait before retrying, when the server sends one (429 responses). */
|
||||||
|
get retryAfter(): number | undefined {
|
||||||
|
const value = (this.details as Record<string, unknown>).retry_after
|
||||||
|
return typeof value === 'number' ? value : undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ const router = createRouter({
|
|||||||
component: () => import('../views/ListView.vue'),
|
component: () => import('../views/ListView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
name: 'profile',
|
||||||
|
component: () => import('../views/ProfileView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Magic-link target. Works signed in or out — verifying returns a session.
|
||||||
|
path: '/verify-email',
|
||||||
|
name: 'verify-email',
|
||||||
|
component: () => import('../views/VerifyEmailView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
|
|||||||
@@ -63,6 +63,39 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
user.value = null
|
user.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verify an email address from a magic-link token; the response logs the user in. */
|
||||||
|
async function verifyEmail(magicToken: string): Promise<void> {
|
||||||
|
adopt(
|
||||||
|
await apiRequest<AuthResponse>('/auth/verify-email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { token: magicToken },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resend the verification email. Returns the seconds to wait before the next request. */
|
||||||
|
async function resendVerification(): Promise<number> {
|
||||||
|
const { retry_after } = await apiRequest<{ retry_after: number }>('/email/verification', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
})
|
||||||
|
return retry_after
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request a deferred email change. Returns the pending address and cooldown. */
|
||||||
|
async function requestEmailChange(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ pending_email: string; retry_after: number }> {
|
||||||
|
const result = await apiRequest<{ pending_email: string; retry_after: number }>('/email/change', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
body: { email, password },
|
||||||
|
})
|
||||||
|
await fetchMe() // pick up user.pending_email
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve the current user from a stored token; clears it if invalid. */
|
/** Resolve the current user from a stored token; clears it if invalid. */
|
||||||
async function fetchMe(): Promise<void> {
|
async function fetchMe(): Promise<void> {
|
||||||
if (!token.value) return
|
if (!token.value) return
|
||||||
@@ -88,5 +121,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
|
verifyEmail,
|
||||||
|
resendVerification,
|
||||||
|
requestEmailChange,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ body {
|
|||||||
|
|
||||||
.app__email {
|
.app__email {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.badge {
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app__main {
|
.app__main {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface User {
|
|||||||
email: string
|
email: string
|
||||||
email_verified: boolean
|
email_verified: boolean
|
||||||
email_verified_at: string | null
|
email_verified_at: string | null
|
||||||
|
/** A confirmed-but-not-yet-applied email change is waiting on this address. */
|
||||||
|
pending_email: string | null
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const cooldown = ref(0)
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
function startCooldown(seconds: number) {
|
||||||
|
cooldown.value = Math.max(0, Math.ceil(seconds))
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = setInterval(() => {
|
||||||
|
cooldown.value -= 1
|
||||||
|
if (cooldown.value <= 0) clearInterval(timer)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => clearInterval(timer))
|
||||||
|
|
||||||
|
// --- resend verification -------------------------------------------------
|
||||||
|
const resending = ref(false)
|
||||||
|
const resendMessage = ref('')
|
||||||
|
const resendError = ref('')
|
||||||
|
|
||||||
|
async function onResend() {
|
||||||
|
resending.value = true
|
||||||
|
resendMessage.value = ''
|
||||||
|
resendError.value = ''
|
||||||
|
try {
|
||||||
|
const retryAfter = await auth.resendVerification()
|
||||||
|
resendMessage.value = `Sent. Check ${auth.user?.email}.`
|
||||||
|
startCooldown(retryAfter)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
resendError.value = e.message
|
||||||
|
if (e.status === 429) startCooldown(e.retryAfter ?? 60)
|
||||||
|
} else {
|
||||||
|
resendError.value = 'Could not send the email.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- change email ------------------------------------------------------
|
||||||
|
const newEmail = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const changing = ref(false)
|
||||||
|
const changeMessage = ref('')
|
||||||
|
const changeError = ref<ApiError | null>(null)
|
||||||
|
|
||||||
|
async function onChangeEmail() {
|
||||||
|
changing.value = true
|
||||||
|
changeMessage.value = ''
|
||||||
|
changeError.value = null
|
||||||
|
try {
|
||||||
|
const { pending_email, retry_after } = await auth.requestEmailChange(newEmail.value, password.value)
|
||||||
|
changeMessage.value = `Confirmation link sent to ${pending_email}. Your address changes once you open it.`
|
||||||
|
newEmail.value = ''
|
||||||
|
password.value = ''
|
||||||
|
startCooldown(retry_after)
|
||||||
|
} catch (e) {
|
||||||
|
changeError.value = e instanceof ApiError ? e : new ApiError('Could not request the change.', 0)
|
||||||
|
if (e instanceof ApiError && e.status === 429) startCooldown(e.retryAfter ?? 60)
|
||||||
|
} finally {
|
||||||
|
changing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resendLabel = computed(() => {
|
||||||
|
if (resending.value) return 'Sending…'
|
||||||
|
if (cooldown.value > 0) return `Resend in ${cooldown.value}s`
|
||||||
|
return 'Resend verification email'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="card">
|
||||||
|
<p><RouterLink to="/">← Back to lists</RouterLink></p>
|
||||||
|
<h1>Your profile</h1>
|
||||||
|
|
||||||
|
<p><strong>Email:</strong> {{ auth.user?.email }}</p>
|
||||||
|
<p>
|
||||||
|
<strong>Status:</strong>
|
||||||
|
<span v-if="auth.emailVerified">verified</span>
|
||||||
|
<span v-else class="badge badge--warn">not verified</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="auth.user?.pending_email" class="notice">
|
||||||
|
A change to <strong>{{ auth.user.pending_email }}</strong> is pending. Open the
|
||||||
|
link we sent to that address to complete it. The link expires 15 minutes
|
||||||
|
after it was sent.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="!auth.emailVerified">
|
||||||
|
<h2>Verify your email</h2>
|
||||||
|
<p class="muted">
|
||||||
|
We sent a link to {{ auth.user?.email }}. It expires 15 minutes after
|
||||||
|
it's sent. You can resend it once a minute.
|
||||||
|
</p>
|
||||||
|
<button type="button" :disabled="resending || cooldown > 0" @click="onResend">
|
||||||
|
{{ resendLabel }}
|
||||||
|
</button>
|
||||||
|
<p v-if="resendMessage" class="muted">{{ resendMessage }}</p>
|
||||||
|
<p v-if="resendError" class="form-error">{{ resendError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Change email address</h2>
|
||||||
|
<form class="form" @submit.prevent="onChangeEmail">
|
||||||
|
<label>
|
||||||
|
<span>New email</span>
|
||||||
|
<input v-model="newEmail" type="email" maxlength="255" required />
|
||||||
|
<small v-if="changeError?.fieldError('email')" class="field-error">
|
||||||
|
{{ changeError.fieldError('email') }}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Current password</span>
|
||||||
|
<input v-model="password" type="password" autocomplete="current-password" required />
|
||||||
|
<small v-if="changeError?.fieldError('password')" class="field-error">
|
||||||
|
{{ changeError.fieldError('password') }}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="changeError && Object.keys(changeError.details).length === 0" class="form-error">
|
||||||
|
{{ changeError.message }}
|
||||||
|
</p>
|
||||||
|
<p v-if="changeMessage" class="muted">{{ changeMessage }}</p>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="changing || cooldown > 0">
|
||||||
|
{{ changing ? 'Sending…' : cooldown > 0 ? `Wait ${cooldown}s` : 'Send confirmation link' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const state = ref<'working' | 'done' | 'error'>('working')
|
||||||
|
const message = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||||
|
if (!token) {
|
||||||
|
state.value = 'error'
|
||||||
|
message.value = 'This link is missing its token.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.verifyEmail(token)
|
||||||
|
state.value = 'done'
|
||||||
|
setTimeout(() => router.push('/'), 1500)
|
||||||
|
} catch (e) {
|
||||||
|
state.value = 'error'
|
||||||
|
message.value = e instanceof ApiError ? e.message : 'Could not verify this link.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Email verification</h1>
|
||||||
|
|
||||||
|
<p v-if="state === 'working'" class="muted">Verifying…</p>
|
||||||
|
|
||||||
|
<template v-else-if="state === 'done'">
|
||||||
|
<p>Your email address is verified and you're signed in.</p>
|
||||||
|
<p class="muted">Taking you to your lists…</p>
|
||||||
|
<RouterLink to="/">Go now</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="form-error">{{ message }}</p>
|
||||||
|
<p class="muted">
|
||||||
|
Request a fresh link from your
|
||||||
|
<RouterLink to="/profile">profile</RouterLink>, or
|
||||||
|
<RouterLink to="/login">log in</RouterLink>.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user