Files
project-manager/README.md
T
aneurinandClaude Sonnet 5 91c0e8d6af Make the magic-link resend cooldown configurable
EmailVerifier::RESEND_INTERVAL_SECONDS was a hardcoded class constant
shared (via a copy-of-a-constant) by AuthController and
EmailVerificationController. It's now a constructor param
(resendIntervalSeconds, default 60, same as before) sourced from
Config -- new MAGIC_LINK_RESEND_SECONDS env var, default unchanged.

Docker Compose sets it to 0, so magic links resend immediately during
local development instead of waiting out the throttle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:30:41 +01:00

22 KiB
Raw Blame History

PHP Project Manager

A small project-management application: a REST API written in PHP (Slim 4) backed by an SQLite file, plus a Vue 3 + TypeScript PWA frontend in web/. Each user owns projects, and each project holds ordered cards.

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 Project + card CRUD API done
4 Frontend projects view — project index + create form done
5 Frontend project detail — cards UI with drag-and-drop reorder done
6 Project view — inline title/description editing, delete via a Manage menu done
7 Email verification (magic links) + profile page (resend, change email) done
8 Passwordless login — magic-link by default, password login behind a toggle done
9 Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none done
10 Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering done
11 Persistent left sidebar (Dashboard link + project dropdown); dashboard = grid of project tiles + a "Create a project" tile done
12 Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed done
13 Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns done
14 New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab done
15 Passkeys (WebAuthn) — register from the profile page, sign in with one instead of a magic link; a dismissible notice nudges users with none done
16 Project configuration view — manage a project's statuses: add, drag to reorder, delete (reassigning any cards on it first) done

There is no password. Signing in is entering an email address and opening the magic link sent to it — the same step creates the account the first time. See Auth.

Run with Docker

The only requirement is Docker with the Compose plugin.

docker compose up -d

This runs a multi-stage build — a Node stage compiles the Vue frontend, then a PHP 8.3 + Apache stage bakes in the PHP source and the built SPA — applies migrations, and serves the whole app at http://localhost:8080: the SPA at / (assets and all) and the REST API under /api (e.g. curl http://localhost:8080/api/health). Unknown paths fall back to the SPA shell for client-side routing.

  • A Mailpit container (the maintained MailHog successor — one ~15 MB Go binary, messages kept in memory) also starts. The API sends all email to it; read it at http://localhost:8025. Set MAIL_TRANSPORT=mail or =smtp (with MAIL_SMTP_*) to send for real.
  • The image is the artifact: PHP source and the compiled frontend are copied in at build time, not bind-mounted. Rebuild to pick up any code change: docker compose up -d --build. For iterating on the frontend, run the Vite dev server on the host instead (see Frontend).
  • The SQLite database and the generated JWT signing key live in the storage named volume, mounted at /var/www/storage, so they survive docker compose restart / down + up.
  • 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 API as long as the document root is public/ and unknown paths fall through to public/index.php. public/.htaccess also serves a built frontend from public/ (copy web/dist/ there) and only falls back to index.php for /api and when no index.html is present.

Frontend

The Vue/TypeScript PWA lives in web/. The production build is compiled into the Docker image and served from the app container at /. For frontend development, run the Vite dev server on the host — with the API container running (docker compose up -d):

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

Unauthenticated visitors are redirected to /login — enter an email address and open the link that arrives; there is no separate sign-up. 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_ALLOW_REGISTRATION true When false, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created
MAGIC_LINK_RESEND_SECONDS 60 Minimum gap before a magic link can be resent to the same address (sign-in or email-change). Docker Compose overrides this to 0, so links resend immediately in development
APP_URL http://localhost:8080 Base URL used to build magic links (http://localhost:5173 for a host npm run dev)
WEBAUTHN_RP_ID APP_URL's host Passkey relying party ID (domain). Must be localhost or a real domain over HTTPS — a LAN IP won't work
WEBAUTHN_RP_NAME Projects Passkey relying party display name, shown in the browser/OS prompt
MAIL_TRANSPORT mail mail (PHP mail()), smtp, or log (append to a file)
MAIL_FROM / MAIL_FROM_NAME no-reply@todo.test / Projects 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

Standalone, SMTP is opt-in and the API otherwise falls back to PHP's mail(). Under Docker Compose the default is MAIL_TRANSPORT=smtp pointed at the bundled Mailpit container (mailpit:1025, no auth/TLS); open http://localhost:8025 to read what was "sent".

API

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

GET /api/health

{ "status": "ok" }

Auth

There is no password and no separate registration endpoint. Entering an email address and opening the link sent to it is the entire flow, for a brand-new address and a returning one alike. A user can also register one or more passkeys and use one instead, once signed in at least once.

