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"}}
+
+
+
+
+
+
+{{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(``)
+ 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(``
- 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.
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).
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