Reorganize docs: simple README + organized docs/
Removed docs/stage-1-auth-api.md -- an early planning doc, badly out of date (predates passwordless auth, statuses, the inbox, and everything after). README.md is now just: what this is, a pointer to docs/, an end-user getting-started guide (Docker up, first-time login via the bundled Mailpit catcher, adding a passkey), and provenance -- everything else it used to carry moved out: - docs/api.md -- the full REST API reference (auth, passkeys, projects, cards, statuses, error shape) + the curl walkthrough. - docs/setup.md -- running without Docker, every environment variable, the test suite. - docs/architecture.md -- backend file layout; points to web/README.md for the frontend, which already documented itself in enough depth to stand alone. - docs/history.md -- the stage-by-stage feature log, with a new row for this session's refactoring work (which hadn't been logged yet). - docs/README.md -- an index tying the above together. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,37 +1,19 @@
|
|||||||
# PHP Project Manager
|
# PHP Project Manager
|
||||||
|
|
||||||
A small project-management application: a REST API written in PHP (Slim 4)
|
A small project-management app: each user owns **projects**, and each
|
||||||
backed by an SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
|
project holds **cards** you can organise as a flat list or a drag-and-drop
|
||||||
Each user owns **projects**, and each project holds ordered **cards**.
|
kanban board. It's a REST API in PHP (Slim 4) backed by SQLite, with a Vue 3
|
||||||
|
+ TypeScript PWA frontend. There's no password — signing in is a link
|
||||||
|
emailed to you, which creates your account the first time, and you can add
|
||||||
|
a passkey afterwards for a quicker sign-in next time.
|
||||||
|
|
||||||
## Status
|
## Documentation
|
||||||
|
|
||||||
| Stage | Scope | State |
|
This page covers running the app. For anything more technical — the REST
|
||||||
|-------|-------|-------|
|
API, configuration, running without Docker, project layout, and the
|
||||||
| 1 | Auth API — register, login, `GET /me` | ✅ done |
|
development history — see [docs/](docs/).
|
||||||
| 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 |
|
|
||||||
| 17 | Card detail view (`/cards/:id`) + its own configuration view — a card's text is no longer inline-editable; every list links to its own page instead | ✅ done |
|
|
||||||
| 18 | Project view split into real routes — Explore (`/projects/:id`) and Kanban (`/projects/:id/kanban`) are separate pages under a shared layout, not client-side tab state | ✅ done |
|
|
||||||
|
|
||||||
There is no password. Signing in is entering an email address and opening the
|
## Getting started
|
||||||
magic link sent to it — the same step creates the account the first time. See
|
|
||||||
[Auth](#auth).
|
|
||||||
|
|
||||||
## Run with Docker
|
|
||||||
|
|
||||||
The only requirement is Docker with the Compose plugin.
|
The only requirement is Docker with the Compose plugin.
|
||||||
|
|
||||||
@@ -39,457 +21,29 @@ The only requirement is Docker with the Compose plugin.
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs a multi-stage build — a Node stage compiles the Vue frontend, then a
|
Then open <http://localhost:8080>. A **[Mailpit](https://mailpit.axllent.org/)**
|
||||||
PHP 8.3 + Apache stage bakes in the PHP source and the built SPA — applies
|
mail-catcher also starts alongside the app, at <http://localhost:8025> —
|
||||||
migrations, and serves the whole app at <http://localhost:8080>: the SPA at `/`
|
since a default local setup has nowhere else to send the sign-in emails.
|
||||||
(assets and all) and the REST API under `/api` (e.g.
|
|
||||||
`curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA
|
### First-time login
|
||||||
shell for client-side routing.
|
|
||||||
|
1. Enter any email address and submit. There's no separate sign-up step and
|
||||||
- A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog
|
no password to choose — this both creates your account and sends it a
|
||||||
successor — one ~15 MB Go binary, messages kept in memory) also starts. The API
|
sign-in link.
|
||||||
sends all email to it; read it at <http://localhost:8025>. Set
|
2. Open <http://localhost:8025> (Mailpit) instead of a real inbox, and open
|
||||||
`MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real.
|
the message that just arrived there.
|
||||||
- The image is the artifact: PHP source and the compiled frontend are copied in
|
3. Click the link inside it. You're now signed in, on a new,
|
||||||
at build time, not bind-mounted. Rebuild to pick up any code change:
|
already-verified account.
|
||||||
`docker compose up -d --build`. For iterating on the frontend, run the Vite
|
|
||||||
dev server on the host instead (see [Frontend](#frontend)).
|
From then on, your profile page (top right, your email address) lets you add
|
||||||
- The SQLite database and the generated JWT signing key live in the `storage`
|
a **passkey** — your device's fingerprint, face, or PIN — so you don't need
|
||||||
named volume, mounted at `/var/www/storage`, so they survive
|
to wait on an email to sign in next time.
|
||||||
`docker compose restart` / `down` + `up`.
|
|
||||||
- `docker compose down -v` removes the volume and gives you a clean database.
|
Everything the app stores (your account, projects, cards) lives in a Docker
|
||||||
- Override settings via the environment or a `.env` file in this directory
|
volume, so it survives `docker compose restart` / `down` + `up`;
|
||||||
(Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see
|
`docker compose down -v` wipes it for a clean slate. See
|
||||||
[docker-compose.yml](docker-compose.yml)).
|
[docs/setup.md](docs/setup.md) for configuration, running without Docker,
|
||||||
|
and running the test suite.
|
||||||
## Run without Docker
|
|
||||||
|
|
||||||
Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus
|
|
||||||
[Composer](https://getcomposer.org/). On Fedora:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install php-cli php-pdo php-mbstring composer
|
|
||||||
```
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
composer install
|
|
||||||
cp .env.example .env # optional; sane defaults are used without it
|
|
||||||
composer migrate # creates storage/database.sqlite and its tables
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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/](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`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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](web/README.md).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All settings are optional environment variables (read from `.env` or the real
|
|
||||||
environment). See [.env.example](.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`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "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](#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](#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.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "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:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"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`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"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](https://github.com/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()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create)
|
|
||||||
/ [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get)
|
|
||||||
(binary fields travel as base64url strings; the frontend converts them —
|
|
||||||
see [web/README.md](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}` | rename the project (`title`) |
|
|
||||||
| `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, 1–255 chars).
|
|
||||||
|
|
||||||
Project representation:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"project": {
|
|
||||||
"id": 1,
|
|
||||||
"title": "Website relaunch",
|
|
||||||
"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](#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 |
|
|
||||||
| `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, 1–1000 chars),
|
|
||||||
`complete` (optional bool, default `false`). The project route also takes an
|
|
||||||
optional `status_id`, appending the card to the end of that status (must
|
|
||||||
belong to the project, else `422`) — omitted, it goes in the project's first
|
|
||||||
status instead. `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:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "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:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"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) |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"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:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "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):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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"}' \
|
|
||||||
| 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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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
|
## Provenance
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Documentation
|
||||||
|
|
||||||
|
Technical documentation for the PHP Project Manager. Start with the root
|
||||||
|
[README](../README.md) if you just want to run the app.
|
||||||
|
|
||||||
|
- **[API reference](api.md)** — every REST endpoint: auth (magic links,
|
||||||
|
passkeys), projects, cards, statuses, error shapes, and a curl walkthrough.
|
||||||
|
- **[Architecture](architecture.md)** — backend layout and how the pieces fit
|
||||||
|
together. Frontend architecture lives in
|
||||||
|
[web/README.md](../web/README.md) instead, since it's a large enough topic
|
||||||
|
on its own.
|
||||||
|
- **[Setup & configuration](setup.md)** — running without Docker, every
|
||||||
|
environment variable, and the test suite.
|
||||||
|
- **[Development history](history.md)** — a stage-by-stage log of how the app
|
||||||
|
got here.
|
||||||
+328
@@ -0,0 +1,328 @@
|
|||||||
|
# API reference
|
||||||
|
|
||||||
|
Base path: `/api`. All request and response bodies are JSON; send
|
||||||
|
`Content-Type: application/json`.
|
||||||
|
|
||||||
|
### `GET /api/health`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "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](#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](#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.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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](https://github.com/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()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create)
|
||||||
|
/ [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get)
|
||||||
|
(binary fields travel as base64url strings; the frontend converts them —
|
||||||
|
see [web/README.md](../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}` | rename the project (`title`) |
|
||||||
|
| `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, 1–255 chars).
|
||||||
|
|
||||||
|
Project representation:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project": {
|
||||||
|
"id": 1,
|
||||||
|
"title": "Website relaunch",
|
||||||
|
"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](#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 |
|
||||||
|
| `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, 1–1000 chars),
|
||||||
|
`complete` (optional bool, default `false`). The project route also takes an
|
||||||
|
optional `status_id`, appending the card to the end of that status (must
|
||||||
|
belong to the project, else `422`) — omitted, it goes in the project's first
|
||||||
|
status instead. `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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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) |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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"}' \
|
||||||
|
| 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"
|
||||||
|
```
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
A REST API in PHP 8 (Slim 4) over a single SQLite file, plus a Vue 3 +
|
||||||
|
TypeScript PWA frontend. Auth is a bearer JWT, obtained via a magic link or a
|
||||||
|
passkey (see [docs/api.md](api.md)) — there's no session store or cookie.
|
||||||
|
|
||||||
|
## Backend 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
A `ProjectScopedController` base class centralizes "look up a project owned
|
||||||
|
by the caller, or 404" for the controllers that need it (`Project`, `Card`,
|
||||||
|
`CardStatus`). Every table a request can reach is scoped to the
|
||||||
|
authenticated user one way or another — directly (`owner_id`/`user_id`) or
|
||||||
|
via a project that is.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
The Vue/TypeScript PWA lives in [web/](../web/) and is a separate concern
|
||||||
|
with its own conventions (routing, state, styling, drag-and-drop). See
|
||||||
|
[web/README.md](../web/README.md) for all of that — this document only
|
||||||
|
covers the backend.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
One SQLite file, migrated forward-only by `bin/migrate.php` from
|
||||||
|
`migrations/*.sql` (each applied file is recorded in a `schema_migrations`
|
||||||
|
table, so re-running is safe). See [docs/setup.md](setup.md) for how to run
|
||||||
|
migrations, and [docs/history.md](history.md) for how the schema and the
|
||||||
|
rest of the app got here.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Development history
|
||||||
|
|
||||||
|
A stage-by-stage log of major features, in the order they landed. Each row
|
||||||
|
describes the app as it was *at that point* — later stages sometimes
|
||||||
|
superseded earlier ones (e.g. password login, added in stage 1, was removed
|
||||||
|
again in stage 12); see [docs/api.md](api.md) and
|
||||||
|
[web/README.md](../web/README.md) for how things work now.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| 17 | Card detail view (`/cards/:id`) + its own configuration view — a card's text is no longer inline-editable; every list links to its own page instead | ✅ done |
|
||||||
|
| 18 | Project view split into real routes — Explore (`/projects/:id`) and Kanban (`/projects/:id/kanban`) are separate pages under a shared layout, not client-side tab state | ✅ done |
|
||||||
|
| 19 | Backend/frontend refactoring pass — deduplicated controller/repository boilerplate and CSS, dropped the never-surfaced project `description` field, squashed the SQL migration history into one clean initial schema | ✅ done |
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
# Setup & configuration
|
||||||
|
|
||||||
|
For the quick version — just running the app — see the root
|
||||||
|
[README](../README.md#getting-started). This covers the rest: running
|
||||||
|
without Docker, every configuration option, and the test suite.
|
||||||
|
|
||||||
|
## Run with Docker
|
||||||
|
|
||||||
|
The only requirement is Docker with the Compose plugin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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](https://mailpit.axllent.org/)** 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 [web/README.md](../web/README.md)).
|
||||||
|
- 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 the repo root
|
||||||
|
(Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see
|
||||||
|
[docker-compose.yml](../docker-compose.yml)).
|
||||||
|
|
||||||
|
## Run without Docker
|
||||||
|
|
||||||
|
Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus
|
||||||
|
[Composer](https://getcomposer.org/). On Fedora:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install php-cli php-pdo php-mbstring composer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
cp .env.example .env # optional; sane defaults are used without it
|
||||||
|
composer migrate # creates storage/database.sqlite and its tables
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
|
The frontend itself still needs its own toolchain — see
|
||||||
|
[web/README.md](../web/README.md).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings are optional environment variables (read from `.env` or the real
|
||||||
|
environment). See [.env.example](../.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".
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install # installs phpunit (require-dev)
|
||||||
|
vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
Each test run applies every file in `migrations/*.sql` to a fresh SQLite
|
||||||
|
database, so it always exercises the current schema from scratch.
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Stage 1 — Authentication API
|
|
||||||
|
|
||||||
Status: **done** and verified end-to-end (PHPUnit feature tests + a live `curl`
|
|
||||||
run against the built-in server).
|
|
||||||
|
|
||||||
## What's there
|
|
||||||
|
|
||||||
A Slim 4 REST API on SQLite with JWT bearer authentication.
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
|--------|------|---------|
|
|
||||||
| GET | `/api/health` | liveness check |
|
|
||||||
| POST | `/api/auth/register` | create account, returns user + token |
|
|
||||||
| POST | `/api/auth/login` | exchange email/password for a token |
|
|
||||||
| GET | `/api/me` | current user (requires `Authorization: Bearer <jwt>`) |
|
|
||||||
|
|
||||||
- **Passwords** are hashed with `password_hash()` (bcrypt). Validation is
|
|
||||||
email-format plus an 8–72 character password. Login returns a single generic
|
|
||||||
"Invalid email or password" message so it does not leak which emails exist.
|
|
||||||
- **Emails** are stored lower-cased in a `UNIQUE COLLATE NOCASE` column;
|
|
||||||
duplicate registration returns `409`.
|
|
||||||
- **Errors** always come back as
|
|
||||||
`{ "error": { "message": ..., "details"?: ... } }` via
|
|
||||||
[../src/Http/JsonErrorHandler.php](../src/Http/JsonErrorHandler.php).
|
|
||||||
- **Tokens** are stateless HS256 JWTs. The signing secret comes from
|
|
||||||
`JWT_SECRET`, or is auto-generated into `storage/secret.key` on first run.
|
|
||||||
- **Migrations** are plain SQL files in [../migrations/](../migrations/), applied
|
|
||||||
idempotently by [../bin/migrate.php](../bin/migrate.php) and tracked in a
|
|
||||||
`schema_migrations` table.
|
|
||||||
|
|
||||||
## Dependency note
|
|
||||||
|
|
||||||
`firebase/php-jwt` is pinned to `^7.0` — Composer blocks `6.10`–`6.11` for a
|
|
||||||
published security advisory (`PKSA-y2cr-5h3j-g3ys`).
|
|
||||||
|
|
||||||
## Key files
|
|
||||||
|
|
||||||
- [../src/bootstrap.php](../src/bootstrap.php) — app wiring + routes
|
|
||||||
- [../src/Http/Controllers/AuthController.php](../src/Http/Controllers/AuthController.php) — register / login / me
|
|
||||||
- [../src/Auth/JwtService.php](../src/Auth/JwtService.php), [../src/Auth/AuthMiddleware.php](../src/Auth/AuthMiddleware.php)
|
|
||||||
- [../src/Repository/UserRepository.php](../src/Repository/UserRepository.php)
|
|
||||||
- [../src/Support/Config.php](../src/Support/Config.php), [../src/Support/Database.php](../src/Support/Database.php)
|
|
||||||
- [../tests/AuthTest.php](../tests/AuthTest.php) — 6 passing feature tests
|
|
||||||
|
|
||||||
## Run it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
composer install && composer migrate && composer serve # http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Local toolchain
|
|
||||||
|
|
||||||
This machine has no `php`/`composer` binary. Tooling was run through the official
|
|
||||||
Composer container image, which bundles PHP + Composer + `pdo_sqlite` +
|
|
||||||
`mbstring`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
podman run --rm -v "$PWD":/app:Z -w /app docker.io/library/composer:2 install
|
|
||||||
podman run --rm -v "$PWD":/app:Z -w /app docker.io/library/composer:2 vendor/bin/phpunit
|
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user