Un-hard-wrap all Markdown documentation

Every prose paragraph and list item was manually wrapped at ~80-100
columns; joined each back into a single line. Headings, table rows,
and fenced code blocks are untouched -- tables already had one row per
line, and wrapping inside a code fence is the code's own formatting,
not something this applies to.

Also fixed two pre-existing typos this surfaced (both from wrapping
without leaving the space that was actually intended): a missing space
in "{ challenge_id, options }" and a stray "+ TypeScript" that had
accidentally been written as if it were a new line, in web/README.md
and README.md respectively.

Code comments are explicitly out of scope for this -- left exactly as
they were.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 02:29:08 +01:00
co-authored by Claude Sonnet 5
parent f1309b4c10
commit 5c8f2dbf07
7 changed files with 90 additions and 445 deletions
+8 -27
View File
@@ -1,17 +1,10 @@
# PHP Project Manager
A small project-management app: each user owns **projects**, and each
project holds **cards** you can organise as a flat list or a drag-and-drop
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.
A small project-management app: each user owns **projects**, and each project holds **cards** you can organise as a flat list or a drag-and-drop 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.
## Documentation
This page covers running the app. For anything more technical — the REST
API, configuration, running without Docker, project layout, and the
development history — see [docs/](docs/).
This page covers running the app. For anything more technical — the REST API, configuration, running without Docker, project layout, and the development history — see [docs/](docs/).
## Getting started
@@ -21,29 +14,17 @@ The only requirement is Docker with the Compose plugin.
docker compose up -d
```
Then open <http://localhost:8080>. A **[Mailpit](https://mailpit.axllent.org/)**
mail-catcher also starts alongside the app, at <http://localhost:8025> —
since a default local setup has nowhere else to send the sign-in emails.
Then open <http://localhost:8080>. A **[Mailpit](https://mailpit.axllent.org/)** mail-catcher also starts alongside the app, at <http://localhost:8025> — since a default local setup has nowhere else to send the sign-in emails.
### First-time login
1. Enter any email address and submit. There's no separate sign-up step and
no password to choose — this both creates your account and sends it a
sign-in link.
2. Open <http://localhost:8025> (Mailpit) instead of a real inbox, and open
the message that just arrived there.
3. Click the link inside it. You're now signed in, on a new,
already-verified account.
1. Enter any email address and submit. There's no separate sign-up step and no password to choose — this both creates your account and sends it a sign-in link.
2. Open <http://localhost:8025> (Mailpit) instead of a real inbox, and open the message that just arrived there.
3. Click the link inside it. You're now signed in, on a new, already-verified account.
From then on, your profile page (top right, your email address) lets you add
a **passkey** — your device's fingerprint, face, or PIN — so you don't need
to wait on an email to sign in next time.
From then on, your profile page (top right, your email address) lets you add a **passkey** — your device's fingerprint, face, or PIN — so you don't need to wait on an email to sign in next time.
Everything the app stores (your account, projects, cards) lives in a Docker
volume, so it survives `docker compose restart` / `down` + `up`;
`docker compose down -v` wipes it for a clean slate. See
[docs/setup.md](docs/setup.md) for configuration, running without Docker,
and running the test suite.
Everything the app stores (your account, projects, cards) lives in a Docker volume, so it survives `docker compose restart` / `down` + `up`; `docker compose down -v` wipes it for a clean slate. See [docs/setup.md](docs/setup.md) for configuration, running without Docker, and running the test suite.
## Provenance
+5 -12
View File
@@ -1,15 +1,8 @@
# Documentation
Technical documentation for the PHP Project Manager. Start with the root
[README](../README.md) if you just want to run the app.
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.
- **[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.
+25 -113
View File
@@ -1,7 +1,6 @@
# API reference
Base path: `/api`. All request and response bodies are JSON; send
`Content-Type: application/json`.
Base path: `/api`. All request and response bodies are JSON; send `Content-Type: application/json`.
### `GET /api/health`
@@ -11,10 +10,7 @@ Base path: `/api`. All request and response bodies are JSON; send
## 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.
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 |
|--------|------|------|---------|
@@ -29,14 +25,7 @@ address and a returning one alike. A user can also register one or more
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.
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." }
@@ -44,8 +33,7 @@ address is malformed.
### `POST /api/auth/verify-email`
Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
`400` (distinct messages). Success signs the caller in:
Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is `400` (distinct messages). Success signs the caller in:
```json
{
@@ -63,24 +51,15 @@ Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
}
```
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.
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.
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`:
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
{
@@ -90,20 +69,11 @@ soon. On success, `202` with `retry_after` and `pending_email`:
}
```
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`.
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.
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 |
|--------|------|------|---------|
@@ -114,35 +84,16 @@ users, not a fleet of company-issued security keys.
| `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:
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.
- `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.
`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`.
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 |
|--------|------|---------|
@@ -152,9 +103,7 @@ owner (the creator); another user's project — or a missing one — always resp
| `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`.
`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, 1255 chars).
@@ -176,17 +125,11 @@ Project representation:
`GET /api/projects` returns `{ "projects": [ … ] }`.
Creating a project also seeds it with three **statuses** — "To do", "Doing",
"Done" (see [Statuses](#statuses)).
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:
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 |
|--------|------|---------|
@@ -197,31 +140,15 @@ own id, rather than nested under a project:
| `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`). 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.
Create body (either creation route): `text` (required, 11000 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:
**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.
`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:
@@ -241,17 +168,11 @@ Card representation:
}
```
`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": [ … ] }`.
`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`).
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 |
|--------|------|---------|
@@ -270,15 +191,9 @@ have one; deleting the last one is rejected (`409`).
}
```
Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by
the caller responds `404`.
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.
**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
@@ -292,10 +207,7 @@ Every error response looks like:
## 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):
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
+4 -17
View File
@@ -1,8 +1,6 @@
# 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.
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
@@ -29,23 +27,12 @@ docker/ Apache vhost (mod_rewrite, document root) + entrypo
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.
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.
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.
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.
+1 -5
View File
@@ -1,10 +1,6 @@
# 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.
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 |
|-------|-------|-------|
+12 -41
View File
@@ -1,8 +1,6 @@
# 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.
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
@@ -12,35 +10,17 @@ 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 (Alpine-based; apk's own prebuilt packages rather than
compiling PHP from source, which is most of why the image is ~90MB rather
than several times that) 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.
This runs a multi-stage build — a Node stage compiles the Vue frontend, then a PHP 8.3 + Apache stage (Alpine-based; apk's own prebuilt packages rather than compiling PHP from source, which is most of why the image is ~90MB rather than several times that) 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`.
- 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)).
- 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:
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
@@ -60,18 +40,13 @@ composer migrate # creates storage/database.sqlite and its tables
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.
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).
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).
All settings are optional environment variables (read from `.env` or the real environment). See [.env.example](../.env.example).
| Variable | Default | Purpose |
|----------|---------|---------|
@@ -89,10 +64,7 @@ environment). See [.env.example](../.env.example).
| `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".
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
@@ -101,5 +73,4 @@ 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.
Each test run applies every file in `migrations/*.sql` to a fresh SQLite database, so it always exercises the current schema from scratch.
+35 -230
View File
@@ -9,10 +9,7 @@ npm install
npm run dev # http://localhost:5173
```
The dev server proxies `/api` to `http://localhost:8080` (the Dockerised API —
run `docker compose up -d` in the parent directory first). Override the target
with `VITE_PROXY_TARGET`, or point the app at a different API entirely with
`VITE_API_BASE_URL` (see [.env.example](.env.example)).
The dev server proxies `/api` to `http://localhost:8080` (the Dockerised API — run `docker compose up -d` in the parent directory first). Override the target with `VITE_PROXY_TARGET`, or point the app at a different API entirely with `VITE_API_BASE_URL` (see [.env.example](.env.example)).
## Build
@@ -21,10 +18,7 @@ npm run build # type-checks, then emits dist/
npm run preview
```
The parent `Dockerfile` runs this build in a Node stage and copies `dist/` into
the PHP image's `public/`, so the `app` container serves the compiled SPA at `/`.
There is no separate frontend container — a production image is `docker compose
build app` from the parent directory.
The parent `Dockerfile` runs this build in a Node stage and copies `dist/` into the PHP image's `public/`, so the `app` container serves the compiled SPA at `/`. There is no separate frontend container — a production image is `docker composebuild app` from the parent directory.
## Layout
@@ -64,270 +58,81 @@ src/views/ DashboardView, LoginView, ProfileView, VerifyEmailView
ProjectConfigureView, CardView, CardConfigureView
```
Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell:
the top bar, then a left **sidebar** (`AppSidebar.vue`) beside the routed view.
The sidebar stays mounted across navigation — it holds a **Dashboard** link, a
divider, a project `<select>`, another divider, then the **Inbox** (see below).
The dropdown is a `v-model`-bound writable `computed` (`selectedProjectId`):
its getter reads the open project from `route.params.id`, so it tracks
whichever project is current; its setter `router.push`es to the chosen one, so
it also works as a project switcher from anywhere. `App.vue`'s top-level
`<RouterView>` is keyed so switching projects (or cards) always does a fresh
load -- see [Project detail](#project-detail) for why that key isn't simply
the path.
Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell: the top bar, then a left **sidebar** (`AppSidebar.vue`) beside the routed view. The sidebar stays mounted across navigation — it holds a **Dashboard** link, a divider, a project `<select>`, another divider, then the **Inbox** (see below). The dropdown is a `v-model`-bound writable `computed` (`selectedProjectId`): its getter reads the open project from `route.params.id`, so it tracks whichever project is current; its setter `router.push`es to the chosen one, so it also works as a project switcher from anywhere. `App.vue`'s top-level `<RouterView>` is keyed so switching projects (or cards) always does a fresh load -- see [Project detail](#project-detail) for why that key isn't simply the path.
Below `768px` (`style.css`'s one layout breakpoint) the sidebar becomes an
off-canvas drawer instead of sitting beside the page: `position: fixed`,
translated out of view by default, slid in via a `.sidebar--open` class.
`App.vue` owns the `drawerOpen` state -- a `☰` button in the header (hidden
above the breakpoint) opens it; a backdrop tap, the drawer's own `✕` (which
`AppSidebar` emits `close` for), or any navigation (a `route.fullPath`
watcher) closes it. Above the breakpoint `drawerOpen` just goes unused, since
nothing renders the button that would set it.
Below `768px` (`style.css`'s one layout breakpoint) the sidebar becomes an off-canvas drawer instead of sitting beside the page: `position: fixed`, translated out of view by default, slid in via a `.sidebar--open` class. `App.vue` owns the `drawerOpen` state -- a `☰` button in the header (hidden above the breakpoint) opens it; a backdrop tap, the drawer's own `✕` (which `AppSidebar` emits `close` for), or any navigation (a `route.fullPath` watcher) closes it. Above the breakpoint `drawerOpen` just goes unused, since nothing renders the button that would set it.
`/` redirects to `/dashboard` (`DashboardView.vue`): a full-width grid linking
to each project (title + card count), with a **"Create a project" tile**
styled to match sitting last in the same grid (creating one stays on the
dashboard; the grid and the sidebar dropdown both pick it up via the shared
`projects` store). Signed-out routes
(`/login`, `/verify-email`) render without the sidebar.
`/` redirects to `/dashboard` (`DashboardView.vue`): a full-width grid linking to each project (title + card count), with a **"Create a project" tile** styled to match sitting last in the same grid (creating one stays on the dashboard; the grid and the sidebar dropdown both pick it up via the shared `projects` store). Signed-out routes (`/login`, `/verify-email`) render without the sidebar.
## Styling
`style.css` is one global stylesheet (no scoped/component styles) with a
handful of conventions worth knowing before adding to it:
`style.css` is one global stylesheet (no scoped/component styles) with a handful of conventions worth knowing before adding to it:
- **Tokens** (`:root` custom properties): colours (`--bg`, `--surface`,
`--border`, `--text`, `--muted`, `--accent`(-text), `--error`, `--warn-bg`/
`-border`), a border-radius scale (`--radius-sm` 6px compact controls,
`--radius-md` 8px buttons/inputs, `--radius-lg` 10px tiles, `--radius-xl`
12px panels, `--radius-pill`), and two opacity values (`--opacity-disabled`
0.6, `--opacity-ghost` 0.5 for a dragged item's placeholder). Reach for one
of these before hand-writing a value that's really just "the same grey
border again" or "the same rounding as every other button."
- **`.field`** is the one themed `<input>`/`<textarea>`/`<select>` look, used
everywhere from the login form to the sidebar's project switcher.
`.field--compact` is the same thing smaller, for a control that's a flex
child beside a button (an inline "add" row) or squeezed into the sidebar.
`.field--autosize` adds the `resize:none; overflow:hidden` the dashboard's
JS-driven auto-growing textarea needs. Crucially, **`.field` is applied
directly to the control**, not to a wrapper (`.form input` no longer
exists) -- see the next point for why that's load-bearing, not just style.
- **Put component classes on the control itself, not a wrapping element**,
for anything that needs a `:focus` state. `input:focus`/`textarea:focus`/
`select:focus` (global, removes the default outline and colours the border
with `--accent` instead) has specificity `(0,0,1,1)` -- one pseudo-class,
one element. A rule shaped `.wrapper input { border-color: var(--border) }`
ties it exactly, and being unconditional, silently wins that tie by simply
appearing later in the file -- the input keeps `var(--border)` forever,
focused or not, no matter what `:focus` says. `.field`, applied straight to
the element, is `(0,0,1,0)` -- strictly lower, so it can never win that
tie regardless of source order. (This bit a real shipped version of the
app: half the inputs had a working focus ring and half silently didn't,
purely from which pattern each one happened to use.) A component that
needs its own more elaborate `:focus` state (`.card-row__text`, background
swap included) writes `.card-row__text:focus` explicitly -- specificity
`(0,0,2,0)`, genuinely higher, wins outright rather than by luck of
ordering.
- **Tokens** (`:root` custom properties): colours (`--bg`, `--surface`, `--border`, `--text`, `--muted`, `--accent`(-text), `--error`, `--warn-bg`/`-border`), a border-radius scale (`--radius-sm` 6px compact controls, `--radius-md` 8px buttons/inputs, `--radius-lg` 10px tiles, `--radius-xl` 12px panels, `--radius-pill`), and two opacity values (`--opacity-disabled` 0.6, `--opacity-ghost` 0.5 for a dragged item's placeholder). Reach for one of these before hand-writing a value that's really just "the same grey border again" or "the same rounding as every other button."
- **`.field`** is the one themed `<input>`/`<textarea>`/`<select>` look, used everywhere from the login form to the sidebar's project switcher. `.field--compact` is the same thing smaller, for a control that's a flex child beside a button (an inline "add" row) or squeezed into the sidebar. `.field--autosize` adds the `resize:none; overflow:hidden` the dashboard's JS-driven auto-growing textarea needs. Crucially, **`.field` is applied directly to the control**, not to a wrapper (`.form input` no longer exists) -- see the next point for why that's load-bearing, not just style.
- **Put component classes on the control itself, not a wrapping element**, for anything that needs a `:focus` state. `input:focus`/`textarea:focus`/`select:focus` (global, removes the default outline and colours the border with `--accent` instead) has specificity `(0,0,1,1)` -- one pseudo-class, one element. A rule shaped `.wrapper input { border-color: var(--border) }` ties it exactly, and being unconditional, silently wins that tie by simply appearing later in the file -- the input keeps `var(--border)` forever, focused or not, no matter what `:focus` says. `.field`, applied straight to the element, is `(0,0,1,0)` -- strictly lower, so it can never win that tie regardless of source order. (This bit a real shipped version of the app: half the inputs had a working focus ring and half silently didn't, purely from which pattern each one happened to use.) A component that needs its own more elaborate `:focus` state (`.card-row__text`, background swap included) writes `.card-row__text:focus` explicitly -- specificity `(0,0,2,0)`, genuinely higher, wins outright rather than by luck of ordering.
## Inbox
A card with no project lives in the caller's inbox (`useInboxStore`), rendered
in the sidebar under the project list -- not per-project, and not tied to
whatever page is open. It's a `vuedraggable` list in the same `"kanban"` drag
group as a project's Kanban columns and Explore list (below), so a card can
be dragged straight out of the sidebar into any status column of whichever
project is currently open (or Explore can send one the other way, though not
receive one -- see [Explore](#explore)). `AppSidebar`'s `onInboxChange`
persists a drop via `reorderColumn(null, null, ids)`, then reloads the inbox
and, if a project's Explore or Kanban route is currently open, that
project's cards too -- either side of a drag could have been the inbox. A
small form under the list adds a card straight to the inbox.
A card with no project lives in the caller's inbox (`useInboxStore`), rendered in the sidebar under the project list -- not per-project, and not tied to whatever page is open. It's a `vuedraggable` list in the same `"kanban"` drag group as a project's Kanban columns and Explore list (below), so a card can be dragged straight out of the sidebar into any status column of whichever project is currently open (or Explore can send one the other way, though not receive one -- see [Explore](#explore)). `AppSidebar`'s `onInboxChange` persists a drop via `reorderColumn(null, null, ids)`, then reloads the inbox and, if a project's Explore or Kanban route is currently open, that project's cards too -- either side of a drag could have been the inbox. A small form under the list adds a card straight to the inbox.
Empty (no cards) is shown as a subtle dashed drop-area rather than blank
space -- `.kanban__cards:empty` in `style.css`, so it's pure CSS keyed off the
real DOM child count. Since a card's kanban column/inbox `<draggable>` is
always rendered even with nothing in it (see above), that container is
genuinely childless when empty, so the rule applies with no extra markup or
JS state; it steps aside automatically once Sortable inserts its drag-over
ghost. The same rule covers every kanban status column too (below).
Empty (no cards) is shown as a subtle dashed drop-area rather than blank space -- `.kanban__cards:empty` in `style.css`, so it's pure CSS keyed off the real DOM child count. Since a card's kanban column/inbox `<draggable>` is always rendered even with nothing in it (see above), that container is genuinely childless when empty, so the rule applies with no extra markup or JS state; it steps aside automatically once Sortable inserts its drag-over ghost. The same rule covers every kanban status column too (below).
## Project detail
`/projects/:id` shows one project. `ProjectView.vue` is a **layout**, not a
page of its own: it renders on a **full-width** layout (the parent route sets
`meta.wide`, inherited by its children, which widens `.app__main` in
`App.vue`), loads the project and its cards, and renders the header + a small
sub-nav — its two children (below) render into its `<RouterView>`.
`/projects/:id` shows one project. `ProjectView.vue` is a **layout**, not a page of its own: it renders on a **full-width** layout (the parent route sets `meta.wide`, inherited by its children, which widens `.app__main` in `App.vue`), loads the project and its cards, and renders the header + a small sub-nav — its two children (below) render into its `<RouterView>`.
The header: a plain title heading, with an inline `.title-back` arrow to the
dashboard right before the text -- renaming lives on the configuration view
(below) -- and `ProjectManageMenu.vue` top right, with a **Configure** link
(to that view) and a **Delete project** action that opens a confirmation
modal; confirming calls `DELETE /api/projects/:id` and returns to the
dashboard.
The header: a plain title heading, with an inline `.title-back` arrow to the dashboard right before the text -- renaming lives on the configuration view (below) -- and `ProjectManageMenu.vue` top right, with a **Configure** link (to that view) and a **Delete project** action that opens a confirmation modal; confirming calls `DELETE /api/projects/:id` and returns to the dashboard.
The sub-nav (`RouterLink`s styled as tabs, active one matched on `route.name`)
is real navigation, not client-side tab state -- **Explore** is the project's
own route (`/projects/:id`, name `project`), **Kanban** a child beneath it
(`/projects/:id/kanban`, name `project-kanban`). Both read the `cards` store
the layout already loaded; App.vue's top-level `<RouterView>` key is derived
from the matched route's *top-level* path plus params rather than the full
path, so switching between them doesn't remount the layout (and re-fetch the
project) the way switching to a different project's id still does.
The sub-nav (`RouterLink`s styled as tabs, active one matched on `route.name`) is real navigation, not client-side tab state -- **Explore** is the project's own route (`/projects/:id`, name `project`), **Kanban** a child beneath it (`/projects/:id/kanban`, name `project-kanban`). Both read the `cards` store the layout already loaded; App.vue's top-level `<RouterView>` key is derived from the matched route's *top-level* path plus params rather than the full path, so switching between them doesn't remount the layout (and re-fetch the project) the way switching to a different project's id still does.
### Explore
The flat card list, **sorted by name (case-insensitive)** via a `sortedCards`
ref (rebuilt by a `watch` on the store's `cards.cards` -- a plain computed
can't be handed to `<draggable>`, which splices its bound list in place as
the user drags). Each row links to the card's own view (`/cards/:id` — see
[Card detail](#card-detail)); there is no manual order here, and no delete
button either -- deleting lives on that view now.
The flat card list, **sorted by name (case-insensitive)** via a `sortedCards` ref (rebuilt by a `watch` on the store's `cards.cards` -- a plain computed can't be handed to `<draggable>`, which splices its bound list in place as the user drags). Each row links to the card's own view (`/cards/:id` — see [Card detail](#card-detail)); there is no manual order here, and no delete button either -- deleting lives on that view now.
The list is a `<draggable>` too, but one-directional: `group: { name:
'kanban', put: false }` and `sort: false` mean a card can be dragged *out* --
to the sidebar's inbox, unfiling it from the project -- but Explore can't
receive a drop itself (there's no status to put an incoming card in), nor
reorder on its own drag (it's sorted by name regardless). No `@change`
handler is needed on this side: `<draggable>` already splices the card out of
`sortedCards` locally, and the inbox's own handler (see above) persists the
move and reloads this project's `cards`, which rebuilds the list from the
authoritative result regardless of which side reacted to the drop.
The list is a `<draggable>` too, but one-directional: `group: { name:'kanban', put: false }` and `sort: false` mean a card can be dragged *out* -- to the sidebar's inbox, unfiling it from the project -- but Explore can't receive a drop itself (there's no status to put an incoming card in), nor reorder on its own drag (it's sorted by name regardless). No `@change` handler is needed on this side: `<draggable>` already splices the card out of `sortedCards` locally, and the inbox's own handler (see above) persists the move and reloads this project's `cards`, which rebuilds the list from the authoritative result regardless of which side reacted to the drop.
### Kanban
One column per project status, in `position` order -- the inbox is *not* a
column here; it's in the sidebar (see above), though it's still a valid drag
target as well as a source (unlike Explore, which can only send a card *to*
the inbox, not receive one). Unlike the cards, statuses are this route's own
fetch (`GET /api/projects/:id/statuses`) -- Explore has no use for them.
`board` is derived from `cards.cards` + those statuses and rebuilt by a
`watch` whenever either changes.
One column per project status, in `position` order -- the inbox is *not* a column here; it's in the sidebar (see above), though it's still a valid drag target as well as a source (unlike Explore, which can only send a card *to* the inbox, not receive one). Unlike the cards, statuses are this route's own fetch (`GET /api/projects/:id/statuses`) -- Explore has no use for them. `board` is derived from `cards.cards` + those statuses and rebuilt by a `watch` whenever either changes.
Every drop — whether reordering within a column (`moved`) or dragging in from
another column or the sidebar's inbox (`added`) — calls
`reorderColumn(projectId, column.statusId, ids)` from `lib/cardOrder.ts` (shared
with the sidebar) → `PUT /api/cards/order`. The server re-parents any moved-in
card and re-packs whatever column it left; afterwards the view always reloads
both `inbox` and this project's `cards`, since either could have been the other
side of the move.
Every drop — whether reordering within a column (`moved`) or dragging in from another column or the sidebar's inbox (`added`) — calls `reorderColumn(projectId, column.statusId, ids)` from `lib/cardOrder.ts` (shared with the sidebar) → `PUT /api/cards/order`. The server re-parents any moved-in card and re-packs whatever column it left; afterwards the view always reloads both `inbox` and this project's `cards`, since either could have been the other side of the move.
## Project configuration
`/projects/:id/configure` (`ProjectConfigureView.vue`) manages a project's
statuses. The header mirrors the project view's — an inline `.title-back`
arrow before the title, this time back to the project, and
`ProjectManageMenu` top right (its own Configure link is hidden here, since
it would just point at the current page).
`/projects/:id/configure` (`ProjectConfigureView.vue`) manages a project's statuses. The header mirrors the project view's — an inline `.title-back` arrow before the title, this time back to the project, and `ProjectManageMenu` top right (its own Configure link is hidden here, since it would just point at the current page).
A **Rename project** section (a plain `PATCH /api/projects/:id` form,
`{ title }`) sits above **Statuses**. On success it also calls the
`projects` store's `fetchProjects()` -- the local `project` ref (and this
view's own header) update from the PATCH response directly, but the
dashboard grid and the sidebar's project dropdown read from that store, so
without the extra fetch the new name (and alphabetical position -- projects
are API-ordered by title) wouldn't show up there until some other reload.
A **Rename project** section (a plain `PATCH /api/projects/:id` form, `{ title }`) sits above **Statuses**. On success it also calls the `projects` store's `fetchProjects()` -- the local `project` ref (and this view's own header) update from the PATCH response directly, but the dashboard grid and the sidebar's project dropdown read from that store, so without the extra fetch the new name (and alphabetical position -- projects are API-ordered by title) wouldn't show up there until some other reload.
The status list is a `vuedraggable` list (its own list, no shared drag group
with the kanban board) bound directly to a local `statuses` ref; dragging
mutates it in place, and `@change` persists the whole new order via
`PUT /api/projects/:id/statuses/order`, reverting to the server's copy on
failure. A small form below it adds a status
(`POST /api/projects/:id/statuses`) at the end of the list.
The status list is a `vuedraggable` list (its own list, no shared drag group with the kanban board) bound directly to a local `statuses` ref; dragging mutates it in place, and `@change` persists the whole new order via `PUT /api/projects/:id/statuses/order`, reverting to the server's copy on failure. A small form below it adds a status (`POST /api/projects/:id/statuses`) at the end of the list.
Each row has a delete button. A status with no cards deletes immediately; one
still holding cards gets `409` back from `DELETE .../statuses/:statusId` with
`error.details.card_count` (surfaced as `ApiError#cardCount`) -- that opens a
modal asking which other status to move its cards to, then resubmits the same
delete with `{ reassign_to }`, which reassigns and deletes in one request. The
last remaining status can't be deleted (a project card always needs one); its
row's delete button is disabled once `statuses.length <= 1`.
Each row has a delete button. A status with no cards deletes immediately; one still holding cards gets `409` back from `DELETE .../statuses/:statusId` with `error.details.card_count` (surfaced as `ApiError#cardCount`) -- that opens a modal asking which other status to move its cards to, then resubmits the same delete with `{ reassign_to }`, which reassigns and deletes in one request. The last remaining status can't be deleted (a project card always needs one); its row's delete button is disabled once `statuses.length <= 1`.
## Card detail
A card's text is no longer inline-editable anywhere it's listed -- the
Explore row, a kanban column, and the sidebar inbox all just link to
`/cards/:id` (`CardView.vue`) instead. Its header follows the same pattern as
every other view now: an inline `.title-back` arrow before the title text,
`Manage` in the actions corner. The arrow goes to the card's project, or the
dashboard for an inbox card (there's no standalone view of the inbox to
return to). Below the header sits just the status badge -- no tabs, since
there's nothing else to show for a single card.
A card's text is no longer inline-editable anywhere it's listed -- the Explore row, a kanban column, and the sidebar inbox all just link to `/cards/:id` (`CardView.vue`) instead. Its header follows the same pattern as every other view now: an inline `.title-back` arrow before the title text, `Manage` in the actions corner. The arrow goes to the card's project, or the dashboard for an inbox card (there's no standalone view of the inbox to return to). Below the header sits just the status badge -- no tabs, since there's nothing else to show for a single card.
`/cards/:id/configure` (`CardConfigureView.vue`) mirrors the project
configuration view once more: same header (its back arrow instead returns to
the card view), with a **Rename card** form below it (`PATCH /api/cards/:id`,
`{ text }`). `CardManageMenu.vue` -- the card equivalent of
`ProjectManageMenu.vue` -- provides both views' **Manage** menu: a
**Configure** link (hidden on the configure view itself) and a **Delete
card** action behind a confirmation modal.
`/cards/:id/configure` (`CardConfigureView.vue`) mirrors the project configuration view once more: same header (its back arrow instead returns to the card view), with a **Rename card** form below it (`PATCH /api/cards/:id`, `{ text }`). `CardManageMenu.vue` -- the card equivalent of `ProjectManageMenu.vue` -- provides both views' **Manage** menu: a **Configure** link (hidden on the configure view itself) and a **Delete card** action behind a confirmation modal.
Saving a rename or confirming a delete refreshes whichever store holds the
card -- the `cards` store (for a project card) or the `inbox` store -- so the
board or sidebar it came from picks up the change; deleting also navigates
back to wherever its back arrow points.
Saving a rename or confirming a delete refreshes whichever store holds the card -- the `cards` store (for a project card) or the `inbox` store -- so the board or sidebar it came from picks up the change; deleting also navigates back to wherever its back arrow points.
## Auth flow
There is no password and no separate sign-up — `LoginView` is an email field
and a "Send sign-in link" button (`POST /api/auth/magic-link`), for a new
address or a returning one alike. On success it shows a "check your email"
message; it does not sign the caller in itself. If the browser supports
WebAuthn, a **"Log in with a passkey"** button sits below the form, past a
divider (see [Passkeys](#passkeys)) — that one *does* sign the caller in
directly, no email round trip.
There is no password and no separate sign-up — `LoginView` is an email field and a "Send sign-in link" button (`POST /api/auth/magic-link`), for a new address or a returning one alike. On success it shows a "check your email" message; it does not sign the caller in itself. If the browser supports WebAuthn, a **"Log in with a passkey"** button sits below the form, past a divider (see [Passkeys](#passkeys)) — that one *does* sign the caller in directly, no email round trip.
- `/verify-email?token=…` is the target for every magic link (sign-in and
email-change confirmation both). `VerifyEmailView` POSTs the token via
`auth.verifyEmail()`, which returns a session — opening the link is what
actually signs the caller in — then redirects to the dashboard.
- The token is kept in `localStorage` and sent as `Authorization: Bearer …`.
On load, `fetchMe()` validates it via `GET /api/me`; a failure clears it.
- Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended
path) when there is no authenticated user.
- Because the only way to get a session is opening a link or using a passkey
(which itself requires a prior link-based sign-in to register), `user.
email_verified` is always `true` for a signed-in user — the frontend doesn't
show any verification nagging or resend UI.
- `/verify-email?token=…` is the target for every magic link (sign-in and email-change confirmation both). `VerifyEmailView` POSTs the token via `auth.verifyEmail()`, which returns a session — opening the link is what actually signs the caller in — then redirects to the dashboard.
- The token is kept in `localStorage` and sent as `Authorization: Bearer …`. On load, `fetchMe()` validates it via `GET /api/me`; a failure clears it.
- Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended path) when there is no authenticated user.
- Because the only way to get a session is opening a link or using a passkey (which itself requires a prior link-based sign-in to register), `user.email_verified` is always `true` for a signed-in user — the frontend doesn't show any verification nagging or resend UI.
## Passkeys
`src/lib/webauthn.ts` wraps the two ceremonies. Both fetch a `{ challenge_id,
options }` pair from the API, decode `options.publicKey`'s base64url fields
(`challenge`, `user.id`, `*Credentials[].id`) into `ArrayBuffer`s, call
`navigator.credentials.create()` / `.get()`, then base64url-encode the
resulting `PublicKeyCredential`'s response back into JSON for the API
(`{ id, response: { clientDataJSON, ... } }`). `passkeysSupported()` is a
one-line `window.PublicKeyCredential` check gating the UI everywhere below.
`src/lib/webauthn.ts` wraps the two ceremonies. Both fetch a `{ challenge_id, options }` pair from the API, decode `options.publicKey`'s base64url fields (`challenge`, `user.id`, `*Credentials[].id`) into `ArrayBuffer`s, call `navigator.credentials.create()` / `.get()`, then base64url-encode the resulting `PublicKeyCredential`'s response back into JSON for the API (`{ id, response: { clientDataJSON, ... } }`). `passkeysSupported()` is a one-line `window.PublicKeyCredential` check gating the UI everywhere below.
- **Register** (`ProfileView`, "Passkeys" section) — lists the caller's
passkeys (`GET /api/passkeys`) with a **Remove** button each
(`DELETE /api/passkeys/{id}`), and an "Add a passkey" form: a label input
(pre-filled with a guess from `navigator.userAgent`, e.g. "Mac") and a
button calling `registerPasskey(label)`. On success it appends to the local
list and calls `auth.fetchMe()` so `user.has_passkey` (and the notice below)
updates immediately.
- **Login** (`LoginView`) — the passkey button calls
`auth.loginWithPasskey()`, which adopts the returned session exactly like
`verifyEmail()`, then redirects to `route.query.redirect` or `/`. A
cancelled prompt (`DOMException` named `NotAllowedError`) shows "Cancelled."
rather than a generic error.
- **`PasskeyNotice.vue`** (mounted in `App.vue`, between the header and the
sidebar/main body — spans the full page width) shows when signed in with
`user.has_passkey === false`. Dismissing it writes
`localStorage['passkeyNoticeDismissedUntil'] = Date.now() + 7 days`; the
banner stays hidden until that passes, and reappears immediately (no reload
needed, since `has_passkey` is reactive on the shared `auth.user`) if every
passkey is later removed.
- **Register** (`ProfileView`, "Passkeys" section) — lists the caller's passkeys (`GET /api/passkeys`) with a **Remove** button each (`DELETE /api/passkeys/{id}`), and an "Add a passkey" form: a label input (pre-filled with a guess from `navigator.userAgent`, e.g. "Mac") and a button calling `registerPasskey(label)`. On success it appends to the local list and calls `auth.fetchMe()` so `user.has_passkey` (and the notice below) updates immediately.
- **Login** (`LoginView`) — the passkey button calls `auth.loginWithPasskey()`, which adopts the returned session exactly like `verifyEmail()`, then redirects to `route.query.redirect` or `/`. A cancelled prompt (`DOMException` named `NotAllowedError`) shows "Cancelled." rather than a generic error.
- **`PasskeyNotice.vue`** (mounted in `App.vue`, between the header and the sidebar/main body — spans the full page width) shows when signed in with `user.has_passkey === false`. Dismissing it writes `localStorage['passkeyNoticeDismissedUntil'] = Date.now() + 7 days`; the banner stays hidden until that passes, and reappears immediately (no reload needed, since `has_passkey` is reactive on the shared `auth.user`) if every passkey is later removed.
## Profile
`/profile` (`ProfileView`) sets `meta.wide` like the dashboard/project views,
rather than sitting in the default narrow column (that's now just the login
form). It shows the current address, the **Passkeys** section
described above, and a **Change email** form (new address only, no password).
On success the API has emailed a confirmation link to the *new* address and
set `user.pending_email` (shown as a notice until it's opened); the change
only lands once that link is opened. The button shows a live countdown driven
by `retry_after` and by `429` responses.
`/profile` (`ProfileView`) sets `meta.wide` like the dashboard/project views, rather than sitting in the default narrow column (that's now just the login form). It shows the current address, the **Passkeys** section described above, and a **Change email** form (new address only, no password). On success the API has emailed a confirmation link to the *new* address and set `user.pending_email` (shown as a notice until it's opened); the change only lands once that link is opened. The button shows a live countdown driven by `retry_after` and by `429` responses.