2026-09-04 11:28:59 +01:00
|
|
|
# Project Manager — web
|
2026-09-03 18:12:31 +01:00
|
|
|
|
|
|
|
|
Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory.
|
|
|
|
|
|
2026-09-04 13:37:17 +01:00
|
|
|
## Develop
|
2026-09-03 18:12:31 +01:00
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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)).
|
|
|
|
|
|
|
|
|
|
## Build
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
npm run build # type-checks, then emits dist/
|
|
|
|
|
npm run preview
|
|
|
|
|
```
|
|
|
|
|
|
2026-09-04 13:37:17 +01:00
|
|
|
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.
|
|
|
|
|
|
2026-09-03 18:12:31 +01:00
|
|
|
## Layout
|
|
|
|
|
|
|
|
|
|
```
|
2026-09-03 18:59:51 +01:00
|
|
|
src/main.ts App bootstrap; resolves the stored session before mount
|
|
|
|
|
src/router/index.ts Routes + guard (redirects to /login when unauthenticated)
|
2026-09-04 14:47:46 +01:00
|
|
|
src/stores/auth.ts Pinia store: token in localStorage, magic-link + fetchMe
|
2026-09-04 11:28:59 +01:00
|
|
|
src/stores/projects.ts Pinia store: the user's projects (fetch + create)
|
2026-09-04 15:22:33 +01:00
|
|
|
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)
|
2026-09-03 18:59:51 +01:00
|
|
|
src/lib/api.ts fetch wrapper, bearer token, typed ApiError
|
2026-09-04 15:22:33 +01:00
|
|
|
src/lib/cardOrder.ts reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board
|
2026-09-04 19:34:53 +01:00
|
|
|
src/lib/webauthn.ts base64url <-> ArrayBuffer + the register/login passkey ceremonies
|
2026-09-04 15:31:30 +01:00
|
|
|
src/components/AppSidebar.vue left nav: Dashboard link, project dropdown, Inbox + form
|
2026-09-04 13:37:17 +01:00
|
|
|
src/components/CardRow.vue editable text + status chip + delete, one card
|
2026-09-04 15:22:33 +01:00
|
|
|
src/components/KanbanCard.vue small draggable card for the board columns and the inbox
|
2026-09-04 19:34:53 +01:00
|
|
|
src/components/PasskeyNotice.vue dismissible "add a passkey" banner across the top of the page
|
2026-09-04 20:31:10 +01:00
|
|
|
src/components/ProjectManageMenu.vue "Manage" dropdown + delete-project modal, shared by ProjectView and ProjectConfigureView
|
|
|
|
|
src/views/ DashboardView, ProjectView, ProjectConfigureView, LoginView,
|
|
|
|
|
ProfileView, VerifyEmailView
|
2026-09-03 18:12:31 +01:00
|
|
|
```
|
|
|
|
|
|
2026-09-04 13:55:31 +01:00
|
|
|
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
|
2026-09-04 15:31:30 +01:00
|
|
|
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. `<RouterView
|
|
|
|
|
:key="route.path">` remounts the view on every path change so switching
|
|
|
|
|
projects always does a fresh load.
|
2026-09-04 13:55:31 +01:00
|
|
|
|
2026-09-04 20:54:18 +01:00
|
|
|
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.
|
|
|
|
|
|
2026-09-04 15:31:30 +01:00
|
|
|
`/` redirects to `/dashboard` (`DashboardView.vue`): a full-width grid linking
|
2026-09-04 19:59:03 +01:00
|
|
|
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
|
2026-09-04 15:31:30 +01:00
|
|
|
(`/login`, `/verify-email`) render without the sidebar.
|
2026-09-04 15:22:33 +01:00
|
|
|
|
2026-09-04 22:03:38 +01:00
|
|
|
## Styling
|
|
|
|
|
|
|
|
|
|
`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.
|
|
|
|
|
|
2026-09-04 15:22:33 +01:00
|
|
|
## 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.
|
2026-09-04 13:55:31 +01:00
|
|
|
|
2026-09-04 20:47:17 +01:00
|
|
|
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).
|
|
|
|
|
|
2026-09-04 11:28:59 +01:00
|
|
|
## Project detail
|
2026-09-03 18:59:51 +01:00
|
|
|
|
2026-09-05 00:46:17 +01:00
|
|
|
`/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>`.
|
2026-09-03 19:10:31 +01:00
|
|
|
|
2026-09-05 00:46:17 +01:00
|
|
|
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.
|
2026-09-04 13:37:17 +01:00
|
|
|
|
2026-09-05 00:36:38 +01:00
|
|
|
### Explore
|
2026-09-04 13:37:17 +01:00
|
|
|
|
|
|
|
|
The flat card list, **sorted by name (case-insensitive)** via a `sortedCards`
|
2026-09-05 01:04:50 +01:00
|
|
|
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.
|
2026-09-04 13:37:17 +01:00
|
|
|
|
|
|
|
|
### Kanban
|
|
|
|
|
|
2026-09-04 15:22:33 +01:00
|
|
|
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
|
2026-09-05 01:04:50 +01:00
|
|
|
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.
|
2026-09-04 13:37:17 +01:00
|
|
|
|
|
|
|
|
Every drop — whether reordering within a column (`moved`) or dragging in from
|
2026-09-04 15:22:33 +01:00
|
|
|
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.
|
2026-09-03 18:59:51 +01:00
|
|
|
|
2026-09-04 20:31:10 +01:00
|
|
|
## Project configuration
|
|
|
|
|
|
|
|
|
|
`/projects/:id/configure` (`ProjectConfigureView.vue`) manages a project's
|
2026-09-05 00:36:38 +01:00
|
|
|
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).
|
2026-09-04 20:31:10 +01:00
|
|
|
|
2026-09-04 21:10:57 +01:00
|
|
|
A **Rename project** section (a plain `PATCH /api/projects/:id` form,
|
2026-09-04 21:01:27 +01:00
|
|
|
`{ 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.
|
|
|
|
|
|
2026-09-04 20:31:10 +01:00
|
|
|
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`.
|
|
|
|
|
|
2026-09-05 00:36:38 +01:00
|
|
|
## 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.
|
|
|
|
|
|
|
|
|
|
`/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.
|
|
|
|
|
|
2026-09-03 18:12:31 +01:00
|
|
|
## Auth flow
|
|
|
|
|
|
2026-09-04 19:34:53 +01:00
|
|
|
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
|
2026-09-04 14:47:46 +01:00
|
|
|
address or a returning one alike. On success it shows a "check your email"
|
2026-09-04 19:34:53 +01:00
|
|
|
message; it does not sign the caller in itself. If the browser supports
|
2026-09-04 19:54:37 +01:00
|
|
|
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.
|
2026-09-04 14:47:46 +01:00
|
|
|
|
|
|
|
|
- `/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.
|
2026-09-03 18:12:31 +01:00
|
|
|
- Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended
|
|
|
|
|
path) when there is no authenticated user.
|
2026-09-04 19:34:53 +01:00
|
|
|
- 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.
|
|
|
|
|
|
|
|
|
|
- **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.
|
2026-09-03 20:04:49 +01:00
|
|
|
|
2026-09-04 14:47:46 +01:00
|
|
|
## Profile
|
2026-09-03 20:04:49 +01:00
|
|
|
|
2026-09-04 21:09:47 +01:00
|
|
|
`/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
|
2026-09-04 19:34:53 +01:00
|
|
|
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.
|