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).
This commit is contained in:
parent
23dc461118
commit
13133717e1
5 changed files with 140 additions and 9 deletions
10
AGENT.md
10
AGENT.md
|
|
@ -1,7 +1,7 @@
|
|||
# AGENT.md — MyDay
|
||||
|
||||
> Operating instructions for AI agents working in this repository.
|
||||
> **Status:** M0 + M0.1 implemented (SPEC-002…009); next milestone **M1 — org structure**.
|
||||
> **Status:** M0 + M0.1 + M0.2 implemented (SPEC-002…010); next milestone **M1 — org structure**.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -72,8 +72,10 @@ See `PROJECT_PLAN.md` §12.1.
|
|||
|
||||
## 6. Current phase
|
||||
|
||||
**M0 (auth + routing foundation) and M0.1 (the web layer / PWA) are complete** — SPEC-002 … SPEC-009
|
||||
are implemented: routing · identity/sessions · the door · `/setup` · enroll/login (+ the passkey
|
||||
nudge) · roles/chain/recovery · the module registry · the web shell & PWA.
|
||||
**M0 (auth + routing foundation), M0.1 (the web layer / PWA) and M0.2 (the admin & account UI) are
|
||||
complete** — SPEC-002 … SPEC-010 are implemented: routing · identity/sessions · the door · `/setup` ·
|
||||
enroll/login (+ the passkey nudge) · roles/chain/recovery · the module registry · the web shell & PWA ·
|
||||
the role-gated nav, the account page (factors · add a passkey · opt-out), the admin Users page
|
||||
(list · create + invite) and the post-setup passkey banner.
|
||||
|
||||
**Next:** **M1 — org structure** (`PROJECT_PLAN.md` §11).
|
||||
|
|
|
|||
|
|
@ -661,3 +661,4 @@ shell/layout lives in `core/web`; a module's views live with the module.
|
|||
| 2026-10-15 | **D-044 ✅ IMPLEMENTED — the setup code + the first-run boilerplate (D-025 re-revised).** The real topology is **LXC (11020) behind Proxmox + Zoraxy**, so the SSH tunnel is impractical (it lands on the container *gateway*, not loopback). `core/setup` gains a **`Gate`** — a **128-bit setup code** (generated at first run, printed in an unmistakable **startup banner**; `setup_code` overrides it) plus a **signed unlock cookie** (`myday_setup`, carrying a keyed one-way **fingerprint**, never the code). The door (`Deps.SetupGate`) now serves the **wizard** to **either** the tunnel **(a)** or an **unlocked** request **(b)**; `GET /` and an un-unlocked `GET /setup` render a friendly **first-run page** with the code form — **never a bare 404**, so the domain stops looking "down" — and every other route stays 404. The unlock is **single-use**, **rate-limited** (the `/setup` A3 class), **CSRF-protected (A15)** and **redacted** (A8); a wrong/absent code gets a **generic 403**. New: `httpx.Signer.Fingerprint` · `config.setup_code` · `setup.Gate` (`gate.go`) · the `unlock` action · `server.SetupGate`/`serveBoilerplate`. Docs: `deploy-zoraxy.md` §5 rewritten for the LXC/Proxmox + code flow (tunnel kept as gate a), README first-run/deploy sections, SPEC-003 §7/§9 (AC-7 · AC-10 · AC-14) and SPEC-005 §3/§4/§6/§9. Gate green (`build` · `vet` · `test -race`) · **static CGO-free** binary · `routing` purity intact. **Live smoke** (real binary, proxy-style `X-Forwarded-For`): the banner prints the code · `/` and `/setup` → **200 boilerplate** · `/home` → **404** · the tunnel → the wizard · wrong code → **403 generic** · correct code → **303** + unlock cookie → the wizard · a spent code → **403** · the code absent from the access log and redacted when hand-typed into the query. |
|
||||
| 2026-10-15 | **Setup gate simplified to the config toggle (D-045).** `setup: true` enables `/setup` at the **public domain**; **completion writes `setup: false`**; `setup: false` + no admin → a **locked boilerplate** (not claimable); the **setup code becomes OPTIONAL**; the **peer/tunnel gate is retired** (D-025 superseded). **Residual:** while `setup: true`, whoever reaches `/setup` first can claim the admin — set `setup_code` or finish immediately. SPEC-005 §3/§4 revised. |
|
||||
| 2026-10-15 | **D-045 ✅ IMPLEMENTED — the toggle gate, the optional code, the retired tunnel.** The gate is `config.setup`: **`setup: true`** → `/setup` serves the **wizard** (or the **code form** first when `setup_code` is set, D-044); **`setup: false` + no admin** → the **LOCKED** state (`/` and `/setup` show an "enable setup" boilerplate, every other route 404, **not claimable**); **completion** writes **`setup: false`**. `setup.New` **no longer generates a code** — blank ⇒ `Gate.Required()` false ⇒ the toggle alone admits. **Retired:** `httpx.SetupRequestAllowed` + `httpx.IsLoopbackIP` (deleted) and the `!bootstrapped()` fail-safe (now the locked predicate, latched at startup). **Kept:** the XFF/XFP handling for the **client IP** and the `Secure`/`__Host-` cookies + HSTS (A6/A11/D-043). New door seams: `Deps.LockedMode`, `SetupGate.{Required,CodeForm,Landing,Locked}`; `Gate` renders three first-run pages. The **startup banner** states the mode and prints the code **only when required**; the locked state prints *"set `setup: true` and restart"*. Docs: SPEC-003 §7/§9 (AC-7 · AC-10 · AC-14), SPEC-005 §1/§3/§5/§6/§9/§10, `deploy-zoraxy.md` §1/§2/§3/§5/§7, README first-run/deploy, `config.example.yaml`, A13 · O-8 · §5.1 · D-025 superseded. Gate green (`go build` · `go vet` · `go test -race` — 18 pkgs) · **static CGO-free** binary · `routing` purity intact. **Live smoke** (real binary, all three states): locked → `/`·`/setup` **200 boilerplate**, `/home` **404**, unlock **404**; `setup: true` no code → `/setup` **wizard**, `/` **landing**, `/home` **404**; `setup: true` + code → **code form** → wrong code **403** → correct code **303** + unlock cookie → **wizard**; full bootstrap (`domain.save`→`admin.create`→`admin.totp.begin/verify`→`finish`) → `config.yaml` **`setup: false`** → `/setup` **404**, `/` **302**. |
|
||||
| 2026-10-15 | **SPEC-010 ✅ IMPLEMENTED → M0.2 (the admin & account UI).** The live instance had **no nav, no way to add a user and no way to add a passkey** — the services existed, the UI did not. New **`internal/core/account`**: the **account page** (`GET /{GUID}/account` — passkey count · TOTP enrolled · an **Add a passkey** button that **LINKS** to the existing D-041 screen, so the WebAuthn ceremony is never duplicated · **Don't ask again** writing `users.passkey_opt_out`) and the **admin Users page** (`GET /{GUID}/users` — list name·email·role·manager·identity status; **create** with only roles **below** the creator (D-019) and the manager defaulting to the creator → `users.Create` + `identities.Mint` + **email the `/{GUID}` invite** (the mailer) and **show the link** for copy/paste). The **shell** now renders the **role-gated core nav** (Home · Account for everyone; **Users · Modules for an admin** — the registry entries ride alongside, the discovered `home` collapsed onto the core Home) and, while the signed-in user has **no passkey, has not opted out and the origin is not loopback**, a prominent **Add-a-passkey banner** — closing the D-041 gap where the wizard's **direct session mint bypasses the TOTP-login nudge** and the admin was never prompted. The door computes `(admin, banner)` in `serveGUID` (it already holds the session/identity/user) and attaches it to the request context; `web.PageFor` copies it onto every authenticated page (`RequestContext` untouched). Role gate: `/users` + `users.create` require **admin** (`server.Tiers`). Additive: `auth.Identities.TOTPEnrolled` · `users.Users.List` · `module.CoreDispatchNames` += `account·users` · `server.Deps.Credentials`. AC-1…AC-7 green (`build` · `vet` · `test -race` — 20 pkgs) · **static CGO-free** binary · `routing` purity intact · **no inline scripts**. Deviations D-1…D-5 in SPEC-010 §9. |
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
> A self-hosted employee **time, absence & reporting** app by **Sechpoint**.
|
||||
> Delivered as a **PWA** for mobile and desktop. **Single-tenant** — one company per instance.
|
||||
|
||||
**Status:** 🚧 **M0 + M0.1 implemented** — the auth & routing foundation and the web/PWA layer
|
||||
(the SPEC-002…009 slices) are built: routing · identity/sessions · the door · `/setup` ·
|
||||
enroll/login (+ the passkey nudge) · roles/chain/recovery · the module registry · the web shell.
|
||||
**Status:** 🚧 **M0 + M0.1 + M0.2 implemented** — the auth & routing foundation, the web/PWA layer and
|
||||
the admin & account UI (the SPEC-002…010 slices) are built: routing · identity/sessions · the door ·
|
||||
`/setup` · enroll/login (+ the passkey nudge) · roles/chain/recovery · the module registry · the web
|
||||
shell · the role-gated nav, the account page (add a passkey · opt-out), the admin Users page
|
||||
(create + invite) and the passkey banner.
|
||||
Next milestone: **M1 — org structure**. The design of record is
|
||||
[`PROJECT_PLAN.md`](PROJECT_PLAN.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ The design of record is [`../../PROJECT_PLAN.md`](../../PROJECT_PLAN.md); decisi
|
|||
| **007** | [Roles, the chain & recovery](spec-007-roles-chain-recovery.md) | M0 | **Implemented** | 004, 006 |
|
||||
| **008** | [Module registry & the Modules page](spec-008-module-registry.md) | M0 | **Implemented** | 003 |
|
||||
| **009** | [Web layer & PWA (screens · WebAuthn JS · service worker)](spec-009-web-pwa.md) | M0.1 | **Implemented** | 005, 006, 007 |
|
||||
| **010** | [The admin & account UI (nav · account · users · the passkey banner)](spec-010-admin-account-ui.md) | M0.2 | **Implemented** | 006, 008, 009 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -33,8 +34,10 @@ The design of record is [`../../PROJECT_PLAN.md`](../../PROJECT_PLAN.md); decisi
|
|||
└─► 008 registry/modules page
|
||||
│
|
||||
└─► 009 web layer & PWA
|
||||
│
|
||||
└─► 010 admin & account UI
|
||||
```
|
||||
|
||||
> **002 is the Stage-0 gate** (ROUTING.md §11): the pure model is approved and unit-tested
|
||||
> **before** any server code. **002 ✅ · 004 ✅ · 003 ✅ · 006 ✅ · 005 ✅ · 007 ✅ · 008 ✅ · 009 ✅
|
||||
> implemented.** **M0 + M0.1 are complete**; next is **M1 — org structure**.
|
||||
> **before** any server code. **002 ✅ · 004 ✅ · 003 ✅ · 006 ✅ · 005 ✅ · 007 ✅ · 008 ✅ · 009 ✅ ·
|
||||
> 010 ✅ implemented.** **M0 + M0.1 + M0.2 are complete**; next is **M1 — org structure**.
|
||||
|
|
|
|||
123
docs/specs/spec-010-admin-account-ui.md
Normal file
123
docs/specs/spec-010-admin-account-ui.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# 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. |
|
||||
|
||||
Loading…
Reference in a new issue