Method Path Auth Purpose
POST /api/auth/magic-link email a one-time sign-in link, creating the account first if the address is new
POST /api/auth/verify-email consume the token: sign in, and (the first time) mark the address verified, or apply a pending email change
POST /api/auth/passkey/options a challenge for signing in with a passkey (see Passkeys)
POST /api/auth/passkey/verify verify a passkey response and sign in
GET /api/me the current user
POST /api/email/change request a deferred email change

POST /api/auth/magic-link

Request: { "email": "ada@example.com" }.

Emails a one-time sign-in link (<APP_URL>/verify-email?token=…, 15-minute expiry) and always returns 202 with the same message. If the address has no account yet, one is created (unverified) right here — that's the only "sign up" there is — unless APP_ALLOW_REGISTRATION=false, in which case an unknown address is silently ignored (still 202, nothing sent) and only an address that already has an account can sign in. A link is only actually (re-)sent if this address hasn't been emailed one in the last 60 seconds. 422 if the address is malformed.

{ "message": "Check your email for a link to sign in." }

POST /api/auth/verify-email

Body: { "token": "..." }. A missing/invalid, already-used, or expired token is 400 (distinct messages). Success signs the caller in:

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

Opening a link is the only way to obtain a session, so an authenticated request is always for a verified address — email_verified is true from the first token a user's browser ever holds.

GET /api/me

Requires Authorization: Bearer <jwt>. 200 OK: the same user object shown above. 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.

POST /api/email/change

