From e173d2c3fd2c0e2a9c5f89888e6654ad55a3a090 Mon Sep 17 00:00:00 2001 From: ShyneTurtle <41578217+ShyneTurtle@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:22 +0200 Subject: [PATCH 1/5] Add buildinfo, icon set, and shared shell packages --- internal/appshell/appshell.go | 10 ++++++ internal/appshell/shell.html | 49 +++++++++++++++++++++++++++++ internal/buildinfo/version.go | 9 ++++++ internal/uiicons/icons.go | 58 +++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 internal/appshell/appshell.go create mode 100644 internal/appshell/shell.html create mode 100644 internal/buildinfo/version.go create mode 100644 internal/uiicons/icons.go diff --git a/internal/appshell/appshell.go b/internal/appshell/appshell.go new file mode 100644 index 0000000..730933f --- /dev/null +++ b/internal/appshell/appshell.go @@ -0,0 +1,10 @@ +// Package appshell embeds the single shared page shell (tab bar + card) +// used by both the account page (internal/web) and every /admin page +// (internal/admin), so there is exactly one file to edit for both instead +// of two independently-maintained copies that inevitably drift apart. +package appshell + +import "embed" + +//go:embed shell.html +var FS embed.FS diff --git a/internal/appshell/shell.html b/internal/appshell/shell.html new file mode 100644 index 0000000..aa3d08a --- /dev/null +++ b/internal/appshell/shell.html @@ -0,0 +1,49 @@ +{{/* shell.html is the ONE post-login page shell -- shared verbatim by + internal/web (the account page) and internal/admin (every /admin + page) by embedding this same file, instead of each package keeping + its own near-identical copy that drifts out of sync every time only + one of them gets edited. Both packages parse this into their template + tree under the name "layout" alongside their own page-specific + content template, so {{template "content" .}} below resolves to + whichever page is being rendered. + + Both packages' page-data types must expose the same fields this file + reads: Title, Active ("account", "admin", or "config"), IsAdmin, and + ConfigEditorEnabled. The account page sets IsAdmin from the viewer's + actual group membership (hiding the Admin/Config editor tabs for a + non-admin); every /admin page hardcodes IsAdmin true since reaching + one at all already required admin-group membership (see + admin.Handlers.gate). */}} +{{define "layout"}} + + + + + {{.Title}} - DeclarativeAuth + + + +
+
+
+ {{icon "user"}}My account + {{if .IsAdmin}} + {{icon "settings"}}Admin + {{if .ConfigEditorEnabled}} + {{icon "file"}}Config editor + {{else}} + {{icon "file"}}Config editor + {{end}} + {{end}} + {{icon "logout"}}Log out +
+
+ {{template "content" .}} +
+
+
+ + + + +{{end}} diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go new file mode 100644 index 0000000..21f4c9f --- /dev/null +++ b/internal/buildinfo/version.go @@ -0,0 +1,9 @@ +// Package buildinfo holds the version string embedded at build time. +package buildinfo + +// Version is overridden at build time via: +// +// -ldflags="-X declarativeauth/internal/buildinfo.Version=v1.2.3" +// +// See deploy/docker/Dockerfile. Left as "dev" for local `go build`/`go run`. +var Version = "dev" diff --git a/internal/uiicons/icons.go b/internal/uiicons/icons.go new file mode 100644 index 0000000..ed1ce81 --- /dev/null +++ b/internal/uiicons/icons.go @@ -0,0 +1,58 @@ +// Package uiicons is a tiny, dependency-free inline-SVG icon set shared by +// the web and admin templates. No icon font or external SVG sprite is +// vendored -- each icon is a short hand-authored path so the binary and +// page weight stay effectively unchanged while giving the previously +// text-only UI some visual structure. +package uiicons + +import "html/template" + +const svgHeader = ``) +} + +// FuncMap is the html/template.FuncMap entry to expose SVG as {{icon "name"}}. +func FuncMap() map[string]any { + return map[string]any{"icon": SVG} +} From 3405d11521d15eda196d723efb84f97b7831f014 Mon Sep 17 00:00:00 2001 From: ShyneTurtle <41578217+ShyneTurtle@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:38 +0200 Subject: [PATCH 2/5] Overhaul admin panel: merged pages, dynamic config editor, redesigned group graph --- internal/admin/configeditor.go | 232 +++---- internal/admin/graph.go | 715 +++++++++++++++++--- internal/admin/groupdetails.go | 171 +++++ internal/admin/handlers.go | 72 +- internal/admin/smtptest.go | 24 +- internal/admin/templates/admin_home.html | 143 ++++ internal/admin/templates/config_editor.html | 86 +-- internal/admin/templates/dashboard.html | 14 - internal/admin/templates/graph.html | 6 - internal/admin/templates/layout.html | 67 -- internal/admin/templates/smtp_test.html | 14 - internal/config/loader.go | 66 ++ test/integration/admin_flow_test.go | 10 +- 13 files changed, 1249 insertions(+), 371 deletions(-) create mode 100644 internal/admin/groupdetails.go create mode 100644 internal/admin/templates/admin_home.html delete mode 100644 internal/admin/templates/dashboard.html delete mode 100644 internal/admin/templates/graph.html delete mode 100644 internal/admin/templates/layout.html delete mode 100644 internal/admin/templates/smtp_test.html diff --git a/internal/admin/configeditor.go b/internal/admin/configeditor.go index 3df1a66..1544a1a 100644 --- a/internal/admin/configeditor.go +++ b/internal/admin/configeditor.go @@ -13,24 +13,9 @@ import ( "declarativeauth/internal/web" ) -// fileNameFor maps a URL fileKey ("users"|"groups"|"oidc-clients") to its -// on-disk file name under h.IdentityPath. -func fileNameFor(fileKey string) (string, bool) { - switch fileKey { - case "users": - return "users.yaml", true - case "groups": - return "groups.yaml", true - case "oidc-clients": - return "oidc-clients.yaml", true - default: - return "", false - } -} - -// validateEdit re-validates fileKey's proposed content against everything +// validateEdit re-validates relPath's proposed content against everything // else currently in the identity directory, by copying the *entire* real -// identity tree into a scratch directory, overlaying fileKey's proposed +// identity tree into a scratch directory, overlaying relPath's proposed // content on top, and running it through the exact same // internal/config.LoadIdentity path used at startup/reload/CLI // validate-config -- no separate/divergent validation logic to keep in @@ -40,7 +25,7 @@ func fileNameFor(fileKey string) (string, bool) { // passwordHashFile reference needs the same directory layout during // live-validation as it has for the real running server, or it would // report a spurious "file not found". -func (h *Handlers) validateEdit(fileKey, content string) error { +func (h *Handlers) validateEdit(relPath, content string) error { tmpDir, err := os.MkdirTemp("", "declarativeauth-edit-*") if err != nil { return err @@ -51,8 +36,11 @@ func (h *Handlers) validateEdit(fileKey, content string) error { return fmt.Errorf("preparing validation scratch copy: %w", err) } - name, _ := fileNameFor(fileKey) - if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil { + target, err := config.ResolveIdentityFile(tmpDir, relPath) + if err != nil { + return err + } + if err := os.WriteFile(target, []byte(content), 0o644); err != nil { return err } @@ -85,9 +73,9 @@ func copyDirTree(src, dst string) error { type configEditorData struct { pageData - FileKey string - FileKeyJS template.JS - FileName string + Files []config.IdentityFile + FilePath string + FilePathJS template.JS Content string CSRFToken string CSRFTokenJS template.JS @@ -95,47 +83,78 @@ type configEditorData struct { Success bool } -func (h *Handlers) handleConfigEditorUsers(w http.ResponseWriter, r *http.Request, username string) { - h.renderEditor(w, r, "users") -} - -func (h *Handlers) handleConfigEditorGroups(w http.ResponseWriter, r *http.Request, username string) { - h.renderEditor(w, r, "groups") +// knownFile reports whether relPath is one of the files ListIdentityFiles +// actually discovered -- i.e. the operator is editing/saving/downloading a +// real declarative config file, not an arbitrary path that merely resolves +// somewhere inside the identity directory. +func knownFile(files []config.IdentityFile, relPath string) bool { + for _, f := range files { + if f.Path == relPath { + return true + } + } + return false } -func (h *Handlers) handleConfigEditorOIDCClients(w http.ResponseWriter, r *http.Request, username string) { - h.renderEditor(w, r, "oidc-clients") +// handleConfigIndex serves /admin/config: it has no file of its own to +// show, so it redirects to whichever declarative config file sorts first +// -- there's no more fixed "users.yaml" to default to now that files are +// discovered dynamically (see config.ListIdentityFiles), same as +// LoadIdentity itself no longer assumes any particular file name exists. +func (h *Handlers) handleConfigIndex(w http.ResponseWriter, r *http.Request, username string) { + files, err := config.ListIdentityFiles(h.IdentityPath) + if err != nil { + http.Error(w, "listing config files: "+err.Error(), http.StatusInternalServerError) + return + } + if len(files) == 0 { + h.renderEditor(w, r, "", files, "", "", false) + return + } + http.Redirect(w, r, "/admin/config/edit/"+files[0].Path, http.StatusSeeOther) } -// oidcClientsStarter is shown when oidc-clients.yaml doesn't exist on disk -// yet (it's optional -- see internal/config.LoadIdentity), so the editor -// has something valid to start from instead of a 500. -const oidcClientsStarter = `apiVersion: declarativeauth.io/v1 -kind: OIDCClientList -clients: [] -` - -func (h *Handlers) renderEditor(w http.ResponseWriter, r *http.Request, fileKey string) { - name, _ := fileNameFor(fileKey) - body, err := os.ReadFile(filepath.Join(h.IdentityPath, name)) +func (h *Handlers) handleConfigEdit(w http.ResponseWriter, r *http.Request, username string) { + relPath := r.PathValue("file") + files, err := config.ListIdentityFiles(h.IdentityPath) if err != nil { - if fileKey == "oidc-clients" && os.IsNotExist(err) { - body = []byte(oidcClientsStarter) - } else { - http.Error(w, "reading "+name+": "+err.Error(), http.StatusInternalServerError) - return - } + http.Error(w, "listing config files: "+err.Error(), http.StatusInternalServerError) + return + } + if !knownFile(files, relPath) { + http.NotFound(w, r) + return } + target, err := config.ResolveIdentityFile(h.IdentityPath, relPath) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + body, err := os.ReadFile(target) + if err != nil { + http.Error(w, "reading "+relPath+": "+err.Error(), http.StatusInternalServerError) + return + } + h.renderEditor(w, r, relPath, files, string(body), "", false) +} + +func (h *Handlers) renderEditor(w http.ResponseWriter, r *http.Request, relPath string, files []config.IdentityFile, content, errMsg string, success bool) { secure := h.TrustedProxies.IsForwardedHTTPS(r) csrf := web.IssueCSRFToken(w, r, secure) + title := "Config editor" + if relPath != "" { + title = "Config editor: " + relPath + } render(w, configEditorTmpl, configEditorData{ - pageData: pageData{Title: "Config editor: " + name, ConfigEditorEnabled: h.ConfigEditorEnabled}, - FileKey: fileKey, - FileKeyJS: jsString(fileKey), - FileName: name, - Content: string(body), + pageData: newPageData(title, "config", h.ConfigEditorEnabled), + Files: files, + FilePath: relPath, + FilePathJS: jsString(relPath), + Content: content, CSRFToken: csrf, CSRFTokenJS: jsString(csrf), + Error: errMsg, + Success: success, }) } @@ -144,13 +163,14 @@ func (h *Handlers) handleConfigValidate(w http.ResponseWriter, r *http.Request, w.WriteHeader(http.StatusForbidden) return } - fileKey := r.FormValue("fileKey") - if _, ok := fileNameFor(fileKey); !ok { + relPath := r.FormValue("file") + files, err := config.ListIdentityFiles(h.IdentityPath) + if err != nil || !knownFile(files, relPath) { w.WriteHeader(http.StatusBadRequest) return } content := r.FormValue("content") - err := h.validateEdit(fileKey, content) + err = h.validateEdit(relPath, content) w.Header().Set("Content-Type", "application/json") if err != nil { @@ -160,85 +180,69 @@ func (h *Handlers) handleConfigValidate(w http.ResponseWriter, r *http.Request, _ = json.NewEncoder(w).Encode(map[string]any{"valid": true}) } -func (h *Handlers) handleConfigSaveUsers(w http.ResponseWriter, r *http.Request, username string) { - h.handleConfigSave(w, r, "users") -} - -func (h *Handlers) handleConfigSaveGroups(w http.ResponseWriter, r *http.Request, username string) { - h.handleConfigSave(w, r, "groups") -} - -func (h *Handlers) handleConfigSaveOIDCClients(w http.ResponseWriter, r *http.Request, username string) { - h.handleConfigSave(w, r, "oidc-clients") -} - -func (h *Handlers) handleConfigSave(w http.ResponseWriter, r *http.Request, fileKey string) { +func (h *Handlers) handleConfigSave(w http.ResponseWriter, r *http.Request, username string) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } - name, _ := fileNameFor(fileKey) + relPath := r.PathValue("file") content := r.FormValue("content") - secure := h.TrustedProxies.IsForwardedHTTPS(r) - data := configEditorData{ - pageData: pageData{Title: "Config editor: " + name, ConfigEditorEnabled: h.ConfigEditorEnabled}, - FileKey: fileKey, FileKeyJS: jsString(fileKey), FileName: name, Content: content, + files, err := config.ListIdentityFiles(h.IdentityPath) + if err != nil { + http.Error(w, "listing config files: "+err.Error(), http.StatusInternalServerError) + return } + if !knownFile(files, relPath) { + http.NotFound(w, r) + return + } + if !web.ValidCSRF(r) { - data.Error = "Your session expired, please try again." - data.CSRFToken = web.IssueCSRFToken(w, r, secure) - data.CSRFTokenJS = jsString(data.CSRFToken) - render(w, configEditorTmpl, data) + h.renderEditor(w, r, relPath, files, content, "Your session expired, please try again.", false) return } - if err := h.validateEdit(fileKey, content); err != nil { - data.Error = "Not saved -- validation failed: " + err.Error() - data.CSRFToken = web.IssueCSRFToken(w, r, secure) - data.CSRFTokenJS = jsString(data.CSRFToken) - render(w, configEditorTmpl, data) + if err := h.validateEdit(relPath, content); err != nil { + h.renderEditor(w, r, relPath, files, content, "Not saved -- validation failed: "+err.Error(), false) + return + } + target, err := config.ResolveIdentityFile(h.IdentityPath, relPath) + if err != nil { + h.renderEditor(w, r, relPath, files, content, "Invalid file path: "+err.Error(), false) return } // Write directly to the mounted identity file; the existing // fsnotify-based hot-reload watcher picks this up as a normal file // write, same as any external ConfigMap update. - if err := os.WriteFile(filepath.Join(h.IdentityPath, name), []byte(content), 0o644); err != nil { - data.Error = "Failed to write file: " + err.Error() - data.CSRFToken = web.IssueCSRFToken(w, r, secure) - data.CSRFTokenJS = jsString(data.CSRFToken) - render(w, configEditorTmpl, data) + if err := os.WriteFile(target, []byte(content), 0o644); err != nil { + h.renderEditor(w, r, relPath, files, content, "Failed to write file: "+err.Error(), false) return } - data.Success = true - data.CSRFToken = web.IssueCSRFToken(w, r, secure) - data.CSRFTokenJS = jsString(data.CSRFToken) - render(w, configEditorTmpl, data) -} - -func (h *Handlers) handleConfigDownloadUsers(w http.ResponseWriter, r *http.Request, username string) { - h.handleConfigDownload(w, r, "users") + h.renderEditor(w, r, relPath, files, content, "", true) } -func (h *Handlers) handleConfigDownloadGroups(w http.ResponseWriter, r *http.Request, username string) { - h.handleConfigDownload(w, r, "groups") -} - -func (h *Handlers) handleConfigDownloadOIDCClients(w http.ResponseWriter, r *http.Request, username string) { - h.handleConfigDownload(w, r, "oidc-clients") -} - -func (h *Handlers) handleConfigDownload(w http.ResponseWriter, r *http.Request, fileKey string) { - name, _ := fileNameFor(fileKey) - body, err := os.ReadFile(filepath.Join(h.IdentityPath, name)) +func (h *Handlers) handleConfigDownload(w http.ResponseWriter, r *http.Request, username string) { + relPath := r.PathValue("file") + files, err := config.ListIdentityFiles(h.IdentityPath) if err != nil { - if fileKey == "oidc-clients" && os.IsNotExist(err) { - body = []byte(oidcClientsStarter) - } else { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + http.Error(w, "listing config files: "+err.Error(), http.StatusInternalServerError) + return + } + if !knownFile(files, relPath) { + http.NotFound(w, r) + return + } + target, err := config.ResolveIdentityFile(h.IdentityPath, relPath) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + body, err := os.ReadFile(target) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) + w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(relPath)+`"`) w.Header().Set("Content-Type", "application/x-yaml") _, _ = w.Write(body) } diff --git a/internal/admin/graph.go b/internal/admin/graph.go index e72e23a..005cc02 100644 --- a/internal/admin/graph.go +++ b/internal/admin/graph.go @@ -1,124 +1,685 @@ package admin import ( + "encoding/json" "fmt" "html/template" - "net/http" + "math" "sort" "declarativeauth/internal/identity" ) -type graphData struct { - pageData +// graphViewData is the subset of adminHomeData the group graph section +// needs; computeGraphData is shared by handleAdmin (the merged email +// test + graph page) so there's exactly one place that turns a snapshot +// into graph SVG + counts. +type graphViewData struct { GroupCount int EdgeCount int SVG template.HTML + // DetailsJSON is every group's computeGroupDetails, keyed by name, as a + // single embedded JSON blob -- see admin_home.html's click handler. + // Embedding it avoids a second request just to inspect a group that's + // already fully described by the snapshot the page was rendered from. + DetailsJSON template.JS } -func (h *Handlers) handleGraph(w http.ResponseWriter, r *http.Request, username string) { - snap := h.Snapshot() +func computeGraphData(snap *identity.Snapshot) graphViewData { edgeCount := 0 for _, g := range snap.Groups { edgeCount += len(g.MemberOfGroups) } - render(w, graphTmpl, graphData{ - pageData: pageData{Title: "Group graph", ConfigEditorEnabled: h.ConfigEditorEnabled}, - GroupCount: len(snap.Groups), - EdgeCount: edgeCount, - SVG: template.HTML(renderGroupGraphSVG(snap.Groups)), - }) + memberCounts := make(map[string]int, len(snap.Groups)) + for name := range snap.Groups { + memberCounts[name] = len(snap.FlattenedMembers[name]) + } + detailsJSON, _ := json.Marshal(computeGroupDetails(snap)) + return graphViewData{ + GroupCount: len(snap.Groups), + EdgeCount: edgeCount, + SVG: template.HTML(renderGroupGraphSVG(snap.Groups, memberCounts)), + DetailsJSON: template.JS(detailsJSON), + } } -// renderGroupGraphSVG lays groups out in levels by inheritance depth (root -// groups with no parents at the top, deeper members below) and renders a -// plain, dependency-free inline SVG -- no client-side graph library is -// vendored, keeping the admin page (and the binary) lightweight and fully -// offline-capable. -func renderGroupGraphSVG(groups map[string]identity.Group) string { - levels := map[string]int{} - var level func(name string, seen map[string]bool) int - level = func(name string, seen map[string]bool) int { - if v, ok := levels[name]; ok { - return v - } - if seen[name] { // defensive: cycles are rejected at config-load time - return 0 - } - seen[name] = true - g, ok := groups[name] - if !ok || len(g.MemberOfGroups) == 0 { - levels[name] = 0 - return 0 - } - max := 0 - for _, p := range g.MemberOfGroups { - if lv := level(p, seen); lv > max { - max = lv - } - } - levels[name] = max + 1 - return max + 1 - } +type graphNode struct { + name string + x, y float64 + w, h float64 + memberCount int + requireMFA bool + // degree is direct parents + direct children -- used as this node's + // "importance" for the centering gravity in forceDirectedLayout (see + // there) so well-connected hub groups end up nearer the middle of the + // graph and peripheral ones settle further out, rather than everything + // being laid out in strict top-to-bottom hierarchy rows. + degree int +} +// renderGroupGraphSVG lays groups out with a 2D force-directed (Fruchterman- +// Reingold style) simulation and renders a plain, dependency-free inline +// SVG -- no client-side graph library is vendored, keeping the admin page +// (and the binary) lightweight and fully offline-capable. +// +// This is deliberately NOT a layered top-to-bottom hierarchy diagram (an +// earlier version fixed every node's y by root/leaf depth, Sugiyama-style). +// That produced a graph that visibly read as a stack of rows, and rows +// don't actually help avoid collisions or crossings: a row with many +// members still has to cram them all into one horizontal band regardless +// of how much vertical room is sitting unused above and below it. Letting +// both axes move freely lets crowded areas actually spread into whichever +// direction has space, and a more central rank/gravity per node (see +// forceDirectedLayout) gives well-connected groups a stable center to +// radiate around instead of forcing an artificial top/bottom reading +// order. Edges are still routed to curve around anything in the way (see +// routeEdges) and drawn *after* every node, so an arrow is never hidden +// behind a card it merely passes close to. +func renderGroupGraphSVG(groups map[string]identity.Group, memberCounts map[string]int) string { names := make([]string, 0, len(groups)) for n := range groups { names = append(names, n) } sort.Strings(names) + if len(names) == 0 { + return `` + } + + children := map[string][]string{} // group -> direct child groups + for name, g := range groups { + for _, parent := range g.MemberOfGroups { + children[parent] = append(children[parent], name) + } + } + + nodes := make(map[string]*graphNode, len(names)) + // Taller than a single line of text to fit the member-count line + // underneath the group name (see the second below). + const nodeH = 56 + for _, n := range names { + w := float64(len(n))*7.5 + 48 + if w < 120 { + w = 120 + } + nodes[n] = &graphNode{ + name: n, + w: w, + h: nodeH, + memberCount: memberCounts[n], + requireMFA: groups[n].RequireMFA, + degree: len(groups[n].MemberOfGroups) + len(children[n]), + } + } + + radius := seedCircular(nodes, names) + forceDirectedLayout(nodes, names, groups, radius) + resolveOverlaps(nodes, names) + + minX, minY, maxX, maxY := boundingBox(nodes) + const margin = 40 + offX, offY := margin-minX, margin-minY + width := int(math.Ceil(maxX-minX)) + 2*margin + height := int(math.Ceil(maxY-minY)) + 2*margin + + // Bake the margin translation into the node positions themselves, + // once, before either loop below reads them -- both the rects and the + // edges (which clip to each node's border via its x/y/w/h) need to + // agree on where a node actually is. Applying the offset only when + // drawing rects (and not when computing edges from the same nodes) is + // exactly what previously made every arrow land tens of pixels off + // from the node it's supposed to touch. for _, n := range names { - level(n, map[string]bool{}) + nodes[n].x += offX + nodes[n].y += offY } - byLevel := map[int][]string{} - maxLevel := 0 + var svg fmtBuilder + svg.printf(``, width, height, width, height) + // Two markers, not one: hovering a node (see admin_home.html's JS) + // swaps its direct edges' marker-end to "arrow-active" so the + // arrowhead lights up along with the line -- a marker is referenced by + // id, not styled by the edge's own class, so highlighting the stroke + // alone would leave a mismatched-color arrowhead. + svg.WriteString(`` + + `` + + `` + + ``) + + // Nodes first, edges on top -- see the doc comment above for why. Each + // node is wrapped in a so + // plain client-side JS (see admin_home.html) can turn a click into + // "show this group's members/ancestry panel" without a graph library. for _, n := range names { - lv := levels[n] - byLevel[lv] = append(byLevel[lv], n) - if lv > maxLevel { - maxLevel = lv + node := nodes[n] + nodeClass := "group-node" + if node.requireMFA { + // A visibly different color for groups that force MFA on their + // members -- a real security-relevant property of the group, + // worth being able to spot in the graph at a glance rather + // than only in the config file. + nodeClass = "group-node-mfa" + } + cx := node.x + node.w/2 + svg.printf(``, template.HTMLEscapeString(n)) + svg.printf(``, nodeClass, node.x, node.y, node.w, node.h) + svg.printf(`%s`, + cx, node.y+node.h/2-2, template.HTMLEscapeString(n)) + member := "members" + if node.memberCount == 1 { + member = "member" } + svg.printf(`%d %s`, + cx, node.y+node.h/2+16, node.memberCount, member) + svg.WriteString(``) } - const colWidth, rowHeight, nodeW, nodeH = 180, 90, 140, 40 - pos := map[string][2]int{} - width := colWidth - for lv := 0; lv <= maxLevel; lv++ { - row := byLevel[lv] - sort.Strings(row) - for i, n := range row { - x := 40 + i*colWidth - y := 40 + lv*rowHeight - pos[n] = [2]int{x, y} - if x+colWidth > width { - width = x + colWidth + routeEdges(names, groups, nodes, &svg) + svg.WriteString(``) + return svg.String() +} + +// seedCircular gives the simulation a deterministic, non-degenerate +// starting point: every node placed around a circle (by sorted name, not +// time/rand, so the same graph always starts the same way), with +// higher-degree ("more important") nodes seeded closer to the center -- +// a head start for forceDirectedLayout's own centering gravity rather than +// something it strictly depends on. Returns the seed radius, used to scale +// the simulation's forces to the graph's actual size. +func seedCircular(nodes map[string]*graphNode, names []string) float64 { + maxDegree := 1 + for _, n := range names { + if d := nodes[n].degree; d > maxDegree { + maxDegree = d + } + } + radius := 90.0 * math.Sqrt(float64(len(names))) + if radius < 220 { + radius = 220 + } + for i, n := range names { + node := nodes[n] + angle := 2 * math.Pi * float64(i) / float64(len(names)) + importance := float64(node.degree) / float64(maxDegree) + r := radius * (1 - 0.6*importance) + node.x = radius + r*math.Cos(angle) + node.y = radius + r*math.Sin(angle) + } + return radius +} + +// forceDirectedLayout runs a 2D Fruchterman-Reingold simulation: every +// pair of nodes repels (so cards spread out and stop overlapping), every +// edge attracts (so related groups stay close), every node is pulled +// towards the graph's center with strength proportional to its own degree +// (see graphNode.degree) so well-connected "hub" groups gravitate to the +// middle and peripheral ones settle further out, and a shrinking +// "temperature" caps how far a node can move per step so the layout +// settles instead of oscillating. +// +// An earlier version fixed y outright from each node's root/leaf depth and +// only ever let this simulation move x -- a layered hierarchy diagram +// rather than a general graph layout. That's what produced the +// "everything in rows" look: a rank with many members had nowhere to +// expand but sideways within its own fixed band, no matter how much +// vertical space sat unused elsewhere. Letting both axes move, with degree +// determining how strongly a node is pulled to the shared center instead +// of a fixed row/column, lets crowded areas actually use whichever +// direction has room. +func forceDirectedLayout(nodes map[string]*graphNode, names []string, groups map[string]identity.Group, radius float64) { + n := len(names) + area := (radius * 2) * (radius * 2) + k := math.Sqrt(area / float64(n)) + centerX, centerY := radius, radius + // Repulsion beyond this range is ignored entirely, so it behaves as + // collision avoidance between nodes that are actually near each other + // rather than a global "everyone pushes everyone apart forever" force + // that would otherwise fight the centering gravity below at every + // distance, no matter how far things have already spread. Wider than a + // simple "just clear of touching" range so that several high-degree + // hubs -- which all get pulled towards the same center point below -- + // still have enough repulsive range to spread out from each other + // before they're close enough for the degree-boosted push (see below) + // to kick in. + repulseCutoff := k * 4 + + maxDegree := 1 + for _, n := range names { + if d := nodes[n].degree; d > maxDegree { + maxDegree = d + } + } + + type edge struct{ a, b string } + var edges []edge + for _, name := range names { + for _, parent := range groups[name].MemberOfGroups { + if _, ok := nodes[parent]; ok { + edges = append(edges, edge{name, parent}) + } + } + } + + const iterations = 400 + temp := k * 2 + for iter := 0; iter < iterations; iter++ { + dispX := make(map[string]float64, n) + dispY := make(map[string]float64, n) + + for i := 0; i < len(names); i++ { + for j := i + 1; j < len(names); j++ { + a, b := nodes[names[i]], nodes[names[j]] + dx, dy := a.x-b.x, a.y-b.y + dist := math.Hypot(dx, dy) + if dist < 0.01 { + dist = 0.01 + dx = 0.01 + } + // Extra separation so wider cards (longer names) don't + // overlap even after repulsion balances out, and don't + // just barely clear each other either -- enough gap to + // read as clearly distinct cards. + minSep := math.Max(a.w, a.h)/2 + math.Max(b.w, b.h)/2 + 26 + if dist > repulseCutoff && dist >= minSep { + continue + } + // Two high-degree hubs both feel a strong pull toward the + // same center point (see the gravity loop below), so + // without something counteracting it they tend to end up + // almost on top of each other -- which then tangles all + // of their many fan-in/fan-out edges into the same small + // area. Boosting repulsion between a pair in proportion to + // their combined degree pushes important hubs apart from + // *each other* specifically, giving their edges room to + // fan out instead of converging on one spot. + degreeBoost := 1.0 + 0.12*float64(a.degree+b.degree) + force := (k * k * degreeBoost) / dist + if dist < minSep { + force += (minSep - dist) * 2 + } + ux, uy := dx/dist, dy/dist + dispX[names[i]] += ux * force + dispY[names[i]] += uy * force + dispX[names[j]] -= ux * force + dispY[names[j]] -= uy * force + } + } + + for _, e := range edges { + a, b := nodes[e.a], nodes[e.b] + dx, dy := a.x-b.x, a.y-b.y + dist := math.Hypot(dx, dy) + if dist < 0.01 { + dist = 0.01 } + force := (dist * dist) / k + ux, uy := dx/dist, dy/dist + dispX[e.a] -= ux * force + dispY[e.a] -= uy * force + dispX[e.b] += ux * force + dispY[e.b] += uy * force + } + + // Centering gravity, scaled by each node's own degree: a + // well-connected hub feels a stronger pull to the middle than a + // peripheral group with only one or two links, which otherwise + // relies on repulsion/edge springs alone to find its resting + // place further out. Every node still gets a small baseline pull + // so a fully isolated group (degree 0) doesn't drift away + // unbounded under pure repulsion. Capped well short of + // overwhelming the degree-boosted repulsion above -- a stronger + // pull here was letting several different hubs all converge on + // essentially the same point, which is its own kind of clutter + // (every one of their edges then tangles in that one spot) even + // though no two cards actually overlapped. + for _, name := range names { + importance := float64(nodes[name].degree) / float64(maxDegree) + gravity := 0.02 + 0.14*importance + dispX[name] += (centerX - nodes[name].x) * gravity + dispY[name] += (centerY - nodes[name].y) * gravity + } + + for _, name := range names { + dx, dy := dispX[name], dispY[name] + dist := math.Hypot(dx, dy) + if dist < 0.01 { + continue + } + capped := math.Min(dist, temp) + nodes[name].x += (dx / dist) * capped + nodes[name].y += (dy / dist) * capped + } + + temp *= 0.97 + } +} + +// resolveOverlaps guarantees no two node cards overlap, regardless of how +// well the physics simulation actually converged: repeatedly find +// overlapping pairs and push each apart along whichever axis has the +// smaller overlap (the smallest nudge that actually separates them), for a +// bounded number of passes. +// +// forceDirectedLayout's repulsion is a soft, iterative force competing +// against every other force in the same system; in a denser graph that +// competition doesn't always fully resolve within a fixed iteration +// budget. This pass is the hard guarantee the soft force alone couldn't +// reliably provide -- it only ever increases separation, so it can't +// reintroduce a crossing the routing pass (which runs after this, from +// these same final positions) would have to account for. +func resolveOverlaps(nodes map[string]*graphNode, names []string) { + const passes = 60 + for pass := 0; pass < passes; pass++ { + moved := false + for i := 0; i < len(names); i++ { + for j := i + 1; j < len(names); j++ { + a, b := nodes[names[i]], nodes[names[j]] + // a.x/a.y (like everywhere else in this file) are the + // rectangle's top-left corner, not its center -- overlap + // on each axis is how much the [x,x+w]/[y,y+h] intervals + // intersect, not (w1+w2)/2 - |x1-x2| (which is only valid + // when x is a center coordinate, and silently gives wrong + // answers, including false negatives, otherwise). + overlapX := math.Min(a.x+a.w, b.x+b.w) - math.Max(a.x, b.x) + overlapY := math.Min(a.y+a.h, b.y+b.h) - math.Max(a.y, b.y) + if overlapX <= 0 || overlapY <= 0 { + continue // not overlapping on at least one axis + } + // Push apart based on each rect's actual center, not its + // corner -- with differently-sized cards, corner-based + // dx/dy doesn't reliably say which side a box's center is + // really on. + dx := (a.x + a.w/2) - (b.x + b.w/2) + dy := (a.y + a.h/2) - (b.y + b.h/2) + if overlapX < overlapY { + shift := overlapX/2 + 1 + if dx < 0 { + shift = -shift + } + a.x += shift + b.x -= shift + } else { + shift := overlapY/2 + 1 + if dy < 0 { + shift = -shift + } + a.y += shift + b.y -= shift + } + moved = true + } + } + if !moved { + break + } + } +} + +func boundingBox(nodes map[string]*graphNode) (minX, minY, maxX, maxY float64) { + first := true + for _, node := range nodes { + x0, y0, x1, y1 := node.x, node.y, node.x+node.w, node.y+node.h + if first { + minX, minY, maxX, maxY = x0, y0, x1, y1 + first = false + continue } + minX, minY = math.Min(minX, x0), math.Min(minY, y0) + maxX, maxY = math.Max(maxX, x1), math.Max(maxY, y1) } - height := 80 + (maxLevel+1)*rowHeight + return +} - svg := fmt.Sprintf(``, width, height) - svg += `` +// edgeRoute is one child->parent inheritance edge, computed in two passes +// by routeEdges before anything is written to the SVG: first its straight +// chord (clipped to each node's border), then how much (if at all) it +// needs to bow to avoid an obstruction. +type edgeRoute struct { + child, parent *graphNode + start, end [2]float64 + // bow is the perpendicular offset applied at the curve's midpoint; + // zero means "draw a plain straight line". Curvature here exists + // purely to route around something in the way, not for visual style -- + // see routeEdges. + bow float64 +} +// routeEdges computes every child->parent edge's path and writes them all, +// curving an edge only when its straight chord would otherwise cut through +// an unrelated node's card or overlap another edge's chord -- a short, +// unobstructed edge is drawn as a plain straight line. An earlier version +// bowed every single edge by a fixed amount regardless of whether anything +// was actually in the way, which added visual noise (and made truly short, +// direct edges look unnecessarily indirect) without helping the graphs +// that actually had crossings. This works with any node positions, so it's +// unaffected by whatever layout algorithm placed them. +func routeEdges(names []string, groups map[string]identity.Group, nodes map[string]*graphNode, svg *fmtBuilder) { + var routes []*edgeRoute for _, n := range names { - g := groups[n] - p := pos[n] - for _, parent := range g.MemberOfGroups { - pp, ok := pos[parent] + child := nodes[n] + for _, parent := range groups[n].MemberOfGroups { + parentNode, ok := nodes[parent] if !ok { continue } - x1, y1 := p[0]+nodeW/2, p[1]+nodeH - x2, y2 := pp[0]+nodeW/2, pp[1] - svg += fmt.Sprintf(``, x1, y1, x2, y2) + cx0, cy0 := child.x+child.w/2, child.y+child.h/2 + cx1, cy1 := parentNode.x+parentNode.w/2, parentNode.y+parentNode.h/2 + routes = append(routes, &edgeRoute{ + child: child, + parent: parentNode, + start: clipToRect(cx0, cy0, cx1, cy1, child), + end: clipToRect(cx1, cy1, cx0, cy0, parentNode), + }) } } - for _, n := range names { - p := pos[n] - svg += fmt.Sprintf(``, p[0], p[1], nodeW, nodeH) - svg += fmt.Sprintf(`%s`, - p[0]+nodeW/2, p[1]+nodeH/2+4, template.HTMLEscapeString(n)) + + // Pass 1: bow around any node (other than this edge's own two + // endpoints) that the straight chord would otherwise pass through. + for _, r := range routes { + dx, dy := r.end[0]-r.start[0], r.end[1]-r.start[1] + dist := math.Hypot(dx, dy) + if dist < 0.01 { + dist = 0.01 + } + px, py := -dy/dist, dx/dist // unit perpendicular to the chord + + for _, other := range nodes { + if other == r.child || other == r.parent { + continue + } + if !segmentIntersectsRect(r.start[0], r.start[1], r.end[0], r.end[1], other) { + continue + } + // Bow away from whichever side the obstruction's center is + // on, by enough to clear its half-diagonal plus a margin. + ocx, ocy := other.x+other.w/2, other.y+other.h/2 + side := (ocx-r.start[0])*px + (ocy-r.start[1])*py + clearance := math.Hypot(other.w, other.h)/2 + 16 + needed := clearance + if side >= 0 { + needed = -clearance + } + if math.Abs(needed) > math.Abs(r.bow) { + r.bow = needed + } + } + } + + // Pass 2: for pairs of edges that are still straight (nothing to route + // around) but whose chords cross each other, bow both apart just + // enough that they're visibly two lines instead of one -- a real + // topological crossing generally can't be eliminated without a much + // longer detour, but overlapping into what reads as a single stroke is + // avoidable and worth fixing. + for i := 0; i < len(routes); i++ { + a := routes[i] + if a.bow != 0 { + continue + } + for j := i + 1; j < len(routes); j++ { + b := routes[j] + if b.bow != 0 { + continue + } + if !segmentsIntersect(a.start[0], a.start[1], a.end[0], a.end[1], b.start[0], b.start[1], b.end[0], b.end[1]) { + continue + } + bow := 16.0 + // Alternate which of the pair bows which way from a hash of + // the endpoints, so the same graph always separates the same + // crossing the same way instead of flipping between renders. + if fnv32(a.child.name+"|"+a.parent.name)%2 == 0 { + a.bow, b.bow = bow, -bow + } else { + a.bow, b.bow = -bow, bow + } + } + } + + for _, r := range routes { + writeEdgePath(svg, r) + } +} + +// writeEdgePath renders one already-routed edge: a plain line when bow is +// zero, otherwise a quadratic bezier bowed at its midpoint. +func writeEdgePath(svg *fmtBuilder, r *edgeRoute) { + // data-child/data-parent let admin_home.html's hover handler find this + // edge from either endpoint without walking the DOM for it -- see the + // "light up direct links on hover" JS there. + attrs := fmt.Sprintf(`data-child="%s" data-parent="%s"`, + template.HTMLEscapeString(r.child.name), template.HTMLEscapeString(r.parent.name)) + + if r.bow == 0 { + svg.printf(``, + attrs, r.start[0], r.start[1], r.end[0], r.end[1]) + return + } + dx, dy := r.end[0]-r.start[0], r.end[1]-r.start[1] + dist := math.Hypot(dx, dy) + if dist < 0.01 { + dist = 0.01 } - svg += `` - return svg + px, py := -dy/dist, dx/dist + midX, midY := (r.start[0]+r.end[0])/2+px*r.bow, (r.start[1]+r.end[1])/2+py*r.bow + svg.printf(``, + attrs, r.start[0], r.start[1], midX, midY, r.end[0], r.end[1]) +} + +// segmentIntersectsRect reports whether the segment (x1,y1)-(x2,y2) passes +// through node's rectangle, via the Liang-Barsky line-clipping algorithm: +// clip the segment's parameter range t in [0,1] against each of the +// rectangle's four half-planes in turn, and see whether any range survives. +func segmentIntersectsRect(x1, y1, x2, y2 float64, node *graphNode) bool { + xmin, xmax := node.x, node.x+node.w + ymin, ymax := node.y, node.y+node.h + dx, dy := x2-x1, y2-y1 + tMin, tMax := 0.0, 1.0 + p := [4]float64{-dx, dx, -dy, dy} + q := [4]float64{x1 - xmin, xmax - x1, y1 - ymin, ymax - y1} + for i := 0; i < 4; i++ { + if p[i] == 0 { + if q[i] < 0 { + return false // parallel to this edge and entirely outside it + } + continue + } + t := q[i] / p[i] + if p[i] < 0 { + if t > tMax { + return false + } + if t > tMin { + tMin = t + } + } else { + if t < tMin { + return false + } + if t < tMax { + tMax = t + } + } + } + return tMin < tMax +} + +// segmentsIntersect reports whether segments (x1,y1)-(x2,y2) and +// (x3,y3)-(x4,y4) cross, via orientation tests (does not special-case +// collinear overlap, which doesn't occur for distinct graph edges here). +func segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4 float64) bool { + cross := func(ax, ay, bx, by float64) float64 { return ax*by - ay*bx } + d1 := cross(x4-x3, y4-y3, x1-x3, y1-y3) + d2 := cross(x4-x3, y4-y3, x2-x3, y2-y3) + d3 := cross(x2-x1, y2-y1, x3-x1, y3-y1) + d4 := cross(x2-x1, y2-y1, x4-x1, y4-y1) + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)) +} + +// clipToRect returns the point where the segment from (x0,y0) towards +// (x1,y1) exits node's rectangle, starting from the rectangle's center -- +// i.e. where a line drawn from this node to the other one should actually +// start, so it touches the border rather than overlapping the card. +func clipToRect(x0, y0, x1, y1 float64, node *graphNode) [2]float64 { + dx, dy := x1-x0, y1-y0 + if dx == 0 && dy == 0 { + return [2]float64{x0, y0} + } + hw, hh := node.w/2, node.h/2 + // Find the smallest positive t such that (x0,y0)+t*(dx,dy) lies on the + // rectangle's border. + best := math.Inf(1) + if dx != 0 { + for _, edgeX := range [2]float64{-hw, hw} { + t := edgeX / dx + if t > 0 { + y := t * dy + if y >= -hh && y <= hh && t < best { + best = t + } + } + } + } + if dy != 0 { + for _, edgeY := range [2]float64{-hh, hh} { + t := edgeY / dy + if t > 0 { + x := t * dx + if x >= -hw && x <= hw && t < best { + best = t + } + } + } + } + if math.IsInf(best, 1) { + return [2]float64{x0, y0} + } + return [2]float64{x0 + best*dx, y0 + best*dy} +} + +func fnv32(s string) uint32 { + var h uint32 = 2166136261 + for i := 0; i < len(s); i++ { + h ^= uint32(s[i]) + h *= 16777619 + } + return h +} + +// fmtBuilder is a tiny strings.Builder wrapper so the SVG-assembly code +// above can use fmt.Fprintf-style calls without importing strings directly +// in every call site. +type fmtBuilder struct { + b []byte +} + +func (f *fmtBuilder) printf(format string, args ...any) { + f.b = append(f.b, []byte(fmt.Sprintf(format, args...))...) +} + +func (f *fmtBuilder) WriteString(s string) { + f.b = append(f.b, s...) +} + +func (f *fmtBuilder) String() string { + return string(f.b) } diff --git a/internal/admin/groupdetails.go b/internal/admin/groupdetails.go new file mode 100644 index 0000000..bf0d340 --- /dev/null +++ b/internal/admin/groupdetails.go @@ -0,0 +1,171 @@ +package admin + +import ( + "sort" + + "declarativeauth/internal/identity" +) + +// pathEntry is one member/ancestor of a group, together with a chain +// showing how it got there. Path always starts at the "far end" (the user, +// or the descendant/ancestor group) and ends at the group this pathEntry +// belongs to -- e.g. for engineering's Users list, a pathEntry might be +// {Name: "jsmith", Path: ["jsmith", "oncall", "backend-team", "engineering"]}, +// read as "jsmith, via oncall, via backend-team, into engineering". Groups +// can inherit the same ancestor through more than one path (the diamond +// example in examples/identity/groups.yaml); only the shortest is shown, +// since listing every possible path would make the panel unreadable for +// not much benefit. +type pathEntry struct { + Name string `json:"name"` + Path []string `json:"path"` +} + +// groupDetail is everything the admin graph's click-to-inspect panel shows +// for one group. Users/Subgroups/FlattenedMemberOf each include direct +// memberships too (as a length-2 Path: [item, this group]) rather than +// listing them separately -- the client just renders a length-2 path as a +// plain chip instead of a breadcrumb, so there's one merged list per +// category instead of a "direct" list duplicating part of a "everyone" +// list right next to it. +type groupDetail struct { + Name string `json:"name"` + RequireMFA bool `json:"requireMFA"` + Users []pathEntry `json:"users"` // every transitive member user + Subgroups []pathEntry `json:"subgroups"` // every transitive member group + FlattenedMemberOf []pathEntry `json:"flattenedMemberOf"` // every group this one transitively belongs to +} + +// computeGroupDetails builds the click-to-inspect data for every group in +// snap, for the admin group graph. It's independent of (and doesn't reuse) +// identity.Snapshot's own FlattenedMemberOf/FlattenedMembers, since those +// only record *whether* a membership holds, not *the path* that produced +// it -- which is the whole point of this panel. +func computeGroupDetails(snap *identity.Snapshot) map[string]groupDetail { + groups := snap.Groups + + children := map[string][]string{} // group -> direct child groups + for name, g := range groups { + for _, parent := range g.MemberOfGroups { + children[parent] = append(children[parent], name) + } + } + + parentsOf := func(n string) []string { return groups[n].MemberOfGroups } + childrenOf := func(n string) []string { return children[n] } + + details := make(map[string]groupDetail, len(groups)) + for name, g := range groups { + // []pathEntry{}, not nil -- this is serialized straight to JSON for + // the graph's client-side click handler, which indexes .length on + // every one of these fields unconditionally; a bare nil slice + // would round-trip as JSON `null` and throw there. + users := []pathEntry{} + for uname, u := range snap.Users { + if path := shortestPath(u.MemberOfGroups, name, parentsOf); path != nil { + users = append(users, pathEntry{Name: uname, Path: append([]string{uname}, path...)}) + } + } + sort.Slice(users, func(i, j int) bool { return users[i].Name < users[j].Name }) + + details[name] = groupDetail{ + Name: name, + RequireMFA: g.RequireMFA, + Users: users, + // bfsPaths(name, childrenOf) walks DOWN from this group to each + // descendant, so it naturally produces [this group, ..., + // descendant] -- backwards from every other path in this + // struct (which all end AT this group, matching the graph's + // own child-to-parent arrow direction) and backwards from the + // arrows themselves, since a descendant's arrow points UP + // towards this group, not down towards the descendant. + // Reversed here once so the client never has to know which + // direction a particular list happens to have been walked in. + Subgroups: reversedPaths(bfsPaths(name, childrenOf)), + FlattenedMemberOf: bfsPaths(name, parentsOf), + } + } + return details +} + +// reversedPaths flips the Path of every entry in place order (front to +// back), leaving Name untouched -- see the Subgroups comment above for why +// bfsPaths's natural output needs this for that one case. +func reversedPaths(entries []pathEntry) []pathEntry { + out := make([]pathEntry, len(entries)) + for i, e := range entries { + rev := make([]string, len(e.Path)) + for j, s := range e.Path { + rev[len(e.Path)-1-j] = s + } + out[i] = pathEntry{Name: e.Name, Path: rev} + } + return out +} + +// bfsPaths finds the shortest path from start to every node reachable via +// edges, breadth-first (so the first path found to any node is already +// shortest), and returns them sorted by name for a stable render. Returns +// []pathEntry{} rather than nil when start has no reachable nodes -- see +// the same note on `users` in computeGroupDetails. +func bfsPaths(start string, edges func(string) []string) []pathEntry { + visited := map[string]bool{start: true} + paths := map[string][]string{start: {start}} + queue := []string{start} + out := []pathEntry{} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, next := range edges(cur) { + if visited[next] { + continue + } + visited[next] = true + path := append(append([]string{}, paths[cur]...), next) + paths[next] = path + out = append(out, pathEntry{Name: next, Path: path}) + queue = append(queue, next) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// shortestPath finds the shortest path from any of sources to target, +// following edges, and returns it starting from whichever source produced +// the shortest route (nil if target isn't reachable from any source). Used +// for "which of this user's direct groups leads to the clicked group, and +// how". +func shortestPath(sources []string, target string, edges func(string) []string) []string { + visited := map[string]bool{} + paths := map[string][]string{} + var queue []string + for _, s := range sources { + if visited[s] { + continue + } + visited[s] = true + paths[s] = []string{s} + queue = append(queue, s) + if s == target { + return paths[s] + } + } + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, next := range edges(cur) { + if visited[next] { + continue + } + visited[next] = true + path := append(append([]string{}, paths[cur]...), next) + paths[next] = path + if next == target { + return path + } + queue = append(queue, next) + } + } + return nil +} diff --git a/internal/admin/handlers.go b/internal/admin/handlers.go index fcd4346..1443fca 100644 --- a/internal/admin/handlers.go +++ b/internal/admin/handlers.go @@ -11,28 +11,56 @@ import ( "log/slog" "net/http" + "declarativeauth/internal/appshell" + "declarativeauth/internal/buildinfo" "declarativeauth/internal/identity" "declarativeauth/internal/mail" + "declarativeauth/internal/uiicons" "declarativeauth/internal/web" ) //go:embed templates var templatesFS embed.FS +// mustParse parses page against the shared app shell (internal/appshell), +// the same one internal/web uses for the account page -- see that +// package's mustParseShell and shell.html's own doc comment. func mustParse(page string) *template.Template { - return template.Must(template.ParseFS(templatesFS, "templates/layout.html", "templates/"+page)) + funcs := template.FuncMap{ + "version": func() string { return buildinfo.Version }, + } + for k, v := range uiicons.FuncMap() { + funcs[k] = v + } + tmpl := template.Must(template.New("shell.html").Funcs(funcs).ParseFS(appshell.FS, "shell.html")) + return template.Must(tmpl.ParseFS(templatesFS, "templates/"+page)) } var ( - dashboardTmpl = mustParse("dashboard.html") - smtpTestTmpl = mustParse("smtp_test.html") - graphTmpl = mustParse("graph.html") + adminHomeTmpl = mustParse("admin_home.html") configEditorTmpl = mustParse("config_editor.html") ) type pageData struct { Title string ConfigEditorEnabled bool + // Active identifies which top tab to highlight: "admin" (shared by the + // email-test and group-graph pages, which present as tabs of one + // section) or "config". The "My account" tab is never active from + // within the admin package -- it always points back to the web app. + Active string + // IsAdmin is always true -- every /admin page is reached only via + // Handlers.gate, which already requires admin-group membership -- but + // shell.html (shared with the account page, where this varies per + // viewer) reads it unconditionally, so it still needs to be set here. + IsAdmin bool +} + +// newPageData is the one place that constructs pageData, so IsAdmin can't +// be forgotten by a future handler the way a bare struct literal would +// allow. +func newPageData(title, active string, configEditorEnabled bool) pageData { + return pageData{Title: title, ConfigEditorEnabled: configEditorEnabled, Active: active, IsAdmin: true} } // Handlers wires the /admin/* surface. @@ -56,20 +84,20 @@ type Handlers struct { // behind admin-group membership. func (h *Handlers) NewMux() *http.ServeMux { mux := http.NewServeMux() - mux.HandleFunc("/admin", h.gate(h.handleDashboard)) - mux.HandleFunc("/admin/smtp-test", h.gate(h.handleSMTPTest)) - mux.HandleFunc("/admin/graph", h.gate(h.handleGraph)) + mux.HandleFunc("/admin", h.gate(h.handleAdmin)) + mux.HandleFunc("/admin/smtp-test", h.gate(h.handleAdmin)) + mux.HandleFunc("/admin/graph", h.gate(h.handleAdmin)) if h.ConfigEditorEnabled { + // File identity is a path relative to h.IdentityPath, discovered + // dynamically (see config.ListIdentityFiles) rather than a fixed + // users/groups/oidc-clients enum, matching how LoadIdentity itself + // finds config -- any file name, any nesting. {file...} captures + // the rest of the path, slashes included. + mux.HandleFunc("/admin/config", h.gate(h.handleConfigIndex)) mux.HandleFunc("/admin/config/validate", h.gate(h.handleConfigValidate)) - mux.HandleFunc("/admin/config/users", h.gate(h.handleConfigEditorUsers)) - mux.HandleFunc("/admin/config/groups", h.gate(h.handleConfigEditorGroups)) - mux.HandleFunc("/admin/config/oidc-clients", h.gate(h.handleConfigEditorOIDCClients)) - mux.HandleFunc("/admin/config/users/save", h.gate(h.handleConfigSaveUsers)) - mux.HandleFunc("/admin/config/groups/save", h.gate(h.handleConfigSaveGroups)) - mux.HandleFunc("/admin/config/oidc-clients/save", h.gate(h.handleConfigSaveOIDCClients)) - mux.HandleFunc("/admin/config/users/download", h.gate(h.handleConfigDownloadUsers)) - mux.HandleFunc("/admin/config/groups/download", h.gate(h.handleConfigDownloadGroups)) - mux.HandleFunc("/admin/config/oidc-clients/download", h.gate(h.handleConfigDownloadOIDCClients)) + mux.HandleFunc("/admin/config/edit/{file...}", h.gate(h.handleConfigEdit)) + mux.HandleFunc("/admin/config/save/{file...}", h.gate(h.handleConfigSave)) + mux.HandleFunc("/admin/config/download/{file...}", h.gate(h.handleConfigDownload)) } return mux } @@ -90,18 +118,6 @@ func (h *Handlers) gate(next func(w http.ResponseWriter, r *http.Request, userna } } -type dashboardData struct { - pageData - Username string -} - -func (h *Handlers) handleDashboard(w http.ResponseWriter, r *http.Request, username string) { - render(w, dashboardTmpl, dashboardData{ - pageData: pageData{Title: "Admin", ConfigEditorEnabled: h.ConfigEditorEnabled}, - Username: username, - }) -} - func render(w http.ResponseWriter, tmpl *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = tmpl.ExecuteTemplate(w, "layout", data) diff --git a/internal/admin/smtptest.go b/internal/admin/smtptest.go index 72e74eb..ca08b69 100644 --- a/internal/admin/smtptest.go +++ b/internal/admin/smtptest.go @@ -6,26 +6,38 @@ import ( "declarativeauth/internal/web" ) -type smtpTestData struct { +// adminHomeData backs the merged "Admin" page: the email test form and the +// group graph, stacked on one page instead of two separate tab-switched +// ones -- both are small, low-traffic diagnostics, and splitting them +// across pages bought nothing but an extra click and an extra nav item. +type adminHomeData struct { pageData CSRFToken string Result string Error string + graphViewData } -func (h *Handlers) handleSMTPTest(w http.ResponseWriter, r *http.Request, username string) { +// handleAdmin serves the "Admin" tab: an email-test form above the group +// inheritance graph. It's still reachable at both /admin/smtp-test (the +// email form's POST target) and /admin/graph (kept as a stable link for +// anything that already points at it) -- see NewMux. +func (h *Handlers) handleAdmin(w http.ResponseWriter, r *http.Request, username string) { secure := h.TrustedProxies.IsForwardedHTTPS(r) - data := smtpTestData{pageData: pageData{Title: "SMTP test", ConfigEditorEnabled: h.ConfigEditorEnabled}} + data := adminHomeData{ + pageData: newPageData("Admin", "admin", h.ConfigEditorEnabled), + graphViewData: computeGraphData(h.Snapshot()), + } switch r.Method { case http.MethodGet: data.CSRFToken = web.IssueCSRFToken(w, r, secure) - render(w, smtpTestTmpl, data) + render(w, adminHomeTmpl, data) case http.MethodPost: data.CSRFToken = web.IssueCSRFToken(w, r, secure) if !web.ValidCSRF(r) { data.Error = "Your session expired, please try again." - render(w, smtpTestTmpl, data) + render(w, adminHomeTmpl, data) return } to := r.FormValue("to") @@ -42,7 +54,7 @@ func (h *Handlers) handleSMTPTest(w http.ResponseWriter, r *http.Request, userna } else { data.Result = "Test email sent to " + to + "." } - render(w, smtpTestTmpl, data) + render(w, adminHomeTmpl, data) default: w.WriteHeader(http.StatusMethodNotAllowed) } diff --git a/internal/admin/templates/admin_home.html b/internal/admin/templates/admin_home.html new file mode 100644 index 0000000..c011bf9 --- /dev/null +++ b/internal/admin/templates/admin_home.html @@ -0,0 +1,143 @@ +{{define "content"}} +{{if .Result}}

