11 Commits
Author SHA1 Message Date
aneurinandClaude Sonnet 5 b00ec7addd Squash all migrations into one clean initial schema
Replaces 001-011 (create/alter/rebuild/backfill, in the order features
landed) with a single 001_initial_schema.sql that creates every table
in its final shape directly -- no password_hash (added then dropped),
no project description (added then dropped), cards already shaped as
the global-inbox-with-a-CHECK-constraint design rather than rebuilt
into it, no data-migration/backfill statements (nothing to backfill
against a schema created fresh).

This is a pre-release project with no data worth preserving, so the
dev database (and the storage volume's generated JWT key with it) was
wiped rather than migrated -- confirmed the fresh schema matches
exactly (same tables/columns as before, minus the two dropped columns)
and the app works end to end against it. PHPUnit's in every run
already builds its database from migrations/*.sql from scratch, so the
suite needed no changes and is unaffected either way: 88/88.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:32:27 +01:00
aneurinandClaude Sonnet 5 fb59a54938 Drop the unused project description field entirely
It never got a UI home on the frontend -- removed from ProjectView a
few sessions back and never restored anywhere -- so it was a fully
live field (validated, stored, returned by the API, covered by tests)
with no consumer.

- Migration 011: ALTER TABLE projects DROP COLUMN description.
- ProjectRepository: description dropped from ProjectRow, SELECT,
  create() and update() -- update() is now just a rename (string, not
  a $fields array; there was never more than one editable field once
  this left).
- ProjectController: store()/update() no longer accept or validate
  it; update() drops the "provide at least one of title, description"
  branch, since title is unconditionally the only field now.
- Frontend Project type, root README's API docs and curl example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:25:53 +01:00
aneurinandClaude Sonnet 5 afdd53ec4c Add passkeys (WebAuthn): register from the profile, log in without email
New library dependency: lbuchs/webauthn (^2.2, MIT, zero transitive deps
beyond PHP+OpenSSL+Mbstring, both already required). 'none' attestation --
this only confirms "the same device that registered", not hardware
provenance, the standard trust model for a public site's own passkey login.

Backend
- migrations/010: `passkeys` (one row per registered credential: owner,
  credential_id, public_key, sign_count, label) and `webauthn_challenges`
  (short-lived, single-use, bridging each ceremony's "options" and "verify"
  calls -- user_id set for a registration, null for a login since who's
  signing in isn't known until the credential comes back).
- Config: WEBAUTHN_RP_ID (defaults to APP_URL's host) and WEBAUTHN_RP_NAME.
- PasskeyRepository, WebAuthnChallengeRepository, PasskeyController:
  GET/POST /api/passkeys, POST /api/passkeys/options, DELETE
  /api/passkeys/{id} (all auth), plus the public POST /api/auth/passkey/
  options and /verify for login. Registration always asks for a
  discoverable, user-verified credential -- what makes login usernameless:
  the browser offers whatever passkeys it has for the site, no email first.
- SessionPayload now also exposes `has_passkey` on every user object
  (PasskeyRepository::countForUser() > 0), reused by both the profile page
  and the dismissible notice.
- PasskeyTest: auth guards, options response shape, challenge single-use/
  expiry/purpose/cross-user rules, malformed-input handling, list/remove
  CRUD (seeded rows) -- everything short of a real signature, which isn't
  practical from PHPUnit. 73 tests pass.

Frontend
- lib/webauthn.ts: base64url <-> ArrayBuffer conversion and the two
  ceremonies (registerPasskey, loginWithPasskey), matching the API's wire
  format exactly.
- ProfileView: a Passkeys section -- list with Remove buttons, an "Add a
  passkey" form (label pre-filled from a UA guess).
- LoginView: a "Log in with a passkey" button above the email form, shown
  only when the browser supports WebAuthn.
- PasskeyNotice.vue: dismissible banner across the top of the page
  (`user.has_passkey === false`); dismissal is a week-long localStorage
  timestamp.

Verified against the rebuilt container using a Chrome DevTools Protocol
*virtual authenticator* (real ECDSA signing, no human interaction) end to
end: notice shown -> register a passkey -> notice gone (same page and after
navigating) -> log out -> "Log in with a passkey" with no email typed ->
correct account, notice still gone -> remove the passkey -> notice back ->
dismiss -> stays hidden for ~7 days across pages. Along the way, caught and
fixed a real bug: AuthenticatorData::getCredentialId() returns a raw binary
string, not a ByteBuffer like most of this library's other binary fields --
bin2hex() it directly rather than calling ->getHex().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 19:34:53 +01:00
aneurinandClaude Sonnet 5 4ee0f24078 Make the inbox global instead of per-project
A card either sits in its owner's inbox (project_id AND status_id both NULL)
or belongs to exactly one project with a status in it (both set) -- enforced
by a CHECK constraint, never one without the other. The inbox is global to a
user now, not per-project: cards can move from a project into the inbox and
back into any status column of any project.

Backend
- migrations/009: rebuilds `cards` (SQLite can't relax NOT NULL / add a CHECK
  in place) with a nullable project_id, a new owner_id (cards need direct
  ownership once they can have no project), and the CHECK constraint. Cards
  that had no status (the old per-project inbox) move to the new global inbox.
  status_id's FK is now ON DELETE RESTRICT, not SET NULL -- nulling it alone
  would violate the invariant, and there's no status-delete endpoint anyway.
- CardRepository: "column" is now (owner_id, project_id, status_id); every
  method that dealt with a project's columns is generalised to also cover the
  inbox and cross-project moves (orderColumn, idsInColumn, repack, ...).
- CardController/routes: single-card and ordering routes move to global,
  since a card may have no project to nest them under --
  GET/PATCH/DELETE /api/cards/{id}, PUT /api/cards/order (body now takes
  project_id + status_id, both null for the inbox). New GET/POST
  /api/inbox/cards. PATCH no longer accepts status_id -- moving a card, in or
  out of a project, is exclusively PUT /api/cards/order now. A card created
  directly in a project (POST /api/projects/{id}/cards) lands in its first
  status, since a project card can't have no status.
- Tests: ProjectTest/CardStatusTest updated for the new routes; CardOrderTest
  rewritten with full inbox/cross-project coverage. 57 tests pass.

Frontend
- New stores/inbox.ts (the global inbox) and lib/cardOrder.ts (the shared
  PUT /api/cards/order call, used by both the sidebar and a project's board).
- AppSidebar: an Inbox section under the project list -- a vuedraggable list
  in the same "kanban" drag group as every project's kanban columns, so a
  card drags straight from the sidebar into whichever project is open, or
  back out. (The empty-inbox state needed a real bugfix: it wasn't rendering
  a <draggable> at all, so there was nowhere to drop a card back into an
  empty inbox.) A drop reloads the inbox and, if a project is open, its cards.
- ProjectView's kanban board drops its synthetic Inbox column -- just the
  real statuses now.
- DashboardView simplified to a plain grid of project tiles (name + card
  count); its per-project "New" section is gone, since a project card can no
  longer have no status.
- stores/cards.ts: patch/remove move to the global /api/cards/{id} routes.

Verified end-to-end against the rebuilt container (existing per-project-inbox
cards correctly migrated to the global inbox, 0 invariant violations) and the
dev server via headless Chrome: sidebar inbox -> project A "To do" -> back to
inbox -> project B "Done", full journey confirmed via the API at each step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:22:33 +01:00
aneurinandClaude Sonnet 5 c82bdbbf0e Passwordless-only auth: drop registration and passwords entirely
There is now one way in: POST /api/auth/magic-link with an email address. It
creates the account (unverified) if the address is new -- that's the only
"sign up" -- and emails a sign-in link either way, subject to the existing
60s-per-user resend throttle. Opening the link (POST /api/auth/verify-email,
unchanged) is what actually creates the session, and marks the address
verified the first time. Since a session can now only ever come from an
opened link, "authenticated" implies "verified" -- there's no more
authenticated-but-unverified state, so the resend-verification endpoint and
all the "verify your email" nagging UI are gone too.

Backend
- migrations/008: ALTER TABLE users DROP COLUMN password_hash.
- UserRepository: create() takes only an email; new findOrCreateByEmail()
  (race-safe) backs the magic-link endpoint.
- AuthController: register()/login() removed; requestLoginLink() now
  find-or-creates before sending.
- EmailVerificationController: resend() removed (dead -- you can't be
  authenticated and unverified); requestChange() drops the password check,
  now just { email }.
- EmailVerifier: sendVerification() removed (unused once register() and
  resend() are gone); sendLoginLink() is the one email people get.
- Routes: POST /auth/register, POST /auth/login, POST /email/verification
  all gone.

Frontend
- LoginView: email field + "Send sign-in link" button, nothing else.
  RegisterView and the /register route are gone.
- auth store: register()/login()/resendVerification() removed;
  requestEmailChange() drops the password param.
- ProfileView: password field and the "verify your email" section removed,
  leaving just the change-email form.
- App.vue: the "verify email" header badge is gone; DashboardView's
  unverified-address notice is gone.
- Now-dead .badge/.badge--warn/a.badge CSS removed.

Tests: AuthTest and EmailVerificationTest rewritten for the new flow (52
tests total, down from 58 -- consolidated, not reduced coverage).
ApiTestCase::authHeader() signs in via the real magic-link -> verify flow.

Verified end-to-end against the rebuilt container and the dev server: a brand
new address gets an account + session from one link; /auth/register,
/auth/login and /email/verification all 404; the UI shows no password field
anywhere and no verification nagging. Also fixed the README's "Try it" curl
snippets, which had been silently broken since JSON_PRETTY_PRINT was added
(grep patterns didn't tolerate the space after ':').

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:47:46 +01:00
aneurinandClaude Sonnet 5 c47c800d01 Add per-project card statuses and a kanban board
Statuses
- Migration 006: card_statuses table (project-scoped) and cards.status_id, a
  nullable FK with ON DELETE SET NULL. Every new project is seeded with
  "To do" / "Doing" / "Done"; GET /api/projects/{id}/statuses lists them.
- New cards have no status -- they sit in an "inbox" until moved.

Project view
- Full-width and tabbed: "All tasks" (a flat list, sorted by name
  case-insensitively) and "Kanban" (Inbox plus one column per status).
- Drag a card within or between columns to reorder / restatus; the Inbox
  column has its own name + Add form.

Ordering
- Migration 007: `position` is now a dense 0..n-1 rank within a
  (project_id, status_id) column, not a project-wide order. New composite
  index idx_cards_project_status_position; existing rows re-ranked.
- PUT /api/projects/{id}/cards/order takes { status_id, card_ids } and sets one
  column's contents and order, re-parenting moved-in cards and re-packing their
  source column in a single transaction. PATCH status_id appends the card to the
  end of the destination column.

58 phpunit tests pass; the frontend type-checks and builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:37:17 +01:00
aneurinandClaude Sonnet 5 c66e5ceb9b Rename lists -> projects and items -> cards throughout
Project scope shifts from a todo list to a project-management app. This is a
straight terminology rename across code, comments, migrations, tests, and
docs — no behaviour change.

- DB: table todo_lists -> projects, todo_items -> cards, column
  todo_items.list_id -> cards.project_id, indexes renamed. Migrations 003/004
  rewritten in place (destructive; recreate the volume with `down -v`).
- API: /api/lists -> /api/projects, nested /items -> /cards, reorder body
  item_ids -> card_ids, JSON keys list/lists/item/items -> project/projects/
  card/cards, item_count -> card_count, list_id -> project_id, and the
  matching error messages.
- PHP: TodoList/TodoItem Repository + Controller -> Project/Card; shared SQL
  aliases l/i -> p/c.
- Frontend: stores lists.ts/items.ts -> projects.ts/cards.ts (useProjectsStore
  / useCardsStore, MAX_PROJECTS), ListView -> ProjectView, TodoItemRow ->
  CardRow, route /lists/:id -> /projects/:id (name "project"), types TodoList/
  TodoItem -> Project/Card, and all UI copy. CSS .lists*/.list-head* ->
  .projects*/.project-head*, .item* -> .card-row* (kept the generic .card
  panel class), .items -> .cards.
- Product name in the header, PWA manifest, index.html title and package
  descriptions -> "Project Manager" / "Projects".

Backend suite: 37 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:28:59 +01:00
aneurinandClaude Sonnet 5 f9b65cc4a7 Add stage 7: email verification magic links and a profile page
Backend
- New Mail namespace: a Mailer interface with SMTP (phpmailer), PHP mail()
  (the default fallback), and log-to-file transports, selected by
  MAIL_TRANSPORT. EmailVerifier issues a hashed, 15-minute magic-link token
  and sends the link (APP_URL/verify-email?token=...).
- Migration 005: email_verifications table + users.verification_email_sent_at.
- Registration now emails a verification link (best effort — a send failure
  doesn't fail registration).
- POST /api/auth/verify-email consumes a token and returns a session, so
  opening the link verifies the address (or applies a pending email change)
  and logs the user in. Single-use; distinct 400s for invalid/used/expired.
- POST /api/email/verification resends; POST /api/email/change requests a
  deferred change (current password required; link goes to the new address;
  users.email only updates when that link is opened). Both throttled to once
  per 60s, returning 429 + retry_after.
- GET /api/me and every session payload now include pending_email. Shared
  SessionPayload builds the user/session JSON for all entry points.

Frontend
- /verify-email view: posts the token, adopts the returned session, redirects.
- /profile view: shows address + status, a resend button with a live cooldown
  (driven by retry_after / 429), and a change-email form (new address +
  current password) that surfaces the pending change.
- Header shows a "verify email" badge linking to the profile.

Tests: 9 new (EmailVerificationTest) covering the link lifecycle, throttle,
and deferred change; AuthTest folded into ApiTestCase, which now routes mail
to a per-test log. Suite: 32 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:04:49 +01:00
aneurinandClaude Sonnet 5 5a0d29a308 Add stage 3: todo list and item CRUD API
Two migrations add todo_lists (owner_id FK to users, title, description) and
todo_items (list_id FK, text, complete, position), both with ON DELETE
CASCADE.

New endpoints under /api/lists, all behind AuthMiddleware:
  - lists: index / store / show / update (PATCH) / destroy
  - items: nested under a list, same five verbs
Lists are owner-scoped — another user's or a missing list responds 404, never
403. New items append after the highest position unless one is given; the
list carries item_count / completed_count. Item PATCH is partial and never
renumbers siblings.

Adds App\Support\Validator for request-body checks, TodoList/TodoItem
repositories, and body()/user() helpers on the Controller base. Feature tests
move their shared harness into tests/ApiTestCase; TodoTest covers CRUD,
ownership isolation, ordering, completion counts, validation and cascade
delete. Full suite: 15 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:30:52 +01:00
aneurinandClaude Sonnet 5 5e3b8dbd7e Add stage 2: Vue/TypeScript PWA shell with auth-gated routing
Backend: new migration adds users.email_verified_at (null = unverified);
registration leaves it null, and the register/login/me payloads now expose
email_verified and email_verified_at.

Frontend (web/): Vite + Vue 3 + TypeScript PWA (vite-plugin-pwa). Pinia auth
store keeps the token in localStorage and validates it via GET /api/me on
load. vue-router guards redirect unauthenticated visitors to /login,
preserving the intended path; /register creates an account and signs in
immediately (with the email unverified). Placeholder home page, minimal
styling, generated icons. Dev server proxies /api to the API.

docker-compose.yml gains an optional "web" service (profile: frontend) so
`docker compose --profile frontend up -d` runs the dev server alongside the
API; `docker compose up -d` still starts the API alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:12:31 +01:00
aneurinandClaude Sonnet 5 7faef6fbff Add stage 1: authentication REST API
Slim 4 + SQLite todo-list API providing email/password registration,
login, and an authenticated GET /me endpoint. Stateless HS256 JWTs,
bcrypt password hashing, uniform JSON error envelope, and a SQL
migration runner. Includes PHPUnit feature tests and stage-1 docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:35:10 +01:00