ManageMenu.vue holds what ProjectManageMenu and CardManageMenu had
copy-pasted between them almost verbatim: the dropdown, the confirm
modal, both useDialog wirings, menuOpen/confirmingDelete/deleting
state. Each is now a thin wrapper supplying only what's genuinely
entity-specific -- the Configure route, the delete labels, and (as
confirmDelete) what deleting actually does, since that differs more
than the UI around it (which stores to refresh, where to navigate).
The confirmation body text comes through the default slot, since a
project's names its card count and a card's doesn't.
notFoundOr(e, notFoundMessage, fallbackMessage) in lib/api.ts replaces
the identical "404 -> fixed message, else -> the error's own message,
else -> a fallback" block that ProjectView, ProjectConfigureView,
CardView, and CardConfigureView had each written out by hand.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Pull the status-management feature (drag reorder, add, delete with
reassignment) out of ProjectConfigureView into its own StatusManager
component. It fetches its own status list independently, so the view
is left with just page chrome and the rename form (316 -> 106 lines).
- Define ColumnChange once in lib/cardOrder.ts instead of duplicating
the same type in AppSidebar.vue and ProjectView.vue.
- Add composables/useDialog.ts for the Escape-to-close + focus-on-open
behaviour shared by every confirm/reassign modal (and, without a
focus target, plain dropdown menus). Used by ProjectManageMenu's
delete-confirmation modal + its own menu, and StatusManager's
reassignment modal.
New /projects/:id/configure view, linked from a new 'Configure' item on
the project view's Manage menu.
Backend:
- CardStatusRepository/CardStatusController gain full CRUD: create
(appended at the end), reorder (dense positions, like card
ordering), and delete.
- Deleting a status with cards attached is rejected with 409 and
error.details.card_count, rather than hitting the existing FK
RESTRICT constraint -- retrying with { reassign_to: <status id> }
moves those cards to that status first (CardRepository::
reassignStatus, appended after the destination's existing cards)
and deletes in one transaction (CardStatusRepository::transaction,
shared PDO connection across repositories).
- The last status in a project can't be deleted, since a project card
is required to have one.
- Routes: POST/DELETE .../statuses(/:id), PUT .../statuses/order.
- 14 new CardStatusTest cases covering all of the above.
Frontend:
- ProjectConfigureView.vue: header (title, back-to-project link, the
shared Manage menu) + a vuedraggable status list (reorder persists
the whole new order) with a delete button per row and an add-status
form. A row's plain delete either succeeds immediately or, on 409,
opens a modal to choose a different status before retrying the
delete with reassign_to.
- Extracted ProjectManageMenu.vue (the Manage dropdown + delete-project
modal) out of ProjectView so both views share it; it now also has a
Configure link (hidden on the configure page itself).
- ApiError gains a cardCount getter (details.card_count), mirroring
the existing retryAfter getter.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>