{{icon "check"}}{{.Result}}

{{end}} +{{if .Error}}

{{icon "warning"}}{{.Error}}

{{end}} + +

{{icon "mail"}} Email test

+

Send a test email through the configured SMTP server to confirm it's set up correctly.

+
+ +
+ + +
+
+ +

{{icon "graph"}} Group graph

+

{{.GroupCount}} groups, {{.EdgeCount}} inheritance edges. Click a group to see its members and inheritance.

+
+ {{.SVG}} +
+ + + + +{{end}} diff --git a/internal/admin/templates/config_editor.html b/internal/admin/templates/config_editor.html index 9b800c9..f280f09 100644 --- a/internal/admin/templates/config_editor.html +++ b/internal/admin/templates/config_editor.html @@ -1,43 +1,49 @@ {{define "content"}} -
-

Editing {{.FileName}}. Changes are validated live and only written to disk on Save - (which triggers the normal hot-reload watcher, same as any other file write).

-

- users.yaml | - groups.yaml | - oidc-clients.yaml | - Download -

- {{if .Error}}

{{.Error}}

{{end}} - {{if .Success}}

Saved.

{{end}} -
- - -

- -
- +{{if .Files}} +
+ {{range .Files}} + {{icon "file"}}{{.Path}} + {{end}}
{{end}} + +{{if not .FilePath}} +

