-
Create your account
+
Create your account
{{if .Error}}
{{.Error}}
{{end}}
No email required. Passkeys are the easiest, most secure option — your device unlocks the account with a fingerprint, face, or PIN.
diff --git a/internal/core/heading_structure_test.go b/internal/core/heading_structure_test.go
new file mode 100644
index 0000000..4c9b7cd
--- /dev/null
+++ b/internal/core/heading_structure_test.go
@@ -0,0 +1,189 @@
+package core
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/jleight/meshtender/internal/store"
+)
+
+var headingRe = regexp.MustCompile(`(?i)
naming the page, and no level is skipped on the
+// way down (an h1 followed by an h3 leaves a hole in the outline that screen-reader
+// users navigating by level fall through).
+func checkHeadingOutline(t *testing.T, label, html string) {
+ t.Helper()
+
+ var levels []int
+ for _, m := range headingRe.FindAllStringSubmatch(html, -1) {
+ n, err := strconv.Atoi(m[1])
+ if err != nil {
+ t.Fatalf("%s: unparseable heading %q", label, m[0])
+ }
+ levels = append(levels, n)
+ }
+ if len(levels) == 0 {
+ t.Errorf("%s: page has no headings at all", label)
+ return
+ }
+
+ h1s := 0
+ for _, l := range levels {
+ if l == 1 {
+ h1s++
+ }
+ }
+ switch {
+ case h1s == 0:
+ t.Errorf("%s: no — the page has no top-level heading (outline starts at h%d)",
+ label, levels[0])
+ case h1s > 1:
+ t.Errorf("%s: %d elements — exactly one should name the page; sequence: %s",
+ label, h1s, seq(levels))
+ }
+ if levels[0] != 1 {
+ t.Errorf("%s: outline starts at h%d, not h1 (sequence: %s)", label, levels[0], seq(levels))
+ }
+ for i := 1; i < len(levels); i++ {
+ if levels[i] > levels[i-1]+1 {
+ t.Errorf("%s: heading level jumps h%d → h%d (skips h%d); sequence: %s",
+ label, levels[i-1], levels[i], levels[i-1]+1, seq(levels))
+ break // one report per page is enough to act on
+ }
+ }
+}
+
+func seq(levels []int) string {
+ parts := make([]string, len(levels))
+ for i, l := range levels {
+ parts[i] = fmt.Sprintf("h%d", l)
+ }
+ return strings.Join(parts, " ")
+}
+
+// TestPageHeadingOutlines walks a representative page from every layout and header
+// source — the default base header, the repeater-tabs header, the org-tabs header,
+// the auth layout (which has no page header, so the card title carries it), the
+// public root layout, and the branded error page — and checks each one's heading
+// outline.
+//
+// Regression for audit findings A1 (28 pages had no ; page titles were
+// ) and A2 (the landing page ran h1 → h3 → h2, skipping a
+// level and then jumping backwards).
+func TestPageHeadingOutlines(t *testing.T) {
+ t.Parallel()
+ st, ctx, ts, h := splitServer(t)
+
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ t.Fatalf("cookiejar: %v", err)
+ }
+ user := seedSession(t, ts, st, ctx, jar, "headinguser")
+ cookies := jar.Cookies(mustURL(t, ts.URL))
+
+ rep, err := st.CreateRepeater(ctx, &store.Repeater{
+ OwnerID: user.ID, Name: "Heading Test Relay", PublicKeyHex: strings.Repeat("e", 64),
+ RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
+ })
+ if err != nil {
+ t.Fatalf("create repeater: %v", err)
+ }
+ org, err := st.CreateOrg(ctx, "Heading Test Org", user.ID)
+ if err != nil {
+ t.Fatalf("create org: %v", err)
+ }
+
+ pages := []struct {
+ label, host, path string
+ anon bool // fetch without the session cookie
+ }{
+ // Root (public) host — the landing page is A2's subject.
+ {"root landing", h.root, "/", false},
+ {"root org directory", h.root, "/orgs", false},
+ {"root docs", h.root, "/docs", false},
+ {"root public org page", h.root, "/orgs/" + org.Slug, false},
+ {"root 404 (error page)", h.root, "/no-such-page", false},
+ // Auth host — authbase has no page header, so the card title is the heading.
+ {"auth login", h.auth, "/login", true},
+ {"auth signup", h.auth, "/signup", true},
+ // App host — covers base's own header, repeater-tabs and org-tabs headers.
+ {"app dashboard", h.app, "/", false},
+ {"app repeater list", h.app, "/repeaters", false},
+ {"app repeater overview", h.app, "/repeaters/" + rep.PublicID, false},
+ {"app repeater sharing", h.app, "/repeaters/" + rep.PublicID + "/share", false},
+ {"app my orgs", h.app, "/orgs", false},
+ {"app org member view", h.app, "/orgs/" + org.Slug, false},
+ {"app 404 (error page)", h.app, "/no-such-page", false},
+ }
+
+ for _, p := range pages {
+ send := cookies
+ if p.anon {
+ send = nil
+ }
+ resp := do(t, ts, p.host, p.path, send...)
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ t.Fatalf("%s: read body: %v", p.label, err)
+ }
+ // A redirect would mean the fixture didn't grant access; the outline of a
+ // redirect body is meaningless, so surface it rather than silently passing.
+ if resp.StatusCode >= 300 && resp.StatusCode < 400 {
+ t.Fatalf("%s: unexpected %d redirect to %q — fixture problem",
+ p.label, resp.StatusCode, resp.Header.Get("Location"))
+ }
+ checkHeadingOutline(t, p.label, string(body))
+ }
+}
+
+// TestModalFragmentHeadings covers the htmx modal fragments separately: they're
+// swapped into an already-rendered page, so their heading has to fit under that
+// page's outline (h1 page → h2 dialog title) rather than restart it.
+func TestModalFragmentHeadings(t *testing.T) {
+ t.Parallel()
+ st, ctx, ts, h := splitServer(t)
+
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ t.Fatalf("cookiejar: %v", err)
+ }
+ user := seedSession(t, ts, st, ctx, jar, "modaluser")
+ cookies := jar.Cookies(mustURL(t, ts.URL))
+ rep, err := st.CreateRepeater(ctx, &store.Repeater{
+ OwnerID: user.ID, Name: "Modal Test Relay", PublicKeyHex: strings.Repeat("f", 64),
+ RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
+ })
+ if err != nil {
+ t.Fatalf("create repeater: %v", err)
+ }
+
+ // The invite modal is the representative fragment; all modal titles share the
+ // same convention.
+ resp := do(t, ts, h.app, "/repeaters/"+rep.PublicID+"/share/link/new", cookies...)
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("invite modal fragment = %d, want 200", resp.StatusCode)
+ }
+
+ found := headingRe.FindAllStringSubmatch(string(body), -1)
+ if len(found) == 0 {
+ t.Fatal("invite modal fragment has no heading — the dialog has nothing to label it")
+ }
+ if got := found[0][1]; got != "2" {
+ t.Errorf("modal title is h%s, want h2 (it sits under the page's h1; an h5 there "+
+ "would skip three levels)", got)
+ }
+}
diff --git a/internal/core/templates/add_repeater.html b/internal/core/templates/add_repeater.html
index 59719c3..0262ae0 100644
--- a/internal/core/templates/add_repeater.html
+++ b/internal/core/templates/add_repeater.html
@@ -1,7 +1,7 @@
{{define "title"}}Add a repeater · MeshTender{{end}}
{{define "header"}}
-
Add a repeater
+
Add a repeater
{{end}}
{{define "content"}}
@@ -27,7 +27,7 @@
-
{{template "icon-antenna" "me-1"}}Add a repeater that's already on the network
+
{{template "icon-antenna" "me-1"}}Add a repeater that's already on the network
Register a repeater that's already configured and on the mesh, then control it
remotely through a KISS modem over the air. You'll grant MeshTender admin yourself first.
This is the usual choice.
@@ -38,7 +38,7 @@
-
{{template "icon-plug" "me-1"}}Set up a brand-new repeater
+
{{template "icon-plug" "me-1"}}Set up a brand-new repeater
Connect a freshly-flashed repeater over USB serial and let
MeshTender generate and run the whole setup — identity, radio, region, name, and the admin grant —
straight down the wire. Best for a repeater that isn't on the network yet.
@@ -64,7 +64,7 @@
{{template "icon-alert" "alert-icon"}}
-
This grants full admin control to MeshTender
+
This grants full admin control to MeshTender
- This grants admin to MeshTender itself — not to other
MeshTender users. Letting specific people operate the repeater is a separate step you control later
@@ -84,7 +84,7 @@
{{template "icon-map-pin" "alert-icon"}}
-
MeshTender will read the repeater's location
+
MeshTender will read the repeater's location
When you confirm this repeater — or use “Fetch location” later — MeshTender reads its coordinates by
running get lat / get lon through your modem, on its own behalf
@@ -251,7 +251,7 @@
{{/* Review + run */}}
-
+
These commands will be sent to your repeater over USB, in order. The private key is
shown masked; it never leaves your browser.
diff --git a/internal/core/templates/admin.html b/internal/core/templates/admin.html
index 5198fa6..8eb9220 100644
--- a/internal/core/templates/admin.html
+++ b/internal/core/templates/admin.html
@@ -3,7 +3,7 @@
Administration
-
Admin
+
Admin
{{end}}
@@ -13,7 +13,7 @@
-
{{template "icon-list" "me-1"}}Command catalog
+
{{template "icon-list" "me-1"}}Command catalog
Edit risk tags and the default command sets.
@@ -23,7 +23,7 @@
-
{{template "icon-users" "me-1"}}Users & capabilities
+
{{template "icon-users" "me-1"}}Users & capabilities
Grant or revoke admin capabilities.
@@ -32,7 +32,7 @@
-
{{template "icon-list" "me-1"}}Traffic analytics
+
{{template "icon-list" "me-1"}}Traffic analytics
Visits, unique visitors, busiest pages, and traffic by surface.
@@ -40,7 +40,7 @@
-
{{template "icon-world" "me-1"}}Reverse proxy test
+
{{template "icon-world" "me-1"}}Reverse proxy test
Inspect forwarding headers and the client IP the app records.
diff --git a/internal/core/templates/admin_catalog.html b/internal/core/templates/admin_catalog.html
index aca6092..b34585d 100644
--- a/internal/core/templates/admin_catalog.html
+++ b/internal/core/templates/admin_catalog.html
@@ -3,7 +3,7 @@
Administration
-
Command catalog
+
Command catalog
{{end}}
@@ -23,7 +23,7 @@
{{range .Groups}}
-
+
diff --git a/internal/core/templates/admin_user_history.html b/internal/core/templates/admin_user_history.html
index a71d40e..ef42978 100644
--- a/internal/core/templates/admin_user_history.html
+++ b/internal/core/templates/admin_user_history.html
@@ -3,7 +3,7 @@
Administration
-
Username history
+
Username history
{{end}}
@@ -35,7 +35,7 @@
{{/* history-modal is the htmx fragment swapped into the shared modal's content. */}}
{{define "history-modal"}}
diff --git a/internal/core/templates/admin_user_permissions.html b/internal/core/templates/admin_user_permissions.html
index 33b195e..1df95c0 100644
--- a/internal/core/templates/admin_user_permissions.html
+++ b/internal/core/templates/admin_user_permissions.html
@@ -3,7 +3,7 @@
closes the modal — no extra client JS needed. */}}
{{define "permissions-modal"}}