NextMyDay/internal/core/web/shell.go
Claus Lohmar b8ead2bf2d 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).
2026-10-15 07:58:09 +00:00

277 lines
9.2 KiB
Go

package web
import (
"bytes"
"context"
_ "embed"
"html/template"
"net/http"
"strings"
"sync"
)
//go:embed layout.html
var layoutHTML string
// layout is the ONE base layout (SPEC-009 §4). A parse failure of a constant is
// impossible; a non-nil error here would be a programming fault.
var layout = template.Must(template.New("layout").Parse(layoutHTML))
// NavLister is the shell's navigation seam (SPEC-008 §7 / SPEC-009 §4): the
// names of the ENABLED modules, in registration order. A disabled module's
// entry is absent (D-038). *module.Registry satisfies it. Declaring the seam
// here — rather than importing the registry — keeps this package a leaf.
type NavLister interface {
Nav() []string
}
var (
navMu sync.RWMutex
navSrc NavLister
)
// SetNav installs the process-wide navigation source. The entrypoint calls it
// once at startup with the runtime module registry — the same single, explicit
// wiring point as build-time discovery (D-028): the directory set is the source
// of truth, and the shell reads the resulting nav. Until it is set (and in
// tests) pages render without a nav.
func SetNav(l NavLister) {
navMu.Lock()
navSrc = l
navMu.Unlock()
}
// NavSource returns the installed nav source (nil when unset).
func NavSource() NavLister {
navMu.RLock()
defer navMu.RUnlock()
return navSrc
}
// Markup is page body HTML a caller has ALREADY escaped. The shell escapes
// everything it interpolates except this type, so the conversion at the call
// site is deliberate and greppable: escape first, then convert.
type Markup = template.HTML
// Attrs is an escaped attribute set for an element — the WebAuthn client's
// data-* contract lives here. Every value interpolated into it MUST be escaped
// by the caller.
type Attrs = template.HTMLAttr
// NavItem is one rendered navigation entry.
type NavItem struct { // Label is the human label.
Label string
// Href is the target — /{GUID}/{module} inside a GUID context.
Href string
// Active marks the current page (aria-current).
Active bool
}
// Page is one page rendered inside the shell.
type Page struct {
// Title is the document title (defaults to AppName).
Title string
// GUID is the identity riding the path. The nav renders only in a GUID
// context; identity stays in the URL (D-001).
GUID string
// Guest marks an anonymous screen (enroll · login · setup · access): no nav
// — there is no identity yet.
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.
FlashKind string
// CSRF is the token every form — and the WebAuthn client — submits. It is
// rendered once as <meta name="csrf-token">.
CSRF string
// Content is the page body: HTML the caller has ALREADY escaped.
Content template.HTML
// MainAttrs are extra attributes for the <main> element — the WebAuthn
// ceremony's data-* contract. The caller MUST escape them.
MainAttrs template.HTMLAttr
}
// 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
GUID string
PasskeyBanner bool
Content template.HTML
MainAttrs template.HTMLAttr
}
// Render writes a page inside the shell. The caller is responsible for
// Cache-Control (the enroll/login/ceremony screens set `no-store` — F-2); the
// shell sets the content type and the status.
//
// The page is rendered to a buffer first: a template fault never leaves a
// half-written body behind a 200.
func Render(w http.ResponseWriter, status int, p Page) error {
data := layoutData{
AppName: AppName,
Title: strings.TrimSpace(p.Title),
CSRF: p.CSRF,
Flash: p.Flash,
FlashKind: flashKind(p.FlashKind),
StaticPath: StaticPath,
Content: p.Content,
MainAttrs: p.MainAttrs,
}
if data.Title == "" {
data.Title = AppName
}
if p.Guest || strings.TrimSpace(p.GUID) == "" {
data.HomeHref = "/"
} else {
data.HomeHref = "/" + p.GUID + "/home"
data.GUID = p.GUID
data.PasskeyBanner = p.PasskeyBanner
data.Nav = navItems(p.GUID, p.Active, p.Admin)
}
var buf bytes.Buffer
if err := layout.Execute(&buf, data); err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err := w.Write(buf.Bytes())
return err
}
// flashKind normalises a banner kind for the class attribute.
func flashKind(kind string) string {
if strings.EqualFold(strings.TrimSpace(kind), "error") {
return "error"
}
return "notice"
}
// 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
}
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: 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 {
s := strings.TrimSpace(name)
if s == "" {
return ""
}
s = strings.NewReplacer("-", " ", "_", " ").Replace(s)
return strings.ToUpper(s[:1]) + s[1:]
}