NextMyDay/docs/specs/spec-010-admin-account-ui.md
Claus Lohmar 13133717e1 docs(spec-010): record the admin & account UI implementation
SPEC-010 is implemented: status + §8 implementation notes and the §9 deviations
(D-1…D-5). The plan, AGENT status, README and the spec index move to M0.2 —
M0, M0.1 and M0.2 are complete; next is M1 (org structure).
2026-10-15 07:58:27 +00:00

123 lines
8.1 KiB
Markdown

# SPEC-010 — the admin & account UI (M0.2)
| Field | Value |
|---|---|
| **Status** | **Implemented** (2026-10-15) — see §8; `build`/`vet`/`test -race` green · static binary · AC-1…AC-7 |
| **Created** | 2026-10-15 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0.2** (make the admin actually usable) |
| **Governing decisions** | **D-009** · **D-019** · **D-029** · **D-034** · **D-041** · `PROJECT_PLAN.md` §7 |
| **Related** | SPEC-004 (identity/users) · SPEC-006 (§8 nudge) · SPEC-008 (modules) · SPEC-009 (the shell) |
---
## 1. Goal
Make the signed-in experience usable: a **nav**, an **Account** page (add a passkey), and a **Users**
page (create users + send the enrolment invite). Today the core *services* exist but there is **no
UI** — the admin lands on the dashboard with no menu and no way to add a user or a passkey.
## 2. Non-goals
Departments/positions (M1). Module enable/disable UI beyond a link to the existing console. Passkey
**removal** (later).
---
## 3. The nav (shell)
The shell renders a nav = **registry entries** (D-038) **plus core entries**, **role-gated**:
| Entry | Route | Who |
|---|---|---|
| **Home** | `/{GUID}/home` | everyone |
| **Account** | `/{GUID}/account` | everyone |
| **Users** | `/{GUID}/users` | **admin** |
| **Modules** | `/{GUID}/core/modules` | **admin** (the existing console) |
## 4. Account page — `/{GUID}/account`
- Show the user's **factors**: passkeys (**count**) · TOTP (enrolled yes/no).
- **Add a passkey** — the WebAuthn ceremony, **reusing the D-041 nudge page/flow** (no duplication).
- **Don't ask again** — `users.passkey_opt_out` (stop the banner).
## 5. Users page — `/{GUID}/users` (admin)
- **List** every user: display name · email · role · manager · identity status
(`pending_enrollment` / `active` / none).
- **Create user**: display name · email · **role** (only roles *below* the creator, D-019) ·
**manager** (defaults to the creator).
- On create: `users.Create` → `identities.Mint` → **email the enrolment invite** — the `/{GUID}`
link (SPEC-006). The identity is `pending_enrollment`; the invite carries the bounded window (A1).
- Show the **invite link** in the UI too, so the admin can copy it if mail isn't configured.
## 6. The post-setup passkey prompt (fixes the admin never being nudged)
The D-041 nudge fires on a **TOTP login** — but the wizard's `finish` mints a session directly, so the
admin is never nudged. Add: while the **signed-in user has no passkey**, has **not** opted out, and the
**origin is not loopback**, the shell shows a **prominent banner** — *"You have no passkey. Add one."* —
linking to the Account page. It disappears once a passkey exists or the user opts out.
## 7. Acceptance criteria
| # | Criterion |
|---|---|
| AC-1 | The shell shows the nav; **Users**/**Modules** appear only for an **admin** |
| AC-2 | The admin can **add a passkey** from the Account page (a real WebAuthn registration) |
| AC-3 | The admin can **create a user** (choosing only roles below their own); the invite is **emailed** (and the link shown) |
| AC-4 | A newly created user can **enrol** at `/{GUID}` (the invite path) |
| AC-5 | A signed-in user **without a passkey** sees the **Add-a-passkey** banner; it clears after adding one or opting out (D-041) |
| AC-6 | A **non-admin** cannot reach `/users` (role gate → 403) |
| AC-7 | `routing` stays **pure**; the binary stays **static**; **no inline scripts** |
---
## 8. Implementation notes (built 2026-10-15)
`go build` · `go vet` · `go test -race` green (all packages) · **static** CGO-free binary
(`CGO_ENABLED=0`) · `internal/core/routing` untouched (purity intact) · **no inline scripts** on the
two new screens. AC-1…AC-7 are asserted **without a browser**; the WebAuthn registration itself stays
browser-bound (SPEC-009 §11).
**What landed**
| Path | What |
|---|---|
| `internal/core/account/` | the **account page** (`GET /{GUID}/account`: factors · Add a passkey · Don't ask again) and the **admin Users page** (`GET /{GUID}/users`: list · create + email the invite), both riding the door as core dispatch modules; `Register` mounts them |
| `internal/core/web/` | the shell gains the **role-gated core nav** (Home · Account · Users · Modules), the **passkey banner** and `PageFor`/`WithShell`/`ShellFromContext`; `layout.html` + `app.css` (the banner, `a.button`, `code`/`.invite`) |
| `internal/core/server/server.go` | `Deps.Credentials`; `serveGUID` attaches the shell state (admin? · banner?) to the request context; `passkeyBanner`/`originHost` |
| `internal/core/auth/` | `Identities.TOTPEnrolled` (the account page's TOTP factor) |
| `internal/core/users/` | `Users.List` (the admin list read) |
| `internal/core/module/registry.go` | `CoreDispatchNames` + `account` · `users` (the fail-closed collision guard) |
| `cmd/myday/main.go` | wires `account.New` · passes `Credentials` · adds the admin-only role requirements for the `users` functions |
**The nav.** `web.navItems` = the **core entries** (Home · Account; Users · Modules only for an admin,
per the `Admin` flag the door computes) **plus** the registry entries (D-038), with a duplicate
target — the discovered `home` dashboard — collapsed onto the core Home entry. A non-admin sees
Home · Account only.
**The passkey ceremony is not duplicated.** The account page's "Add a passkey" is a **link** to the
existing SPEC-006 §8 / D-041 screen (`GET /{GUID}/auth/passkey`), which performs the begin and whose
single `passkey.add` action finishes it — the one WebAuthn implementation.
**The banner.** While the signed-in user has **no passkey**, has **not** opted out and the origin is
**not loopback**, the shell shows the prompt. The condition is the door's (`serveGUID` already holds
the session, the identity and the user); it is attached to the request context and every authenticated
page draws it via `web.PageFor`. This closes the D-041 gap where the wizard's direct session mint
bypasses the TOTP-login nudge (SPEC-006 §8) and the admin was never prompted.
**How the shell learns the user's role / passkey state.** The door attaches `(admin, banner)` to the
request context (`web.WithShell`); a handler renders an authenticated page with
`web.Render(w, s, web.PageFor(r, page))`. `web` stays a leaf (it sees only a bool + the GUID); the
`RequestContext` hand-over is untouched, so identification ≠ authorisation (D-003) is unchanged.
## 9. Deviations & questions for the Architect
| # | Item | Why |
|---|---|---|
| **D-1** | `module.CoreDispatchNames` gained **`account`** and **`users`**. | The two pages ride the door as **core dispatch modules**; a discovered module claiming either name would silently lose precedence / shadow the core — the same fail-closed reason the existing four are guarded (SPEC-009 §8). Flagged because it widens a documented list. |
| **D-2** | The **shell state** (role + banner) is attached to the **request context** by the door and applied with `web.PageFor`, rather than added to `RequestContext`. | `web.Render` takes no request, and the banner must be consistent on **every** authenticated page (dashboard · Modules · nudge · account · Users), not just the new ones. A request-context value keeps `web` a leaf and leaves the `RequestContext` (identification) untouched. |
| **D-3** | The account page's "Add a passkey" is a **link** to `/{GUID}/auth/passkey`, not an embedded second ceremony. | SPEC-010 §4 says *reuse* the D-041 ceremony and *do not duplicate* the WebAuthn flow — the link is the literal reuse. |
| **D-4** | `auth.Identities` gained **`TOTPEnrolled`**; `users.Users` gained **`List`**. | The account page reads the TOTP factor; the admin page lists users. Both are natural contract additions on the service that owns the data; the concrete services already had the reads (`totp_secrets` · a `SELECT`). |
| **D-5** | The banner also appears **on the account page itself** (linking to the page you are on) and on the passkey screen. | SPEC-010 §6 states the banner shows whenever the three conditions hold and links to the Account page; suppressing it there is a design choice, not a requirement — left as specified. |