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).
277 lines
9.2 KiB
Go
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:]
|
|
}
|