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:
@@ -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
@@ -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
@@ -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, 1–255 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, 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.
|
||||
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:
|
||||
**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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user