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).
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 ispending_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. |