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}}
You have no passkey.Add one to sign in without the one-time code.
{{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(`