NextMyDay/internal/core/auth/flow/render.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

133 lines
5.4 KiB
Go

package flow
import (
"html"
"net/http"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// renderHTML writes a server-rendered page inside the core shell (SPEC-009 §4).
// The door renders no dynamic HTML itself (SPEC-003 §4); these are the flow's
// hand-off targets.
//
// 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`.
//
// 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, web.PageFor(r, page))
}
// enrollPage is the enrollment screen (SPEC-006 §4). The passkey ceremony is
// driven by the same-origin WebAuthn client (SPEC-009 §5) from the data-*
// contract on <main>; the TOTP forms work without JavaScript. Every form
// carries the signed pre-session CSRF token (A15), which the client also reads
// from the shell's <meta name="csrf-token">.
func enrollPage(guid, token string) web.Page {
g := html.EscapeString(guid)
c := html.EscapeString(token)
action := func(name string) string {
return `"/` + g + `?do=auth&amp;action=` + name + `"`
}
return web.Page{
Title: "Enrollment",
GUID: guid,
Guest: true,
CSRF: token,
MainAttrs: web.Attrs(`data-webauthn="enroll"` +
` data-begin=` + action("enroll.begin") +
` data-finish=` + action("enroll.finish")),
Content: web.Markup(`<h1>Enrollment</h1>
<p>Two factors are required: a passkey <strong>and</strong> an authenticator app.</p>
<section aria-label="passkey">
<h2>1. Passkey</h2>
<p id="passkey-status" data-webauthn-status>Register a passkey for this device.</p>
<button type="button" data-webauthn-start>Register a passkey</button>
<noscript><p>Registering a passkey needs JavaScript in the browser. You can still set up your authenticator app below, and add a passkey later at sign-in.</p></noscript>
</section>
<section aria-label="totp">
<h2>2. Authenticator app</h2>
<form method="post" action=` + action("totp.begin") + `>
<input type="hidden" name="csrf" value="` + c + `">
<button type="submit" class="secondary">Set up authenticator</button></form>
<form method="post" action=` + action("totp.verify") + `>
<input type="hidden" name="csrf" value="` + c + `">
<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>
<button type="submit">Verify code</button></form>
</section>`),
}
}
// passkeyPage is the post-login passkey nudge (SPEC-006 §8, D-041). The
// WebAuthn registration is begun server-side and the options are embedded for
// the browser; the single `passkey.add` action completes it. The two skip forms
// work without JavaScript. Every form carries the SESSION-bound CSRF token (a
// session exists here — A15 does not apply).
func passkeyPage(guid, token, options string) web.Page {
g := html.EscapeString(guid)
c := html.EscapeString(token)
action := func(name string) string {
return `"/` + g + `?do=auth&amp;action=` + name + `"`
}
return web.Page{
Title: "Add a passkey",
GUID: guid,
CSRF: token,
Flash: "Skip the one-time code next time by adding a passkey for this device.",
MainAttrs: web.Attrs(`data-webauthn="passkey"` +
` data-finish=` + action("passkey.add") +
` data-options="` + html.EscapeString(options) + `"`),
Content: web.Markup(`<h1>Add a passkey</h1>
<section aria-label="passkey">
<p id="passkey-status" data-webauthn-status>Use this device's screen lock or security key.</p>
<button type="button" data-webauthn-start>Add a passkey</button>
<noscript><p>Adding a passkey needs JavaScript in the browser.</p></noscript>
</section>
<section aria-label="skip">
<h2>Not now?</h2>
<form method="post" action=` + action("passkey.later") + `>
<input type="hidden" name="csrf" value="` + c + `">
<button type="submit" class="secondary">Not now</button></form>
<form method="post" action=` + action("passkey.never") + `>
<input type="hidden" name="csrf" value="` + c + `">
<button type="submit" class="secondary">Don&#39;t ask again</button></form>
</section>`),
}
}
// loginPage is the login screen (SPEC-006 §5): Passkey primary, TOTP fallback.
func loginPage(guid, token string) web.Page {
g := html.EscapeString(guid)
c := html.EscapeString(token)
action := func(name string) string {
return `"/` + g + `?do=auth&amp;action=` + name + `"`
}
return web.Page{
Title: "Sign in",
GUID: guid,
Guest: true,
CSRF: token,
MainAttrs: web.Attrs(`data-webauthn="login"` +
` data-begin=` + action("login.begin") +
` data-finish=` + action("login.finish")),
Content: web.Markup(`<h1>Sign in</h1>
<p>Use a passkey for this device, or your authenticator code.</p>
<section aria-label="passkey">
<h2>Passkey</h2>
<p id="passkey-status" data-webauthn-status>Sign in with a passkey.</p>
<button type="button" data-webauthn-start>Use a passkey</button>
<noscript><p>Passkey sign-in needs JavaScript in the browser. Use your authenticator code below.</p></noscript>
</section>
<section aria-label="totp">
<h2>Authenticator code</h2>
<form method="post" action=` + action("totp.verify") + `>
<input type="hidden" name="csrf" value="` + c + `">
<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>
<button type="submit">Use an authenticator code</button></form>
</section>`),
}
}