No declarative config files found under the identity directory.

+{{else}} +

Changes are validated live and only written to disk on Save.

+
+ + +

+
+ + {{icon "download"}}Download +
+ {{if .Error}}

{{icon "warning"}}{{.Error}}

{{end}} + {{if .Success}}

{{icon "check"}}Saved.

{{end}} +
+ +{{end}} +{{end}} diff --git a/internal/admin/templates/dashboard.html b/internal/admin/templates/dashboard.html deleted file mode 100644 index 6ae8948..0000000 --- a/internal/admin/templates/dashboard.html +++ /dev/null @@ -1,14 +0,0 @@ -{{define "content"}} -
-

Logged in as {{.Username}} (member of the admin group).

- -
-{{end}} diff --git a/internal/admin/templates/graph.html b/internal/admin/templates/graph.html deleted file mode 100644 index 3a84289..0000000 --- a/internal/admin/templates/graph.html +++ /dev/null @@ -1,6 +0,0 @@ -{{define "content"}} -
-

{{.GroupCount}} groups, {{.EdgeCount}} inheritance edges.

- {{.SVG}} -
-{{end}} diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html deleted file mode 100644 index 0947c63..0000000 --- a/internal/admin/templates/layout.html +++ /dev/null @@ -1,67 +0,0 @@ -{{define "layout"}} - - - - - {{.Title}} - DeclarativeAuth Admin - - - - -

