# 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. |