Requires Authorization: Bearer <jwt>. Body: { "email": "new@example.com" }. The address must be free (409) and different from the current one (422). Throttled to once per 60 seconds (shared with /api/auth/magic-link's resend window, per user) — 429 with error.details.retry_after when too soon. On success, 202 with retry_after and pending_email:

{
  "message": "Confirmation email sent to the new address.",
  "pending_email": "new@example.com",
  "retry_after": 60
}

The change is not applied until the magic link sent to the new address is opened — until then GET /api/me still shows the old address, with pending_email set. Opening that link both changes the address and re-verifies it, via the same /api/auth/verify-email.

Passkeys

WebAuthn, via lbuchs/webauthn. A passkey is always registered as a discoverable, user-verified credential, which is what makes login usernameless: the browser prompts the signed-in device for whichever passkey it has for this site, with no email typed first. There's no attestation/provenance check ('none' format) — this only confirms "the same device that registered", the standard trust model for a public site's own users, not a fleet of company-issued security keys.

Method Path Auth Purpose
GET /api/passkeys list the caller's passkeys
POST /api/passkeys/options a registration challenge
POST /api/passkeys verify the browser's response and store the credential
DELETE /api/passkeys/{id} remove a passkey (204)
POST /api/auth/passkey/options a login challenge (no email — discoverable)
POST /api/auth/passkey/verify verify and sign in

Both .../options endpoints return { "challenge_id": 1, "options": { "publicKey": {…} } }options.publicKey is passed more or less directly to navigator.credentials.create() / .get() (binary fields travel as base64url strings; the frontend converts them — see web/README.md). challenge_id identifies a single-use challenge, good for 5 minutes, and must be sent back with the browser's response:

  • POST /api/passkeys body: { "challenge_id": 1, "credential": {…}, "label": "My laptop" }. credential is { id, response: { clientDataJSON, attestationObject } } (all base64url). 201 with the stored passkey ({ id, label, created_at, last_used_at } — never the credential id or public key) on success; 400 if the response doesn't check out, 409 if that credential is already registered.
  • POST /api/auth/passkey/verify body: { "challenge_id": 1, "credential": {…} }, where credential also carries authenticatorData, signature, and userHandle. Success returns the same { user, token, expires_at } envelope as /api/auth/verify-email. 401 if the credential isn't recognised or the signature doesn't check out.

user.has_passkey (on every user object) is true once at least one is registered — that's what the frontend's "add a passkey" notice keys off.

Projects

All routes below require Authorization: Bearer <jwt>. A project belongs to one owner (the creator); another user's project — or a missing one — always responds 404.

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

GET /api/projects is always ordered alphabetically (case-insensitive) by title; there is no other sort option. A user may own at most 100 projects — 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.

Project representation:

{
  "project": {
    "id": 1,
    "title": "Website relaunch",
    "description": "Q3",
    "owner_id": 1,
    "card_count": 3,
    "completed_count": 1,
    "created_at": "2026-09-03T12:00:00Z",
    "updated_at": "2026-09-03T12:00:00Z"
  }
}

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

Creating a project also seeds it with three statuses — "To do", "Doing", "Done" (see Statuses).

Cards

A card either sits in its owner's inbox (project_id and status_id both null) or belongs to exactly one of their projects with a status in it (both set) — enforced by a database CHECK constraint, never one without the other. The inbox is global to the user, not per-project. Because a card may have no project, single-card and ordering routes are addressed globally, by the card's own id, rather than nested under a project:

Method Path Purpose
GET /api/projects/{id}/cards a project's cards, grouped by status then position
POST /api/projects/{id}/cards add a card directly to the project (its first status)
GET /api/inbox/cards the caller's inbox
POST /api/inbox/cards add a card to the inbox
GET | PATCH | DELETE /api/cards/{cardId} one card, owner-scoped (404 otherwise)
PUT /api/cards/order set the order/contents of one column

Create body (either creation route): text (required, 11000 chars), complete (optional bool, default false). PATCH accepts text and/or complete only — moving a card is done via the order route below, not PATCH.

Ordering. position is a dense 0..n-1 rank within a column — the cards that share an (owner, project, status). The inbox is its own column, per owner. PUT /api/cards/order sets one column's contents and order:

{ "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] }

project_id/status_id are both null for the inbox, or both set to a project owned by the caller and one of its statuses (404/422 otherwise). card_ids must be distinct cards owned by the caller and must include every card already in the target column (422 otherwise); it rewrites positions to 0..n-1. Any card in the list that wasn't already in that column is re-parented into it — moving it from another project's status, or the inbox, or vice versa — and the column it left is re-packed, all in one transaction. Returns { "cards": [ … ] } for the new column. This is what dragging a card in the kanban board (or the sidebar's inbox) calls on every drop; moving a card from one project to another is just two calls, via the inbox in between.

Card representation:

{
  "card": {
    "id": 10,
    "project_id": 1,
    "text": "Design homepage",
    "complete": false,
    "position": 0,
    "status_id": 2,
    "status": { "id": 2, "name": "Doing" },
    "created_at": "2026-09-03T12:00:00Z",
    "updated_at": "2026-09-03T12:00:00Z"
  }
}

project_id and status_id are null together for an inbox card. status is the embedded { id, name } of the linked status, or null. GET …/cards returns { "cards": [ … ] }.

Statuses

Every project has an ordered set of card statuses, created with the project: "To do", "Doing", "Done". They are project-specific — each project owns its own rows, managed from the project's configuration view (create, reorder, delete). A project always keeps at least one status, since a project card must have one; deleting the last one is rejected (409).

Method Path Purpose
GET /api/projects/{id}/statuses the project's statuses, ordered by position
POST /api/projects/{id}/statuses add one at the end — { "name": "Blocked" }
PUT /api/projects/{id}/statuses/order reorder — { "status_ids": [3, 1, 2] }, every status once
DELETE /api/projects/{id}/statuses/{statusId} delete (see below)
{
  "statuses": [
    { "id": 1, "project_id": 1, "name": "To do", "position": 0 },
    { "id": 2, "project_id": 1, "name": "Doing", "position": 1 },
    { "id": 3, "project_id": 1, "name": "Done",  "position": 2 }
  ]
}

Requires Authorization: Bearer <jwt>; a project that is missing or not owned by the caller responds 404.

Deleting a status that still has cards fails with 409 and error.details.card_count set, rather than silently orphaning them (a referenced status can't be deleted at the database level either — the FK is ON DELETE RESTRICT). Retry with { "reassign_to": <another status id> } in the same project; those cards are moved there and the status deleted, in one transaction.

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

Signing in needs the link the API emails, so this pulls it back out of the bundled Mailpit catcher (adjust if you've pointed MAIL_TRANSPORT elsewhere). The API pretty-prints its JSON, so responses are piped through tr -d ' \n' before grep (Mailpit's own JSON doesn't need that):

BASE=http://localhost:8080

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

MSG_ID=$(curl -s "http://localhost:8025/api/v1/messages?limit=1" | grep -o '"ID":"[^"]*"' | head -1 | cut -d'"' -f4)
LINK_TOKEN=$(curl -s "http://localhost:8025/api/v1/message/$MSG_ID" | grep -o 'token=[a-f0-9]*' | head -1 | cut -d= -f2)

TOKEN=$(curl -s -X POST $BASE/api/auth/verify-email \
  -H 'Content-Type: application/json' \
  -d "{\"token\":\"$LINK_TOKEN\"}" | tr -d ' \n' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)

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

PROJECT=$(curl -s -X POST $BASE/api/projects \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"Website relaunch","description":"Q3"}' \
  | tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)

curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN"

curl -s -X POST $BASE/api/projects/$PROJECT/cards \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"text":"Design homepage"}'

curl -s $BASE/api/projects/$PROJECT/cards -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, Passkey, Project, Card, CardStatus)
src/Repository/              Database access (User, EmailVerification, Passkey, WebAuthnChallenge, Project, Card, CardStatus)
src/Support/Validator.php    Request-body validation helper
migrations/*.sql             Schema, applied by bin/migrate.php
Dockerfile                   Multi-stage: Node frontend build + PHP 8.3/Apache runtime
docker-compose.yml           Local stack: app (SPA + API) + Mailpit
docker/                      Apache vhost + container entrypoint
web/                         Vue 3 + TypeScript + Vite PWA frontend (dev on the host)

Provenance

This project was generated with Claude Code.