{{.Title}}

- {{template "content" .}} - - - -{{end}} diff --git a/internal/admin/templates/smtp_test.html b/internal/admin/templates/smtp_test.html deleted file mode 100644 index bc8019e..0000000 --- a/internal/admin/templates/smtp_test.html +++ /dev/null @@ -1,14 +0,0 @@ -{{define "content"}} -
- {{if .Result}}

{{.Result}}

{{end}} - {{if .Error}}

{{.Error}}

{{end}} -
- - -

- -
-
-{{end}} diff --git a/internal/config/loader.go b/internal/config/loader.go index 17a0a71..ac2e1e9 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -212,6 +212,72 @@ func LoadIdentity(dir string) (*identity.Snapshot, error) { }, nil } +// IdentityFile is one recognized declarative config file found by +// ListIdentityFiles. +type IdentityFile struct { + // Path is relative to the identity directory, with forward slashes + // regardless of OS -- suitable for both display and round-tripping + // back into ResolveIdentityFile. + Path string + // Kind is "UserList", "GroupList", or "OIDCClientList". + Kind string +} + +// ListIdentityFiles walks dir exactly like LoadIdentity does and returns +// every file that LoadIdentity would actually fold into the Snapshot -- +// same recursive discovery, same apiVersion/kind header check -- so +// callers like the admin config editor show precisely "the files this +// server is reading", not a hardcoded users.yaml/groups.yaml/ +// oidc-clients.yaml list that stops matching reality the moment someone +// splits or renames a file. Order is stable (sorted by path) across calls. +func ListIdentityFiles(dir string) ([]IdentityFile, error) { + files, err := yamlFilesUnder(dir) + if err != nil { + return nil, err + } + var out []IdentityFile + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", f, err) + } + var hdr fileHeader + if err := yaml.Unmarshal(b, &hdr); err != nil { + continue + } + if hdr.APIVersion != configAPIVersion { + continue + } + switch hdr.Kind { + case "GroupList", "UserList", "OIDCClientList": + default: + continue + } + rel, err := filepath.Rel(dir, f) + if err != nil { + return nil, err + } + out = append(out, IdentityFile{Path: filepath.ToSlash(rel), Kind: hdr.Kind}) + } + return out, nil +} + +// ResolveIdentityFile validates that relPath (as returned by +// ListIdentityFiles, or typed by an operator into the admin config editor) +// actually resolves to a path inside dir, and returns that absolute path. +// Without this check, a crafted relPath like "../../etc/something" could +// read or write outside the identity directory entirely. +func ResolveIdentityFile(dir, relPath string) (string, error) { + if relPath == "" { + return "", fmt.Errorf("empty file path") + } + cleaned := filepath.Clean(filepath.FromSlash(relPath)) + if filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("invalid file path %q", relPath) + } + return filepath.Join(dir, cleaned), nil +} + // resolvePasswordHash resolves a UserSpec's PasswordHash/PasswordHashFile // into the hash string identity.User.PasswordHash should carry, plus the // raw bytes actually read for it. Those raw bytes get folded into the diff --git a/test/integration/admin_flow_test.go b/test/integration/admin_flow_test.go index df4979d..00af096 100644 --- a/test/integration/admin_flow_test.go +++ b/test/integration/admin_flow_test.go @@ -202,7 +202,7 @@ func TestAdmin_ConfigEditor_DisabledByDefault404s(t *testing.T) { client := &http.Client{Jar: jar} loginAs(t, client, jar, issuer, "admin", "AdminSecret1!") - resp, err := client.Get(issuer + "/admin/config/users") + resp, err := client.Get(issuer + "/admin/config/edit/users.yaml") if err != nil { t.Fatalf("get config editor: %v", err) } @@ -218,12 +218,12 @@ func TestAdmin_ConfigEditor_ValidateAndSaveTriggersReload(t *testing.T) { client := &http.Client{Jar: jar} loginAs(t, client, jar, issuer, "admin", "AdminSecret1!") - resp, err := client.Get(issuer + "/admin/config/users") + resp, err := client.Get(issuer + "/admin/config/edit/users.yaml") if err != nil { t.Fatalf("get config editor: %v", err) } resp.Body.Close() - u, _ := url.Parse(issuer + "/admin/config/users") + u, _ := url.Parse(issuer + "/admin/config/edit/users.yaml") var csrf string for _, c := range jar.Cookies(u) { if c.Name == "da_csrf" { @@ -233,7 +233,7 @@ func TestAdmin_ConfigEditor_ValidateAndSaveTriggersReload(t *testing.T) { invalidContent := "apiVersion: declarativeauth.io/v1\nkind: UserList\nusers:\n - username: x\n memberOfGroups: [nonexistent-group]\n" resp, err = client.PostForm(issuer+"/admin/config/validate", url.Values{ - "fileKey": {"users"}, "content": {invalidContent}, "csrf_token": {csrf}, + "file": {"users.yaml"}, "content": {invalidContent}, "csrf_token": {csrf}, }) if err != nil { t.Fatalf("validate: %v", err) @@ -251,7 +251,7 @@ func TestAdmin_ConfigEditor_ValidateAndSaveTriggersReload(t *testing.T) { } newContent := string(validContent) + " - username: newuser\n email: newuser@example.com\n enabled: true\n memberOfGroups: []\n" - resp, err = client.PostForm(issuer+"/admin/config/users/save", url.Values{ + resp, err = client.PostForm(issuer+"/admin/config/save/users.yaml", url.Values{ "content": {newContent}, "csrf_token": {csrf}, }) if err != nil { From 7ff31d4e503dd7a599f50fb11a41fef57b730e45 Mon Sep 17 00:00:00 2001 From: ShyneTurtle <41578217+ShyneTurtle@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:49 +0200 Subject: [PATCH 3/5] Unify account/admin page shell, cache static assets, log build version --- .github/workflows/docker-build.yaml | 2 + cmd/declarativeauth/cmd_serve.go | 2 + deploy/docker/Dockerfile | 10 +++- internal/server/run.go | 19 +++---- internal/web/handlers.go | 82 ++++++++++++++++++++--------- internal/web/static/boot.js | 36 +++++++++++++ internal/web/templates.go | 28 +++++++++- internal/web/templates/home.html | 39 +++++++------- internal/web/templates/layout.html | 13 ++++- 9 files changed, 172 insertions(+), 59 deletions(-) create mode 100644 internal/web/static/boot.js diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 27c42c0..eaf9257 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -43,3 +43,5 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} diff --git a/cmd/declarativeauth/cmd_serve.go b/cmd/declarativeauth/cmd_serve.go index b1d7317..4a23e97 100644 --- a/cmd/declarativeauth/cmd_serve.go +++ b/cmd/declarativeauth/cmd_serve.go @@ -7,6 +7,7 @@ import ( "os/signal" "syscall" + "declarativeauth/internal/buildinfo" "declarativeauth/internal/config" "declarativeauth/internal/logging" "declarativeauth/internal/metrics" @@ -71,6 +72,7 @@ func runServe(args []string) error { snap := holder.Get() logger.Info("declarativeauth ready", "component", "server", + "version", buildinfo.Version, "users", len(snap.Users), "groups", len(snap.Groups), "ldap_addr", cfg.LDAP.ListenAddr, diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 91a6ff3..b05904d 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -13,7 +13,15 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/declarativeauth ./cmd/declarativeauth +# VERSION defaults to "dev" for local/untagged builds; CI passes the git tag +# or short SHA via --build-arg (see .github/workflows/docker-build.yaml). +# Baked in with -X rather than read at runtime so `declarativeauth serve`'s +# startup log and the admin/web UI's console.info both show a real build +# identifier with zero runtime config needed. +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \ + -ldflags="-s -w -X declarativeauth/internal/buildinfo.Version=${VERSION}" \ + -o /out/declarativeauth ./cmd/declarativeauth # Pre-create the identity mountpoint here, where a shell exists, so it # lands in the final image with known-good permissions (0755: traversable # by the "nonroot" user the final stage runs as). The distroless base below diff --git a/internal/server/run.go b/internal/server/run.go index fbb185c..d805783 100644 --- a/internal/server/run.go +++ b/internal/server/run.go @@ -212,15 +212,16 @@ func Run(ctx context.Context, cfg *config.ServerConfig, holder *config.SnapshotH mfaChallenges := &store.EmailChallengeStore{Pool: pool} mfaPolicy := &auth.MFAPolicy{Snapshot: holder.Get, Settings: mfaSettings} webHandlers := &web.Handlers{ - Authenticator: authenticator, - Sessions: sessions, - TrustedProxies: trustedProxies, - Audit: auditStore, - Snapshot: holder.Get, - AdminGroup: cfg.AdminUI.AdminGroup, - MFAPolicy: mfaPolicy, - MFAChallenges: mfaChallenges, - MFAMail: mailClient, + Authenticator: authenticator, + Sessions: sessions, + TrustedProxies: trustedProxies, + Audit: auditStore, + Snapshot: holder.Get, + AdminGroup: cfg.AdminUI.AdminGroup, + ConfigEditorEnabled: cfg.AdminUI.ConfigEditor.Enabled, + MFAPolicy: mfaPolicy, + MFAChallenges: mfaChallenges, + MFAMail: mailClient, OnLoginResult: func(success bool, d time.Duration) { if reg == nil { return diff --git a/internal/web/handlers.go b/internal/web/handlers.go index dcb19a2..a6b3403 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -26,9 +26,13 @@ type Handlers struct { StaticFS http.Handler // Snapshot and AdminGroup back the home page: the user's declared - // name/email, and whether to show a link to /admin. - Snapshot func() *identity.Snapshot - AdminGroup string + // name/email, and whether to show the admin tabs. ConfigEditorEnabled + // mirrors admin.Handlers.ConfigEditorEnabled (same + // DECLARATIVEAUTH_ADMIN_UI_CONFIG_EDITOR_ENABLED source) so the "Config + // editor" tab can be greyed out instead of just omitted. + Snapshot func() *identity.Snapshot + AdminGroup string + ConfigEditorEnabled bool // Passkeys, if non-nil, enables the passkey-management section of the // home page (nil when DECLARATIVEAUTH_WEBAUTHN_ENABLED is false or @@ -63,10 +67,25 @@ func (h *Handlers) NewMux() *http.ServeMux { mux.HandleFunc("/", h.handleHome) mux.HandleFunc("/login", h.handleLogin) mux.HandleFunc("/logout", h.handleLogout) - mux.Handle("/static/", http.FileServer(http.FS(assetsFS))) + mux.Handle("/static/", cacheStatic(http.FileServer(http.FS(assetsFS)))) return mux } +// cacheStatic marks embedded static assets as cacheable. They're compiled +// into the binary (see templates.go's go:embed), so a given deploy always +// serves byte-identical content at a given path -- but a new deploy can +// change that content, so this stops well short of "immutable": a +// revalidate-on-every-load max-age keeps browsers from ever needing a full +// re-download unless the file actually changed (embed.FS's fixed modtime +// makes the FileServer's built-in If-Modified-Since handling return 304s +// consistently within one running binary). +func cacheStatic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate") + next.ServeHTTP(w, r) + }) +} + type passkeyView struct { Name string IDBase64 string @@ -75,17 +94,23 @@ type passkeyView struct { } type homePageData struct { - Title string - Username string - DisplayName string - Email string - IsAdmin bool - CSRFToken string - PasskeysEnabled bool - Passkeys []passkeyView - MFARequired bool // declaratively required: the toggle below is hidden, not just disabled - MFASelfEnabled bool - Error string + Title string + Username string + // Active is always "account" -- this field exists (rather than the + // template hardcoding "account") only because shell.html is shared + // with admin.pageData, which sets it to "admin"/"config" instead; see + // internal/appshell. + Active string + DisplayName string + Email string + IsAdmin bool + ConfigEditorEnabled bool + CSRFToken string + PasskeysEnabled bool + Passkeys []passkeyView + MFARequired bool // declaratively required: the toggle below is hidden, not just disabled + MFASelfEnabled bool + Error string } // handleHome serves the user's profile page: name/email, an admin-panel @@ -149,16 +174,18 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request) { } render(w, homeTmpl, homePageData{ - Title: "My account", - Username: username, - DisplayName: u.DisplayNameOrDefault(), - Email: u.Email, - IsAdmin: h.AdminGroup != "" && snap.IsMemberOf(username, h.AdminGroup), - CSRFToken: csrf, - PasskeysEnabled: h.Passkeys != nil, - Passkeys: views, - MFARequired: mfaRequired, - MFASelfEnabled: mfaSelfEnabled, + Title: "My account", + Username: username, + Active: "account", + DisplayName: u.DisplayNameOrDefault(), + Email: u.Email, + IsAdmin: h.AdminGroup != "" && snap.IsMemberOf(username, h.AdminGroup), + ConfigEditorEnabled: h.ConfigEditorEnabled, + CSRFToken: csrf, + PasskeysEnabled: h.Passkeys != nil, + Passkeys: views, + MFARequired: mfaRequired, + MFASelfEnabled: mfaSelfEnabled, }) } @@ -319,6 +346,11 @@ func sanitizeReturnTo(v string) string { return v } +// render executes tmpl's "layout" template -- for most pages that's this +// package's own narrow auth-page shell (templates/layout.html), but for +// homeTmpl specifically it's the shared tabbed shell from internal/appshell +// (see mustParseShell), since each *template.Template built by mustParse/ +// mustParseShell only ever has one "layout" definition in it. func render(w http.ResponseWriter, tmpl *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = tmpl.ExecuteTemplate(w, "layout", data) diff --git a/internal/web/static/boot.js b/internal/web/static/boot.js new file mode 100644 index 0000000..1d7579c --- /dev/null +++ b/internal/web/static/boot.js @@ -0,0 +1,36 @@ +// Logs the build version and page load time to the console once the page +// has finished loading. Reads the version from , which +// both layout templates set from the server's embedded build version. +(function () { + // Date.now() fallback in case the Performance API is entirely + // unavailable (very old browsers) -- captured at script-parse time, + // which is close enough to navigation start for a rough fallback + // number; the primary path below doesn't use this at all. + var fallbackStart = Date.now(); + + function report() { + var version = document.body.getAttribute("data-version") || "dev"; + var loadMs; + if (window.performance && typeof performance.now === "function") { + // performance.now() is time elapsed since the navigation's time + // origin -- i.e. it already covers DNS/connect/TLS/request/response + // and DOM processing, the same start point Navigation Timing's own + // entries use. Reading it here, at "load", is deliberately preferred + // over reading a PerformanceNavigationTiming entry's own + // loadEventEnd: that field isn't guaranteed to be finalized yet + // while one of the "load" event's own listeners (this one) is still + // running, per the Navigation Timing spec -- which reliably under- + // reported the total, network time included, if read that way. + loadMs = Math.round(performance.now()); + } else { + loadMs = Date.now() - fallbackStart; + } + console.info("DeclarativeAuth " + version + " - page loaded in " + loadMs + "ms"); + } + + if (document.readyState === "complete") { + report(); + } else { + window.addEventListener("load", report); + } +})(); diff --git a/internal/web/templates.go b/internal/web/templates.go index 029b371..f434604 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -3,13 +3,37 @@ package web import ( "embed" "html/template" + + "declarativeauth/internal/appshell" + "declarativeauth/internal/buildinfo" + "declarativeauth/internal/uiicons" ) //go:embed templates static var assetsFS embed.FS +func templateFuncs() template.FuncMap { + funcs := template.FuncMap{ + "version": func() string { return buildinfo.Version }, + } + for k, v := range uiicons.FuncMap() { + funcs[k] = v + } + return funcs +} + +// mustParse is for the narrow pre-login auth pages (login, reset, MFA +// verify, error), which use this package's own "layout" (see +// templates/layout.html). func mustParse(page string) *template.Template { - return template.Must(template.ParseFS(assetsFS, "templates/layout.html", "templates/"+page)) + return template.Must(template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, "templates/layout.html", "templates/"+page)) +} + +// mustParseShell is for pages that belong to the shared tabbed app shell +// (currently just the account page) -- see internal/appshell. +func mustParseShell(page string) *template.Template { + tmpl := template.Must(template.New("shell.html").Funcs(templateFuncs()).ParseFS(appshell.FS, "shell.html")) + return template.Must(tmpl.ParseFS(assetsFS, "templates/"+page)) } var ( @@ -18,6 +42,6 @@ var ( resetRequestTmpl = mustParse("reset_request.html") resetSentTmpl = mustParse("reset_sent.html") resetConfirmTmpl = mustParse("reset_confirm.html") - homeTmpl = mustParse("home.html") + homeTmpl = mustParseShell("home.html") mfaVerifyTmpl = mustParse("mfa_verify.html") ) diff --git a/internal/web/templates/home.html b/internal/web/templates/home.html index 96fbb8c..aae8ae2 100644 --- a/internal/web/templates/home.html +++ b/internal/web/templates/home.html @@ -1,28 +1,27 @@ {{define "content"}} -{{if .Error}}

