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

8.1 KiB

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.