NextMyDay/internal/core/module/page.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

326 lines
10 KiB
Go

package module
import (
"bytes"
"context"
"errors"
"html/template"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// The Modules page is a CORE page (D-038), not a module: it lists the
// discovered modules and enables/disables them. It rides the door under a
// dedicated core dispatch module so the door still gates it (session + role +
// CSRF) exactly like any other function.
const (
// ConsoleModule is the `do`/PARAM2 module carrying the core Modules page.
ConsoleModule = "core"
// FunctionModules is the path function (GET /{GUID}/core/modules).
FunctionModules = "modules"
// FunctionToggle is the action function
// (POST /{GUID}?do=core&action=modules.toggle).
FunctionToggle = "modules.toggle"
)
// Outcome notices the toggle reports.
const (
noticeSaved = "saved_restart_required"
noticeEditAndRestart = "edit_and_restart"
)
// PageDeps assembles the Modules page.
type PageDeps struct {
// Registry is the runtime module registry (list + required flags).
Registry *Registry
// Config is the loaded configuration; its `modules:` map is the desired
// state the page edits (D-035/D-038).
Config *config.Config
// ConfigPath is where the toggles are persisted (usually config.yaml,
// never the committed example).
ConfigPath string
// WriteFile writes the config (tests inject a failing writer to exercise
// the "edit and restart" path). Defaults to os.WriteFile 0600.
WriteFile func(path string, data []byte) error
// Audit receives the enable/disable events (A11, AC-6).
Audit audit.Audit
// Logger receives structured logs. Defaults to slog.Default().
Logger *slog.Logger
}
// Page renders and toggles the core Modules page.
type Page struct {
d PageDeps
mu sync.Mutex // guards the config map read/modify/write
}
// NewPage validates and builds the Modules page.
func NewPage(d PageDeps) (*Page, error) {
if d.Registry == nil {
return nil, errors.New("module: Modules page requires a Registry")
}
if d.Config == nil {
return nil, errors.New("module: Modules page requires a Config")
}
if strings.TrimSpace(d.ConfigPath) == "" {
return nil, errors.New("module: Modules page requires a ConfigPath")
}
if d.WriteFile == nil {
d.WriteFile = func(path string, data []byte) error { return os.WriteFile(path, data, 0o600) }
}
if d.Logger == nil {
d.Logger = slog.Default()
}
return &Page{d: d}, nil
}
// Register mounts the page into the door registry: the list is a PATH route
// (GET /{GUID}/core/modules) and the toggle is an ACTION
// (POST /{GUID}?do=core&action=modules.toggle), which the door CSRF-protects.
//
// The caller must require the admin role for both (server.Tiers); the page does
// not decide its own access control.
func (p *Page) Register(reg *server.MapRegistry) {
if reg == nil {
return
}
reg.RegisterModule(server.NewModule(ConsoleModule).
AddRoute(FunctionModules, FunctionModules, p.List).
AddAction(FunctionToggle, FunctionToggle, p.Toggle))
}
// List renders the Modules page (GET /{GUID}/core/modules).
func (p *Page) List(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error {
return p.render(w, r, rc, "", "")
}
// Toggle saves a module's enabled state to the config
// (POST /{GUID}?do=core&action=modules.toggle). A required module is refused
// (AC-4); an unwritable config reports "edit and restart" (D-035). The change
// is applied at the NEXT start (D-038: no live remount).
func (p *Page) Toggle(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error {
ctx := r.Context()
name := strings.TrimSpace(r.FormValue("module"))
if name == "" {
p.record(ctx, "module.toggle", "", "bad_request")
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
m, ok := p.d.Registry.Get(name)
if !ok {
p.record(ctx, "module.toggle", name, "unknown")
writeError(w, http.StatusNotFound, "not found")
return nil
}
on := parseEnabled(r.FormValue("enabled"))
if !on && m.Required() {
p.record(ctx, "module.toggle", name, "refused_required")
writeError(w, http.StatusForbidden, "required modules cannot be disabled")
return nil
}
notice, err := p.save(name, on)
if err != nil {
return err
}
p.record(ctx, auditAction(on), name, notice)
return p.render(w, r, rc, notice, name)
}
// save writes the desired state to the config file. It returns a human notice
// describing the outcome. A marshal failure is returned (500); a write failure
// is reported as "edit and restart" and is NOT fatal (D-035).
func (p *Page) save(name string, on bool) (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
c := *p.d.Config
if c.Modules == nil {
c.Modules = make(map[string]bool)
}
// c.Modules aliases the live config's map, so consecutive toggles compose
// instead of clobbering one another. The mounted (running) registry is
// unaffected: it was latched at startup (D-038).
c.Modules[name] = on
data, err := config.Marshal(&c)
if err != nil {
return "", err
}
if err := p.d.WriteFile(p.d.ConfigPath, data); err != nil {
p.d.Logger.Warn("module: config file is not writable — edit and restart",
"path", p.d.ConfigPath, "error", err)
return noticeEditAndRestart, nil
}
return noticeSaved, nil
}
// render draws the page inside the core shell (SPEC-009 §4/§7). notice names an
// outcome banner ("" for none).
func (p *Page) render(w http.ResponseWriter, r *http.Request, rc *server.RequestContext, notice, changed string) error {
p.mu.Lock()
rows := p.rowsLocked()
restart := false
for _, row := range rows {
if row.Wanted != row.Enabled {
restart = true
break
}
}
p.mu.Unlock()
data := pageData{
Rows: rows,
Token: httpx.TokenFromRequest(r),
NoticeText: noticeText(notice, changed),
RestartNeeded: restart,
}
var body bytes.Buffer
if err := modulesTemplate.Execute(&body, data); err != nil {
return err
}
w.Header().Set("Cache-Control", "no-store")
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.
func (p *Page) rowsLocked() []row {
out := make([]row, 0, len(p.d.Registry.Modules()))
for _, m := range p.d.Registry.Modules() {
name := m.Name()
wanted := true
if !m.Required() {
if v, ok := p.d.Config.Modules[name]; ok {
wanted = v
}
}
out = append(out, row{
Name: name,
Kind: KindOf(m),
Required: m.Required(),
Enabled: p.d.Registry.Enabled(name),
Wanted: wanted,
Locked: m.Required(),
})
}
return out
}
// record writes one audit row for a toggle (A11, AC-6). The door attached the
// actor (the admin) to the context.
func (p *Page) record(ctx context.Context, action, name, outcome string) {
if p.d.Audit == nil {
return
}
p.d.Audit.Record(ctx, audit.Event{
Action: action,
Subject: "module:" + name,
Detail: outcome,
})
}
// auditAction names the audit event for an enable/disable (A11).
func auditAction(on bool) string {
if on {
return "module.enable"
}
return "module.disable"
}
// parseEnabled reads a form's desired state. Absent/unknown means OFF (the
// safest default for a disable gate).
func parseEnabled(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "on", "yes", "enable", "enabled":
return true
}
return false
}
// noticeText renders the outcome banner.
func noticeText(notice, changed string) string {
switch notice {
case noticeSaved:
return "Saved " + changed + " to config.yaml. Restart MyDay to apply the change."
case noticeEditAndRestart:
return "Could not write config.yaml. Edit it by hand and restart MyDay to apply the change."
}
return ""
}
// writeError writes a terse plain-text error response (matching the door's).
// It is deliberately void: a handler that has already written its response must
// return nil, or the door would append a second (500) response on a write error.
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_, _ = w.Write([]byte(msg + "\n"))
}
// pageData is the template's input.
type pageData struct {
Rows []row
Token string
NoticeText string
RestartNeeded bool
}
// row is one module's line on the page.
type row struct {
Name string
Kind string
Required bool
Enabled bool // running (mounted now)
Wanted bool // desired per config (applies at restart)
Locked bool
}
// modulesTemplateHTML is the page BODY only: the shell (SPEC-009 §4) owns the
// document, the header and the nav; this is the content slot.
const modulesTemplateHTML = `<h1>Modules</h1>
{{if .NoticeText}}<p class="notice">{{.NoticeText}}</p>{{end}}
{{if .RestartNeeded}}<p class="restart">A change is pending — restart MyDay to apply it.</p>{{end}}
<table>
<thead><tr><th>Module</th><th>Kind</th><th>Required</th><th>Enabled</th><th></th></tr></thead>
<tbody>
{{range .Rows}}<tr>
<td>{{.Name}}</td>
<td>{{.Kind}}</td>
<td>{{if .Required}}yes{{else}}no{{end}}</td>
<td>{{if .Enabled}}yes{{else}}no{{end}}{{if ne .Enabled .Wanted}} (wanted: {{if .Wanted}}yes{{else}}no{{end}}){{end}}</td>
<td>
{{if .Locked}}<span class="locked">locked</span>
{{else}}<form method="post" action="?do=core&amp;action=modules.toggle">
<input type="hidden" name="csrf" value="{{$.Token}}">
<input type="hidden" name="module" value="{{.Name}}">
{{if .Wanted}}<button name="enabled" value="false">Disable</button>
{{else}}<button name="enabled" value="true">Enable</button>{{end}}
</form>{{end}}
</td>
</tr>
{{end}}</tbody>
</table>
`
// modulesTemplate is parsed once; a parse failure is impossible for a constant.
var modulesTemplate = template.Must(template.New("modules").Parse(modulesTemplateHTML))