{{.Error}}

{{end}} +{{if .Error}}

{{icon "warning"}}{{.Error}}

{{end}} -

{{.DisplayName}} ({{.Username}})
{{.Email}}

+

{{.DisplayName}} ({{.Username}})
{{.Email}}

-

- Log out - {{if .IsAdmin}} · Admin{{end}} -

- -

Two-factor authentication (email)

-{{if .MFARequired}} -

Required for your account by policy.

-{{else}} -

Status: {{if .MFASelfEnabled}}Enabled{{else}}Disabled{{end}}

-
+
+ +
+

{{icon "shield"}} Two-factor authentication

+

{{if .MFARequired}}Enabled (required by policy){{else if .MFASelfEnabled}}Enabled{{else}}Disabled{{end}}

+
+
+ -
-{{end}} {{if .PasskeysEnabled}} -

Passkeys

-

Passkeys let you log in without a password, using a device's built-in authenticator (fingerprint, face, security key, or password manager).

+

{{icon "key"}} Passkeys

+

Log in without a password using your device's fingerprint, face, security key, or password manager.

{{if .Passkeys}} @@ -37,7 +36,7 @@

Passkeys

- + @@ -45,10 +44,10 @@

Passkeys

{{else}} -

No passkeys registered yet.

+

No passkeys registered yet.

