feat(web): role-gate the nav and show the post-setup passkey banner

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).
This commit is contained in:
Claus Lohmar 2026-10-15 07:58:09 +00:00
parent 34f8ddbcfe
commit b8ead2bf2d
9 changed files with 282 additions and 28 deletions

View file

@ -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

View file

@ -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

View file

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

View file

@ -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

View file

@ -19,6 +19,7 @@
{{if .Nav}}<nav aria-label="Main"><ul>{{range .Nav}}<li><a href="{{.Href}}"{{if .Active}} aria-current="page"{{end}}>{{.Label}}</a></li>{{end}}</ul></nav>{{end}}
</header>
{{if .Flash}}<p class="flash {{.FlashKind}}" role="status">{{.Flash}}</p>{{end}}
{{if .PasskeyBanner}}<p class="banner" role="status"><strong>You have no passkey.</strong> <a href="/{{.GUID}}/account">Add one</a> to sign in without the one-time code.</p>{{end}}
<main id="main"{{.MainAttrs}}>
{{.Content}}
</main>

View file

@ -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 {

View file

@ -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); }

View file

@ -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</a>"); 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).

View file

@ -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(`<h1>MyDay</h1>
<p>Your day, at a glance.</p>`),
})
}))
}