aneurinandClaude Sonnet 5 f9b65cc4a7 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>
2026-09-03 20:04:49 +01:00
2026-09-03 17:35:10 +01:00
2026-09-03 17:35:10 +01:00

PHP Todo List

A small todo-list application: a REST API written in PHP (Slim 4) backed by an SQLite file, plus a Vue 3 + TypeScript PWA frontend in web/.

Status

Stage Scope State
1 Auth API — register, login, GET /me done
2 Frontend shell — Vite PWA, auth-gated routing, register/login pages done
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 and emails a magic link that verifies the address; user.email_verified stays false until the link is opened. See Email verification.

Run with Docker

The only requirement is Docker with the Compose plugin.

docker compose up -d

This builds a PHP 8.3 + Apache image, applies migrations, and serves the API at http://localhost:8080 (e.g. curl http://localhost:8080/api/health).

  • The project directory is bind-mounted into the container, so editing PHP source takes effect without a rebuild (within ~2s, due to the opcache revalidation interval). vendor/ is used from the host — run composer once first if it is missing (see "Run without Docker" below, or docker compose run --rm --entrypoint composer app install).
  • The SQLite database and the generated JWT signing key live in the storage named volume, mounted at /var/www/storage (outside the bind-mounted source), so they survive docker compose restart / down + up.
  • Rebuild only after changing the Dockerfile: docker compose up -d --build.
  • docker compose down -v removes the volume and gives you a clean database.
  • Override settings via the environment or a .env file in this directory (Compose substitutes APP_DEBUG, JWT_SECRET, JWT_TTL — see docker-compose.yml).

Run without Docker

Requires PHP 8.1+ with the pdo_sqlite and mbstring extensions, plus Composer. On Fedora:

sudo dnf install php-cli php-pdo php-mbstring composer

Setup

composer install
cp .env.example .env      # optional; sane defaults are used without it
composer migrate          # creates storage/database.sqlite and its tables

Running

composer serve            # http://localhost:8080  (php -S localhost:8080 -t public)

Any web server can serve the app as long as the document root is public/ and unknown paths fall through to public/index.php.

Frontend

The Vue/TypeScript PWA lives in web/ and talks to this API. With the API running (docker compose up -d):

cd web
npm install
npm run dev        # http://localhost:5173, proxies /api to localhost:8080

Or run it inside Compose alongside the API:

docker compose --profile frontend up -d

Unauthenticated visitors are redirected to /login; /register creates an account and signs in immediately. See web/README.md.

Configuration

All settings are optional environment variables (read from .env or the real environment). See .env.example.

Variable Default Purpose
APP_DEBUG false Include exception details in error responses
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

Base path: /api. All request and response bodies are JSON; send Content-Type: application/json.

GET /api/health

{ "status": "ok" }

POST /api/auth/register

Request:

{ "email": "ada@example.com", "password": "correct horse battery staple" }

201 Created:

{
  "user": {
    "id": 1,
    "email": "ada@example.com",
    "email_verified": false,
    "email_verified_at": null,
    "pending_email": null,
    "created_at": "2026-09-03T12:00:00Z"
  },
  "token": "<jwt>",
  "expires_at": "2026-09-04T12:00:00+00:00"
}

New accounts are created with an unverified email (email_verified: false).

Errors: 422 invalid input, 409 email already registered.

Validation: email must be a valid address (≤ 255 chars); password must be 872 characters.

POST /api/auth/login

Request:

{ "email": "ada@example.com", "password": "correct horse battery staple" }

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).

GET /api/me

Requires Authorization: Bearer <jwt>.

200 OK:

{
  "user": {
    "id": 1,
    "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 owner (the creator); another user's list — or a missing one — always responds 404.

Method Path Purpose
GET /api/lists the caller's lists, sorted A→Z by title
POST /api/lists create a list
GET /api/lists/{id} one list
PATCH /api/lists/{id} update title and/or description
DELETE /api/lists/{id} delete the list and its items (204)

GET /api/lists is always ordered alphabetically (case-insensitive) by title; there is no other sort option. A user may own at most 100 lists — creating one beyond that responds 409.

Create/update body: title (required on create, 1255 chars), description (optional, ≤ 2000 chars, defaults to ""). PATCH needs at least one field.

List representation:

{
  "list": {
    "id": 1,
    "title": "Shopping",
    "description": "For the week",
    "owner_id": 1,
    "item_count": 3,
    "completed_count": 1,
    "created_at": "2026-09-03T12:00:00Z",
    "updated_at": "2026-09-03T12:00:00Z"
  }
}

GET /api/lists returns { "lists": [ … ] }.

Todo items

Scoped to a list; the parent list's ownership is checked first (404 otherwise).

Method Path Purpose
GET /api/lists/{id}/items items, ordered by position then id
POST /api/lists/{id}/items add an item
PUT /api/lists/{id}/items/order reorder all items in one shot
GET /api/lists/{id}/items/{itemId} one item
PATCH /api/lists/{id}/items/{itemId} update text, complete, and/or position
DELETE /api/lists/{id}/items/{itemId} delete the item (204)

Create body: text (required, 11000 chars), complete (optional bool, default false), position (optional integer ≥ 0; when omitted the item is appended after the current highest position). PATCH needs at least one field. position is a plain sort key the client manages — updating one item never renumbers its siblings.

PUT …/items/order takes { "item_ids": [3, 1, 2] } — every item in the list, each exactly once (422 otherwise). It rewrites positions to 0..n-1 in one transaction and returns { "items": [ … ] } in the new order. This is what the drag-and-drop reorder in the UI calls.

Item representation:

{
  "item": {
    "id": 10,
    "list_id": 1,
    "text": "Milk",
    "complete": false,
    "position": 0,
    "created_at": "2026-09-03T12:00:00Z",
    "updated_at": "2026-09-03T12:00:00Z"
  }
}

GET …/items returns { "items": [ … ] }.

Error shape

Every error response looks like:

{ "error": { "message": "The submitted data was invalid.", "details": { "email": ["Email must be a valid address."] } } }

details is present only when relevant (e.g. validation).

Try it

BASE=http://localhost:8080

curl -s -X POST $BASE/api/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"password123"}'

TOKEN=$(curl -s -X POST $BASE/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"password123"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)

curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN"

LIST=$(curl -s -X POST $BASE/api/lists \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"Shopping","description":"For the week"}' \
  | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)

curl -s -X POST $BASE/api/lists/$LIST/items \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"text":"Milk"}'

curl -s $BASE/api/lists/$LIST/items -H "Authorization: Bearer $TOKEN"

Tests

composer install         # installs phpunit (require-dev)
vendor/bin/phpunit

Layout

public/index.php              Front controller
src/bootstrap.php             App wiring and route definitions
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, 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
docker-compose.yml           One-command local stack (API; web via --profile frontend)
docker/                      Apache vhost + container entrypoint
web/                         Vue 3 + TypeScript + Vite PWA frontend

Provenance

This project was generated with Claude Code.

S
Description
No description provided
Readme
588 KiB
Languages
PHP 61%
Vue 21.4%
TypeScript 8.1%
CSS 7.8%
Dockerfile 1.3%
Other 0.4%