{{end}} - +

diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 932db60..4ab3365 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -1,3 +1,8 @@ +{{/* This "layout" is only for the pre-login auth pages (login, reset, + MFA verify, error) -- a narrow centered card, not part of the tabbed + post-login app. The account page and every /admin page instead share + one "layout" template from internal/appshell/shell.html; see + mustParseShell in templates.go. */}} {{define "layout"}} @@ -6,12 +11,16 @@ {{.Title}} - DeclarativeAuth - +
-

{{.Title}}

+
+

{{.Title}}

+ {{block "title-actions" .}}{{end}} +
{{template "content" .}}
+ {{end}} From 43ff775d926e83683caf67473b4ab0d9e877061d Mon Sep 17 00:00:00 2001 From: ShyneTurtle <41578217+ShyneTurtle@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:21:01 +0200 Subject: [PATCH 4/5] Restyle web UI: icons, colors, spacing --- internal/web/static/strength.js | 10 +- internal/web/static/style.css | 375 +++++++++++++++++++--- internal/web/templates/error.html | 2 +- internal/web/templates/login.html | 17 +- internal/web/templates/mfa_verify.html | 4 +- internal/web/templates/reset_confirm.html | 2 +- internal/web/templates/reset_request.html | 2 +- 7 files changed, 351 insertions(+), 61 deletions(-) diff --git a/internal/web/static/strength.js b/internal/web/static/strength.js index 7896df5..a0065b0 100644 --- a/internal/web/static/strength.js +++ b/internal/web/static/strength.js @@ -8,7 +8,13 @@ "use strict"; var LABELS = ["very weak", "weak", "fair", "good", "strong"]; - var COLORS = ["#b91c1c", "#d97706", "#d97706", "#65a30d", "#16a34a"]; + // Colors come from the shared CSS custom properties (see style.css) + // rather than a second hardcoded copy here, so the palette only has one + // place to change. + var COLOR_VARS = ["--meter-weak", "--meter-fair", "--meter-fair", "--meter-good", "--meter-strong"]; + function strengthColor(score) { + return getComputedStyle(document.documentElement).getPropertyValue(COLOR_VARS[score]).trim(); + } var SEQUENCE_GUESS_BITS = 6.0; var MIN_SEQUENCE_RUN = 4; @@ -120,7 +126,7 @@ var score = strength(passwordInput.value); var pct = passwordInput.value ? (score + 1) * 20 : 0; bar.style.width = pct + "%"; - bar.style.background = COLORS[score]; + bar.style.background = strengthColor(score); if (!passwordInput.value) { label.textContent = " "; } else { diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 9a461e2..e88bb8d 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1,86 +1,365 @@ +/* Shared stylesheet for both the public web pages and the /admin UI -- + one set of design tokens and components instead of the two independently + drifting stylesheets this used to be split across. */ +/* Every color used anywhere below is one of these tokens (or `currentColor`, + which just inherits whichever token is already in effect) -- there should + be no hardcoded hex/rgb color outside this block and its dark-mode + override. Several tokens (--link, --button-bg, --node-*, ...) are + deliberately aliases of a smaller set of "real" colors (--accent, + --accent-soft, ...) so a palette change only has one place to edit per + color, not one per place it's used. */ :root { --bg: #f4f5f7; --card-bg: #fff; --text: #1f2933; --muted: #6b7280; - --border: #d1d5db; - --link: #2563eb; - --button-bg: #2563eb; - --button-bg-hover: #1d4ed8; + --border: #e2e4e9; + + --accent: #4f46e5; + --accent-hover: #4338ca; + --accent-soft: #eef2ff; + --link: var(--accent); + --button-bg: var(--accent); + --button-bg-hover: var(--accent-hover); --button-text: #fff; + --error: #b91c1c; + --error-soft: #fef2f2; + /* A solid button fill, as opposed to --error itself which is tuned to + read as text against --bg/--card-bg -- in dark mode those are two + different colors (see below) because a *light* error red works as + text-on-dark but fails contrast as a fill behind white button text. */ + --error-button-bg: var(--error); + --success: #15803d; + --success-soft: #f0fdf4; --meter-track: #e5e7eb; + /* Password-strength meter scale (weakest to strongest); read from JS via + getComputedStyle instead of a second hardcoded copy -- see strength.js. */ + --meter-weak: var(--error); + --meter-fair: #d97706; + --meter-good: #65a30d; + --meter-strong: var(--success); + + --node-fill: var(--accent-soft); + --node-stroke: var(--accent); + --edge-stroke: #a5adba; + /* Groups that force MFA on their members get the red/error family + instead of the default accent -- same "security-relevant, worth + standing out" reasoning as the logout tab above. */ + --node-mfa-fill: var(--error-soft); + --node-mfa-stroke: var(--error); + + /* The toggle-switch thumb stays light in both themes (it sits on a + colored/neutral track either way), so unlike most tokens here it isn't + redefined in the dark-mode block below -- see the "not aliased" + comment further down for the same pattern with --button-bg. */ + --switch-thumb: #fff; + + --radius: 10px; + --shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.08); } -/* Follows the OS/browser "prefers-color-scheme" setting automatically -- - no toggle, no JS, no stored preference: whatever the device already says - is what it gets. */ @media (prefers-color-scheme: dark) { :root { - --bg: #111827; - --card-bg: #1f2937; + --bg: #0f1117; + --card-bg: #1a1d27; --text: #e5e7eb; - --muted: #9ca3af; - --border: #374151; - --link: #60a5fa; - --button-bg: #2563eb; - --button-bg-hover: #3b82f6; + --muted: #9099ab; + --border: #2a2d3a; + + --accent: #818cf8; + --accent-hover: #a5b4fc; + --accent-soft: #1e2140; + --link: var(--accent); + /* Not aliased to --accent: buttons keep the same punchy brand indigo in + both themes (white text always contrasts fine against it), while + --accent itself lightens in dark mode because *it's* used for text/ + links that need to contrast against --bg instead. */ + --button-bg: #4f46e5; + --button-bg-hover: #6366f1; --button-text: #fff; + --error: #f87171; - --meter-track: #374151; + --error-soft: #2a1618; + --error-button-bg: #dc2626; + --success: #4ade80; + --success-soft: #142218; + --meter-track: #2a2d3a; + --meter-weak: var(--error); + --meter-fair: #f59e0b; + --meter-good: #84cc16; + --meter-strong: var(--success); + + --node-fill: var(--accent-soft); + --node-stroke: var(--accent); + --edge-stroke: #565c73; + + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); } } +* { box-sizing: border-box; } body { - font-family: system-ui, sans-serif; + font-family: -apple-system, "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); + margin: 0; +} +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +h1 { font-size: 1.4rem; margin: 0; } +h2 { font-size: 1.05rem; margin: 1.75rem 0 0.75rem; } +h2:first-child { margin-top: 0; } +p { line-height: 1.5; } + +/* A toggle switch to the left of its section's heading, with the current + status shown as a subtitle under the heading instead of as text next to + the switch (see the MFA toggle). The row carries the h2's usual margin + so the heading inside can be zeroed out without losing that spacing. */ +.mfa-row { display: flex; align-items: center; gap: 0.85rem; margin: 1.75rem 0 0.75rem; } +.mfa-row:first-child { margin-top: 0; } +.mfa-row h2 { margin: 0; } +.mfa-row .muted { margin: 0.15rem 0 0; } + +svg.icon, nav svg { width: 18px; height: 18px; flex: none; } + +/* ---- Public web pages: a single centered card ---- */ +body.web-page { display: flex; justify-content: center; - padding-top: 10vh; + align-items: flex-start; + padding-top: 8vh; + min-height: 100vh; } .card { background: var(--card-bg); - border-radius: 8px; - box-shadow: 0 1px 4px rgba(0,0,0,0.1); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); padding: 2rem; - width: 320px; } -h1 { font-size: 1.25rem; margin-top: 0; } -label { display: block; margin-bottom: 1rem; font-size: 0.9rem; } -input { +body.web-page .card { width: min(420px, 92vw); } +.web-page .card h1 { margin-bottom: 0; } + +/* Title row with room for an action on the far right (e.g. the "Log out" + button on the account page) -- plain h1 when a page has nothing to put + there, since .page-title just falls back to a single flex child. */ +.page-title { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; } +.page-title h1 { margin-bottom: 0; } + +label { display: block; margin-bottom: 0.85rem; font-size: 0.85rem; color: var(--muted); } +label > span, label > strong { color: var(--text); } +input[type=text], input[type=email], input[type=password], textarea { display: block; width: 100%; - padding: 0.5rem; - margin-top: 0.25rem; + padding: 0.4rem 0.55rem; + margin-top: 0.3rem; box-sizing: border-box; - background: var(--card-bg); + background: var(--bg); color: var(--text); border: 1px solid var(--border); - border-radius: 4px; + border-radius: 6px; + font-size: 0.9rem; + line-height: 1.3; } -button { width: 100%; padding: 0.6rem; background: var(--button-bg); color: var(--button-text); border: none; border-radius: 4px; cursor: pointer; } -button:hover { background: var(--button-bg-hover); } -.error { color: var(--error); font-size: 0.9rem; } -a { color: var(--link); } - -.strength-meter { - height: 6px; - background: var(--meter-track); - border-radius: 3px; - margin-top: -0.5rem; - margin-bottom: 0.25rem; - overflow: hidden; +input:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +textarea { font-family: ui-monospace, "SF Mono", Consolas, monospace; min-height: 520px; } + +/* Toggle switch -- a +

+ +
@@ -9,13 +14,10 @@ - -

Forgot your password?

+

Forgot your password?

+
-

-

- {{end}} diff --git a/internal/web/templates/mfa_verify.html b/internal/web/templates/mfa_verify.html index d1e5ecb..5a82b11 100644 --- a/internal/web/templates/mfa_verify.html +++ b/internal/web/templates/mfa_verify.html @@ -1,6 +1,6 @@ {{define "content"}} -{{if .Error}}

{{.Error}}

{{end}} -

We've emailed you a 6-digit verification code. Enter it below to finish logging in.

+{{if .Error}}

{{icon "warning"}}{{.Error}}

{{end}} +

We've emailed you a 6-digit verification code. Enter it below to finish logging in.