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>
9.0 KiB
Project Manager — web
Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory.
Develop
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).
Build
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.
Layout
src/main.ts App bootstrap; resolves the stored session before mount
src/router/index.ts Routes + guard (redirects to /login when unauthenticated)
src/stores/auth.ts Pinia store: token in localStorage, magic-link + fetchMe
src/stores/projects.ts Pinia store: the user's projects (fetch + create)
src/stores/cards.ts Pinia store: one project's cards (CRUD; no reordering -- see lib/cardOrder.ts)
src/stores/inbox.ts Pinia store: the caller's global inbox (fetch + create)
src/lib/api.ts fetch wrapper, bearer token, typed ApiError
src/lib/cardOrder.ts reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board
src/lib/webauthn.ts base64url <-> ArrayBuffer + the register/login passkey ceremonies
src/components/AppSidebar.vue left nav: Dashboard link, project dropdown, Inbox + form
src/components/CardRow.vue editable text + status chip + delete, one card
src/components/KanbanCard.vue small draggable card for the board columns and the inbox
src/components/PasskeyNotice.vue dismissible "add a passkey" banner across the top of the page
src/views/ DashboardView, ProjectView, LoginView, ProfileView,
VerifyEmailView
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.pushes to the chosen one, so
it also works as a project switcher from anywhere. <RouterView :key="route.path"> remounts the view on every path change so switching
projects always does a fresh load.
/ redirects to /dashboard (DashboardView.vue): a full-width grid linking
to each project (title + card count), then a new-project form directly
below it (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.
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 every project's kanban columns (below), so a card can be dragged
straight out of the sidebar into any status column of whichever project is
currently open, or the other way. AppSidebar's onInboxChange persists a
drop via reorderColumn(null, null, ids), then reloads the inbox and, if a
project view is currently mounted (route.name === 'project'), 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.
Project detail
/projects/:id shows one project. It renders on a full-width layout (the
route sets meta.wide, which widens .app__main in App.vue). The title and
description are inline-editable (saved on blur via PATCH /api/projects/:id; the
description shows an "Add a description" placeholder when empty). A Manage
menu (top right) has a Delete project action that opens a confirmation modal;
confirming calls DELETE /api/projects/:id and returns to the dashboard.
Below the header are two tabs (local activeTab state, v-show so both stay
mounted). The tab order is fixed — All tasks first, Kanban second — but
activeTab initialises to 'kanban', so a project opens on the board.
All tasks
The flat card list, sorted by name (case-insensitive) via a sortedCards
computed — there is no manual order here. Each row is an inline-editable text
field (saved on blur), a status chip (card.status.name or "No status"), and a
delete button.
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
source/target. board is derived from cards.cards + the project's 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.
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 above the form (see
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).VerifyEmailViewPOSTs the token viaauth.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
localStorageand sent asAuthorization: Bearer …. On load,fetchMe()validates it viaGET /api/me; a failure clears it. - Routes with
meta.requiresAuthredirect 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_verifiedis alwaystruefor 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 ArrayBuffers, 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 fromnavigator.userAgent, e.g. "Mac") and a button callingregisterPasskey(label). On success it appends to the local list and callsauth.fetchMe()souser.has_passkey(and the notice below) updates immediately. - Login (
LoginView) — the passkey button callsauth.loginWithPasskey(), which adopts the returned session exactly likeverifyEmail(), then redirects toroute.query.redirector/. A cancelled prompt (DOMExceptionnamedNotAllowedError) shows "Cancelled." rather than a generic error. PasskeyNotice.vue(mounted inApp.vue, between the header and the sidebar/main body — spans the full page width) shows when signed in withuser.has_passkey === false. Dismissing it writeslocalStorage['passkeyNoticeDismissedUntil'] = Date.now() + 7 days; the banner stays hidden until that passes, and reappears immediately (no reload needed, sincehas_passkeyis reactive on the sharedauth.user) if every passkey is later removed.
Profile
/profile (ProfileView) 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.