diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b94714..186bcbc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
+### Fixed
+
+- panel: the user create/edit form placed «Back to users» at the bottom of the
+ card instead of under the heading like the delivery, domain, and domain-delete
+ pages. A shared `back_link` template now renders every drill-down up-link, and
+ `TestDrillDownPagesPlaceBackLinkAboveContent` guards its position.
+
### Changed
- panel: **Settings** shows panel credentials and DMARC aggregate reports side
diff --git a/internal/web/view/templates/delivery.html b/internal/web/view/templates/delivery.html
index f03fccb..ee88623 100644
--- a/internal/web/view/templates/delivery.html
+++ b/internal/web/view/templates/delivery.html
@@ -18,7 +18,7 @@
{{.Row.Status}}
-← Back to deliveries
+{{template "back_link" (back .BackURL "Back to deliveries")}}
{{/* The two columns: what was recorded on the left, in what order it happened
on the right. They are a pair — the facts are only worth reading against
diff --git a/internal/web/view/templates/domain_delete.html b/internal/web/view/templates/domain_delete.html
index fcb0978..e0701f4 100644
--- a/internal/web/view/templates/domain_delete.html
+++ b/internal/web/view/templates/domain_delete.html
@@ -1,7 +1,7 @@
{{define "content"}}
Delete {{.Domain.Name}}
-← Back to {{.Domain.Name}}
+{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
Confirm deletion
diff --git a/internal/web/view/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html
index f9ec373..c2ea521 100644
--- a/internal/web/view/templates/domain_detail.html
+++ b/internal/web/view/templates/domain_detail.html
@@ -1,7 +1,7 @@
{{define "content"}}
{{.Domain.Name}}
-
← All domains
+{{template "back_link" (back "/domains" "All domains")}}
{{if .Flash}}
{{.Flash}}
{{end}}
{{if .RateLimitErr}}
{{.RateLimitErr}}
{{end}}
diff --git a/internal/web/view/templates/layout.html b/internal/web/view/templates/layout.html
index d17e6e3..7e35be2 100644
--- a/internal/web/view/templates/layout.html
+++ b/internal/web/view/templates/layout.html
@@ -107,6 +107,14 @@
so its definition replaces the empty one. */}}
{{define "wide"}}{{end}}
+{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
+ function, e.g. {{template "back_link" (back "/users" "Back to users")}}.
+ Place it directly under the page
and above the cards (see .back in
+ panel.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
+{{define "back_link"}}
+← {{.Label}}
+{{end}}
+
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
inherit the link's colour through currentColor, cost no extra request, and
need no exemption from the panel's "default-src 'self'" policy. Each is
diff --git a/internal/web/view/templates/user_form.html b/internal/web/view/templates/user_form.html
index fe1c3cc..864a859 100644
--- a/internal/web/view/templates/user_form.html
+++ b/internal/web/view/templates/user_form.html
@@ -1,6 +1,8 @@
{{define "content"}}
{{if .IsEdit}}Edit user{{else}}Create user{{end}}
+{{template "back_link" (back "/users" "Back to users")}}
+
{{end}}
diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go
index 837c2ed..d45821d 100644
--- a/internal/web/view/templates_test.go
+++ b/internal/web/view/templates_test.go
@@ -279,6 +279,32 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
}
}
+// Drill-down pages carry an up-link directly under the heading and above the
+// cards. A link at the bottom of a form is easy to miss and drifts from the
+// rest of the panel, so the shared back_link template is mandatory on those
+// pages and TestDrillDownPagesPlaceBackLinkAboveContent guards its position.
+func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
+ drillDown := map[string]bool{
+ "user_form.html": true,
+ "domain_detail.html": true,
+ "domain_delete.html": true,
+ "delivery.html": true,
+ }
+ forEachTemplate(t, func(name, body string) {
+ if !drillDown[name] {
+ return
+ }
+ if !strings.Contains(body, `template "back_link"`) {
+ t.Errorf("%s is a drill-down page but does not use the shared back_link template", name)
+ }
+ backIdx := strings.Index(body, `template "back_link"`)
+ cardIdx := strings.Index(body, `class="card`)
+ if cardIdx >= 0 && backIdx > cardIdx {
+ t.Errorf("%s places the back link after the first card", name)
+ }
+ })
+}
+
// Since the panel root redirects to the status page, a link left pointing at
// "/" silently lands on the wrong screen instead of failing — so no template may
// contain one.
diff --git a/internal/web/view/view.go b/internal/web/view/view.go
index 648dc68..cec4935 100644
--- a/internal/web/view/view.go
+++ b/internal/web/view/view.go
@@ -65,7 +65,7 @@ func New(version string) (*Engine, error) {
}
for name, files := range pageFiles {
patterns := append([]string{"templates/layout.html"}, files...)
- tmpl, err := template.New("layout.html").ParseFS(assetsFS, patterns...)
+ tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", name, err)
}
@@ -81,6 +81,17 @@ func New(version string) (*Engine, error) {
return e, nil
}
+// templateFuncs supplies helpers shared across page templates.
+func templateFuncs() template.FuncMap {
+ return template.FuncMap{
+ // back builds the map back_link reads; keeps href and label paired at
+ // the call site instead of repeating the markup.
+ "back": func(href, label string) map[string]string {
+ return map[string]string{"Href": href, "Label": label}
+ },
+ }
+}
+
// Page returns a parsed page template by logical name. It is exported for
// template guard tests that assert structural properties across all pages.
func (e *Engine) Page(name string) *template.Template {