From b8ead2bf2d2b941633daef553bec217c13662833 Mon Sep 17 00:00:00 2001 From: Claus Lohmar Date: Thu, 15 Oct 2026 07:58:09 +0000 Subject: [PATCH] feat(web): role-gate the nav and show the post-setup passkey banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell now renders the CORE nav entries — Home · Account for everyone, Users · Modules only for an admin — alongside the registry entries, with the discovered `home` dashboard collapsed onto the core Home entry (SPEC-010 §3). A signed-in user with no passkey, who has not opted out and is not on a loopback origin, now sees a prominent "Add a passkey" banner linking to the Account page (SPEC-010 §6). This closes the D-041 gap where the wizard's direct session mint bypasses the TOTP-login nudge, so the admin was never prompted. The door computes (admin, banner) in serveGUID — it already holds the session, identity and user — and attaches it to the request context; the new web.PageFor copies it onto every authenticated page (RequestContext is untouched, so identification stays separate from authorisation, D-003). --- internal/core/auth/flow/flow.go | 6 +- internal/core/auth/flow/render.go | 8 +- internal/core/module/page.go | 4 +- internal/core/server/server.go | 51 ++++++++++++ internal/core/web/layout.html | 1 + internal/core/web/shell.go | 127 +++++++++++++++++++++++++----- internal/core/web/static/app.css | 39 +++++++++ internal/core/web/web_test.go | 70 +++++++++++++++- internal/modules/home/handlers.go | 4 +- 9 files changed, 282 insertions(+), 28 deletions(-) diff --git a/internal/core/auth/flow/flow.go b/internal/core/auth/flow/flow.go index 1a83c4f..c7dd9cb 100644 --- a/internal/core/auth/flow/flow.go +++ b/internal/core/auth/flow/flow.go @@ -141,7 +141,7 @@ func (a *Actions) EnrollScreen(w http.ResponseWriter, r *http.Request, guid stri if err != nil { return err } - return renderHTML(w, http.StatusOK, enrollPage(guid, token)) + return renderHTML(w, r, http.StatusOK, enrollPage(guid, token)) } // LoginScreen renders the login screen for an active GUID with no session @@ -165,7 +165,7 @@ func (a *Actions) LoginScreen(w http.ResponseWriter, r *http.Request, guid strin if err != nil { return err } - return renderHTML(w, http.StatusOK, loginPage(guid, token)) + return renderHTML(w, r, http.StatusOK, loginPage(guid, token)) } // ── enrollment actions ────────────────────────────────────────────────────── @@ -606,7 +606,7 @@ func (a *Actions) PasskeyPrompt(w http.ResponseWriter, r *http.Request, guid str if err := a.putCeremony(w, r, begin); err != nil { return err } - return renderHTML(w, http.StatusOK, passkeyPage(guid, httpx.TokenFromRequest(r), string(begin.Options))) + return renderHTML(w, r, http.StatusOK, passkeyPage(guid, httpx.TokenFromRequest(r), string(begin.Options))) } // PasskeyAdd completes the WebAuthn registration begun by PasskeyPrompt and diff --git a/internal/core/auth/flow/render.go b/internal/core/auth/flow/render.go index ad90348..bb67d3b 100644 --- a/internal/core/auth/flow/render.go +++ b/internal/core/auth/flow/render.go @@ -13,9 +13,13 @@ import ( // // F-2: the enrollment screen carries the TOTP ceremony and the login screen a // session — neither may be cached, so every flow response is `no-store`. -func renderHTML(w http.ResponseWriter, status int, page web.Page) error { +// +// web.PageFor applies the request's shell state (the role-gated nav and the +// post-setup passkey banner, SPEC-010) — a guest screen (enroll/login) carries +// none, and returns unchanged. +func renderHTML(w http.ResponseWriter, r *http.Request, status int, page web.Page) error { w.Header().Set("Cache-Control", "no-store") - return web.Render(w, status, page) + return web.Render(w, status, web.PageFor(r, page)) } // enrollPage is the enrollment screen (SPEC-006 §4). The passkey ceremony is diff --git a/internal/core/module/page.go b/internal/core/module/page.go index 0e17be9..719eaab 100644 --- a/internal/core/module/page.go +++ b/internal/core/module/page.go @@ -193,13 +193,13 @@ func (p *Page) render(w http.ResponseWriter, r *http.Request, rc *server.Request } w.Header().Set("Cache-Control", "no-store") - return web.Render(w, http.StatusOK, web.Page{ + return web.Render(w, http.StatusOK, web.PageFor(r, web.Page{ Title: "Modules", GUID: rc.Workflow, Active: ConsoleModule, CSRF: data.Token, Content: web.Markup(body.String()), - }) + })) } // rowsLocked snapshots what the page lists. The caller holds p.mu. diff --git a/internal/core/server/server.go b/internal/core/server/server.go index 97080dc..461d7d7 100644 --- a/internal/core/server/server.go +++ b/internal/core/server/server.go @@ -21,9 +21,11 @@ package server import ( + "context" "errors" "log/slog" "net/http" + "net/url" "strconv" "strings" "time" @@ -35,6 +37,7 @@ import ( "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" ) // Deps assembles the door's dependencies. @@ -50,6 +53,10 @@ type Deps struct { // Identities resolves a GUID to a user (the door's own lookup, distinct // from the pure Resolver surface). Identities auth.Identities + // Credentials answers "does this identity have a passkey?" — the door's + // post-setup passkey banner (SPEC-010 §6). When nil the banner never + // shows (it is only meaningful once a passkey store is wired). + Credentials auth.Credentials // Users loads the authenticated user's role for the role gate. Users users.Users // Audit receives the door's and the services' audit rows. @@ -94,6 +101,7 @@ type Server struct { reg Registry sessions auth.Sessions ids auth.Identities + creds auth.Credentials users users.Users audit audit.Audit csrf *httpx.CSRF @@ -190,6 +198,7 @@ func New(d Deps) (*Server, error) { reg: d.Registry, sessions: d.Sessions, ids: d.Identities, + creds: d.Credentials, users: d.Users, audit: d.Audit, csrf: d.CSRF, @@ -646,11 +655,53 @@ func (s *Server) serveGUID(w http.ResponseWriter, r *http.Request, res routing.R return } ctx = httpx.WithToken(ctx, s.csrf.Token(sess.ID)) + // SPEC-010 §3/§6: attach the shell decoration — the role-gated nav and the + // post-setup passkey banner — so every authenticated page renders it + // consistently (web.PageFor reads it). The door already loaded the user for + // the role gate, so this adds no extra user lookup. + ctx = web.WithShell(ctx, u.Role == users.RoleAdmin, s.passkeyBanner(ctx, r, u, idn)) r = r.WithContext(ctx) s.invoke(w, r, h, newRequestContext(res, sess.UserID, cid, ip)) } +// passkeyBanner reports whether the shell should show the prominent post-setup +// "add a passkey" prompt for this request (SPEC-010 §6): the signed-in user has +// no passkey, has NOT opted out, and the origin is not loopback. Any lookup +// error fails safe — no banner. +// +// It is deliberately the door's call, not a handler's: the door is the one +// place that already holds the session, the identity and the user, so the +// banner is consistent on the dashboard, the Modules page, the account page and +// the passkey screen alike. It mirrors the SPEC-006 §8 nudge predicate +// (opt-out · loopback · no passkey) but is a separate surface — the nudge +// redirects a TOTP login; this decorates an already-signed-in page. +func (s *Server) passkeyBanner(ctx context.Context, r *http.Request, u users.User, idn auth.Identity) bool { + if s.creds == nil || u.PasskeyOptOut { + return false + } + if httpx.IsLoopbackHost(s.originHost(r)) { + return false + } + has, err := s.creds.HasWebAuthnCredential(ctx, idn.ID) + if err != nil { + s.log.Warn("door: passkey banner lookup failed", "error", err) + return false + } + return !has +} + +// originHost is the origin the banner is judged against: the CONFIGURED +// base_url's host when set — so a reverse proxy that rewrites `Host` cannot +// wrongly suppress (or enable) it — falling back to the request `Host` (L7c, +// the same signal as the SPEC-006 nudge). +func (s *Server) originHost(r *http.Request) string { + if u, err := url.Parse(strings.TrimSpace(s.cfg.Server.BaseURL)); err == nil && u.Host != "" { + return u.Host + } + return r.Host +} + // loginRequired hands off to the login screen without revealing why the session // was rejected (missing, empty, invalid, or belonging to another user). The // screen is a RENDERED PAGE and answers 200; only a failed credential attempt is diff --git a/internal/core/web/layout.html b/internal/core/web/layout.html index 43b8fea..e1d70f9 100644 --- a/internal/core/web/layout.html +++ b/internal/core/web/layout.html @@ -19,6 +19,7 @@ {{if .Nav}}{{end}} {{if .Flash}}

{{.Flash}}

{{end}} +{{if .PasskeyBanner}}{{end}}
{{.Content}}
diff --git a/internal/core/web/shell.go b/internal/core/web/shell.go index 9a32140..4e90741 100644 --- a/internal/core/web/shell.go +++ b/internal/core/web/shell.go @@ -2,6 +2,7 @@ package web import ( "bytes" + "context" _ "embed" "html/template" "net/http" @@ -78,6 +79,16 @@ type Page struct { Guest bool // Active is the PARAM2 module to mark as the current nav entry ("" none). Active string + // Admin marks a signed-in administrator, so the shell shows the + // role-gated nav entries (Users · Modules — SPEC-010 §3). A non-admin sees + // Home and Account only. It is normally filled by PageFor from the shell + // state the door attached; set it directly only in a unit test. + Admin bool + // PasskeyBanner shows the prominent post-setup "add a passkey" prompt + // (SPEC-010 §6) while the signed-in user has no passkey and has not opted + // out (and the origin is not loopback). The door decides; the shell draws. + // It is normally filled by PageFor. + PasskeyBanner bool // Flash is a notice or error banner ("" for none). Flash string // FlashKind is "error" for an error banner; anything else is a notice. @@ -94,16 +105,18 @@ type Page struct { // layoutData is the layout's input (the unexported shape of a Page). type layoutData struct { - AppName string - Title string - CSRF string - Flash string - FlashKind string - Nav []NavItem - HomeHref string - StaticPath string - Content template.HTML - MainAttrs template.HTMLAttr + AppName string + Title string + CSRF string + Flash string + FlashKind string + Nav []NavItem + HomeHref string + StaticPath string + GUID string + PasskeyBanner bool + Content template.HTML + MainAttrs template.HTMLAttr } // Render writes a page inside the shell. The caller is responsible for @@ -130,7 +143,9 @@ func Render(w http.ResponseWriter, status int, p Page) error { data.HomeHref = "/" } else { data.HomeHref = "/" + p.GUID + "/home" - data.Nav = navItems(p.GUID, p.Active) + data.GUID = p.GUID + data.PasskeyBanner = p.PasskeyBanner + data.Nav = navItems(p.GUID, p.Active, p.Admin) } var buf bytes.Buffer @@ -151,29 +166,105 @@ func flashKind(kind string) string { return "notice" } -// navItems builds the nav from the installed NavLister (SPEC-008 §7: the nav is -// driven by the registry, so a disabled module's entry is absent — D-038). -func navItems(guid, active string) []NavItem { +// navItems builds the nav from the CORE entries plus the installed NavLister +// (SPEC-009 §4: a disabled module's entry is absent — D-038; SPEC-010 §3: the +// core entries Home · Account · Users · Modules, role-gated). A duplicate +// target — the registry's `home`, say — is collapsed onto the core entry, so +// the nav never lists the same page twice. +func navItems(guid, active string, admin bool) []NavItem { l := NavSource() if l == nil || guid == "" { return nil } - names := l.Nav() - items := make([]NavItem, 0, len(names)) - for _, name := range names { + items := coreNav(guid, active, admin) + seen := make(map[string]bool, len(items)) + for _, it := range items { + seen[it.Href] = true + } + for _, name := range l.Nav() { name = strings.TrimSpace(name) if name == "" { continue } + href := "/" + guid + "/" + name + if seen[href] { + continue // a core entry already covers this module (the dashboard) + } + seen[href] = true items = append(items, NavItem{ Label: ModuleLabel(name), - Href: "/" + guid + "/" + name, + Href: href, Active: name == active, }) } return items } +// coreNav is the built-in navigation every app page carries (SPEC-010 §3): Home +// and Account for everyone; Users and Modules for an admin. The admin entries +// mirror the role gate (`server.Tiers`) — the door is the authority; this only +// decides what the shell advertises. +func coreNav(guid, active string, admin bool) []NavItem { + items := []NavItem{ + {Label: "Home", Href: "/" + guid + "/home", Active: active == "home"}, + {Label: "Account", Href: "/" + guid + "/account", Active: active == "account"}, + } + if admin { + items = append(items, + NavItem{Label: "Users", Href: "/" + guid + "/users", Active: active == "users"}, + NavItem{Label: "Modules", Href: "/" + guid + "/core/modules", Active: active == "core"}, + ) + } + return items +} + +// ── the per-request shell state (SPEC-010 §3/§6) ───────────────────────────── +// +// The door is the ONE place that knows the signed-in user's role and whether +// the passkey banner applies (it already loaded the user for the role gate). It +// attaches that decoration to the request context; a handler renders an +// authenticated page with PageFor, which copies it onto the Page — so the nav +// and the banner are consistent on every screen without each handler +// re-deriving them. + +// shellState is the shell decoration for one authenticated request. +type shellState struct { + admin bool + banner bool +} + +// shellCtxKey is the unexported context key for the request's shell state. +type shellCtxKey struct{} + +// WithShell attaches the signed-in user's shell state to the request context. +// The door calls it once per authenticated request. +func WithShell(ctx context.Context, admin, banner bool) context.Context { + return context.WithValue(ctx, shellCtxKey{}, shellState{admin: admin, banner: banner}) +} + +// ShellFromContext returns the shell state the door attached; the zero value +// (no nav decoration, no banner) for an anonymous screen or an untouched test. +func ShellFromContext(ctx context.Context) (admin, banner bool) { + s, _ := ctx.Value(shellCtxKey{}).(shellState) + return s.admin, s.banner +} + +// PageFor fills the shell decoration — the role-gated nav and the passkey +// banner — from the request context the door attached, and returns the Page. A +// handler rendering an authenticated page writes: +// +// web.Render(w, http.StatusOK, web.PageFor(r, web.Page{...})) +// +// A guest page (Guest, or no GUID) is returned unchanged: there is no shell +// state to apply. +func PageFor(r *http.Request, p Page) Page { + if r == nil || p.Guest || strings.TrimSpace(p.GUID) == "" { + return p + } + p.Admin, p.PasskeyBanner = ShellFromContext(r.Context()) + return p +} + // ModuleLabel renders a module name as a nav label: the first letter upcased // and separators read as spaces ("home" -> "Home", "time_off" -> "Time off"). func ModuleLabel(name string) string { diff --git a/internal/core/web/static/app.css b/internal/core/web/static/app.css index 795cb49..2759516 100644 --- a/internal/core/web/static/app.css +++ b/internal/core/web/static/app.css @@ -145,6 +145,45 @@ button.secondary { .flash.notice { background: var(--notice-bg); color: var(--notice-ink); } .flash.error { background: var(--error-bg); color: var(--danger); } +/* The post-setup passkey prompt (SPEC-010 §6): prominent, above the content. */ +.banner { + max-width: 42rem; + margin: 1rem auto 0; + padding: 0.75rem 1rem; + border: 1px solid var(--brand); + border-left-width: 4px; + border-radius: 8px; + background: #eef2ff; + color: var(--ink); + font-size: 0.95rem; +} +.banner a { color: var(--brand); font-weight: 600; } + +/* A link styled as a button (Add a passkey — SPEC-010 §4). */ +a.button { + display: inline-block; + font-weight: 600; + padding: 0.55rem 0.9rem; + border: 1px solid var(--brand); + border-radius: 8px; + background: var(--brand); + color: #fff; + text-decoration: none; +} +a.button:hover { background: #1a3fb8; } + +code, .invite { + display: inline-block; + max-width: 100%; + overflow-wrap: anywhere; + padding: 0.2rem 0.4rem; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85rem; +} + #passkey-status { font-size: 0.95rem; color: var(--ink-soft); } #passkey-status.error { color: var(--danger); } diff --git a/internal/core/web/web_test.go b/internal/core/web/web_test.go index b98f5a8..4cd92e9 100644 --- a/internal/core/web/web_test.go +++ b/internal/core/web/web_test.go @@ -132,8 +132,76 @@ func TestShellEscapesAndTrustsOnlyMarkup(t *testing.T) { } } -// ── AC-3: no inline scripts ────────────────────────────────────────────────── +// ── SPEC-010: the role-gated core nav and the passkey banner ───────────────── +// TestShellCoreNavIsRoleGated: Home · Account appear for everyone; Users · +// Modules appear only when the Page marks an admin (SPEC-010 §3). +func TestShellCoreNavIsRoleGated(t *testing.T) { + web.SetNav(fakeNav{"home"}) + t.Cleanup(func() { web.SetNav(nil) }) + + admin := renderShell(t, web.Page{GUID: "g", Active: "home", Admin: true, Content: web.Markup("x")}) + for _, want := range []string{ + `href="/g/home"`, `href="/g/account"`, `href="/g/users"`, `href="/g/core/modules"`, + } { + if !strings.Contains(admin, want) { + t.Errorf("an admin's nav is missing %q:\n%s", want, admin) + } + } + // The discovered `home` module is collapsed onto the core Home entry — the + // nav never lists the same target twice (the brand link also points home, + // so we count the labelled nav entry). + if got := strings.Count(admin, ">Home"); got != 1 { + t.Errorf("the nav lists Home %d times, want 1", got) + } + + member := renderShell(t, web.Page{GUID: "g", Active: "home", Content: web.Markup("x")}) + if !strings.Contains(member, `href="/g/home"`) || !strings.Contains(member, `href="/g/account"`) { + t.Errorf("a non-admin is missing Home/Account:\n%s", member) + } + if strings.Contains(member, `href="/g/users"`) || strings.Contains(member, `href="/g/core/modules"`) { + t.Errorf("a non-admin sees the admin nav entries (SPEC-010 §3):\n%s", member) + } +} + +// TestShellPasskeyBanner: the prominent prompt renders with its account link +// only when the Page asks for it (SPEC-010 §6). +func TestShellPasskeyBanner(t *testing.T) { + web.SetNav(fakeNav{"home"}) + t.Cleanup(func() { web.SetNav(nil) }) + + on := renderShell(t, web.Page{GUID: "g", PasskeyBanner: true, Content: web.Markup("x")}) + if !strings.Contains(on, "You have no passkey") { + t.Errorf("the banner is missing:\n%s", on) + } + if !strings.Contains(on, `href="/g/account"`) { + t.Errorf("the banner does not link to the account page:\n%s", on) + } + + off := renderShell(t, web.Page{GUID: "g", Content: web.Markup("x")}) + if strings.Contains(off, "You have no passkey") { + t.Errorf("the banner rendered without being asked:\n%s", off) + } +} + +// TestPageForReadsTheShellState: PageFor copies the door-attached role + banner +// onto the Page, and leaves a guest page untouched (SPEC-010 §3/§6). +func TestPageForReadsTheShellState(t *testing.T) { + r := httptest.NewRequest("GET", "/g/home", nil) + r = r.WithContext(web.WithShell(r.Context(), true, true)) + + got := web.PageFor(r, web.Page{GUID: "g"}) + if !got.Admin || !got.PasskeyBanner { + t.Errorf("PageFor did not apply the shell state: %+v", got) + } + + guest := web.PageFor(r, web.Page{Guest: true}) + if guest.Admin || guest.PasskeyBanner { + t.Errorf("PageFor decorated a guest page: %+v", guest) + } +} + +// ── AC-3: no inline scripts ────────────────────────────────────────────────── // TestShellHasNoInlineScripts: the layout references same-origin files only. // The CSP is `script-src 'self'` (SPEC-003 §4) — one inline script would force // weakening it, so there must never be one (AC-3). diff --git a/internal/modules/home/handlers.go b/internal/modules/home/handlers.go index f145940..c0a56b8 100644 --- a/internal/modules/home/handlers.go +++ b/internal/modules/home/handlers.go @@ -17,11 +17,11 @@ import ( // the flash area. func dashboard(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { w.Header().Set("Cache-Control", "no-store") - return web.Render(w, http.StatusOK, web.Page{ + return web.Render(w, http.StatusOK, web.PageFor(r, web.Page{ Title: "MyDay", GUID: rc.Workflow, Active: "home", Content: web.Markup(`

MyDay

Your day, at a glance.

`), - }) + })) }