diff --git a/internal/core/config_profile.go b/internal/core/config_profile.go
index 0f98a2e..711beb0 100644
--- a/internal/core/config_profile.go
+++ b/internal/core/config_profile.go
@@ -53,8 +53,12 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) {
}
data := map[string]any{
- "Org": org,
- "Nav": s.OrgNavFor(r.Context(), id, org.Slug, "config", isMember, role == "admin"),
+ "Org": org,
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
+ OrgID: id, Name: org.Name, Slug: org.Slug, Active: "config",
+ IsMember: isMember, IsAdmin: role == "admin", Manage: isMember,
+ CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
+ }),
"CanEdit": role == "admin",
}
var latP, lonP *float64
@@ -103,7 +107,7 @@ func (s *Handlers) pageConfigHub(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "config_edit.html", map[string]any{
"Org": org,
- "Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: orgID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}),
"Profiles": profiles,
"RegionCount": len(regions),
"PrimaryRegion": primary,
@@ -149,7 +153,7 @@ func (s *Handlers) pageProfileEdit(w http.ResponseWriter, r *http.Request) {
func (s *Handlers) renderProfileEdit(w http.ResponseWriter, r *http.Request, org *store.Org, pid int64, name, stepsText string, errs, risky []string) {
s.Render(w, r, "config_profile_edit.html", map[string]any{
"Org": org,
- "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "config", true, true),
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}),
"ProfileID": pid,
"Name": name,
"StepsText": stepsText,
@@ -266,7 +270,7 @@ func (s *Handlers) pageRegionsEdit(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "config_regions_edit.html", map[string]any{
"Org": org,
- "Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: orgID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}),
"EmptyRegion": configRegionView{AllowFlood: true},
"Regions": regionViews(regions),
"RootAllowFlood": org.RootAllowFlood,
@@ -296,7 +300,7 @@ func (s *Handlers) handleSaveRegions(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "config_regions_edit.html", map[string]any{
"Org": org,
- "Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: orgID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}),
"EmptyRegion": configRegionView{AllowFlood: true},
"Errors": errs,
"Regions": regionVs,
diff --git a/internal/core/endpoints_org_test.go b/internal/core/endpoints_org_test.go
index a171146..f296b80 100644
--- a/internal/core/endpoints_org_test.go
+++ b/internal/core/endpoints_org_test.go
@@ -129,3 +129,24 @@ func TestOrgJoinLeavePosts(t *testing.T) {
t.Fatal("leave did not remove membership")
}
}
+
+// TestOrgTabsHeaderConsistent: the shared org-header renders the Actions menu on
+// EVERY member tab (not just Home) — the fix for the menu vanishing when you switch
+// tabs. Admins also get "Edit configuration" in it.
+func TestOrgTabsHeaderConsistent(t *testing.T) {
+ t.Parallel()
+ st, ctx, ts, h := splitServer(t)
+ owner, sess := appLogin(t, ts, st, ctx, h.app, "tabsadmin") // creator = admin member
+ org, err := st.CreateOrg(ctx, "Tabs Org", owner.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tab := range []string{"", "/members", "/repeaters", "/config"} {
+ body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+tab, sess))
+ for _, want := range []string{">Actions<", "Leave organization", "View public page", "Edit configuration"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("tab %q missing %q from the shared Actions header", tab, want)
+ }
+ }
+ }
+}
diff --git a/internal/core/orgs.go b/internal/core/orgs.go
index c39ede6..10366e4 100644
--- a/internal/core/orgs.go
+++ b/internal/core/orgs.go
@@ -123,7 +123,7 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
isAdmin := role == "admin"
data := map[string]any{
"Org": org,
- "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", true, isAdmin),
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "home", IsMember: true, IsAdmin: isAdmin, Manage: true}),
"Role": role,
"IsAdmin": isAdmin,
"Members": members,
@@ -174,7 +174,11 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *
// The public view never exposes the Members tab — membership isn't public,
// only the admin list is — so build the nav as a non-member regardless of who
// is viewing (a member previews the public page via ?view=public).
- "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", false, isAdmin),
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
+ OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "home",
+ IsMember: false, IsAdmin: isAdmin, Manage: false,
+ CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
+ }),
"Admins": admins,
"MemberCount": memberCount,
"RepeaterCount": repeaterCount,
@@ -218,7 +222,7 @@ func (s *Handlers) pageOrgMembers(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "org_members.html", map[string]any{
"Org": org,
- "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "members", true, role == "admin"),
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "members", IsMember: true, IsAdmin: role == "admin", Manage: true}),
"IsAdmin": role == "admin",
"Members": members,
"Self": uid,
@@ -252,8 +256,12 @@ func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) {
return
}
s.Render(w, r, "org_repeaters.html", map[string]any{
- "Org": org,
- "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "repeaters", isMember, role == "admin"),
+ "Org": org,
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
+ OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "repeaters",
+ IsMember: isMember, IsAdmin: role == "admin", Manage: isMember,
+ CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
+ }),
"Reps": rv,
})
}
diff --git a/internal/core/root_identity_test.go b/internal/core/root_identity_test.go
index b5dfcf8..18212df 100644
--- a/internal/core/root_identity_test.go
+++ b/internal/core/root_identity_test.go
@@ -32,6 +32,13 @@ func TestRootOrgPageIdentityAwareCTA(t *testing.T) {
if strings.Contains(anon, "Go to organization") {
t.Fatalf("anonymous root org page should not show the app jump")
}
+ // The CTA must be consistent across every public tab, not just Home.
+ for _, tab := range []string{"/repeaters", "/config"} {
+ page := readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug+tab))
+ if !strings.Contains(page, "Sign in to join") || strings.Contains(page, "Go to organization") {
+ t.Fatalf("anonymous root %s tab CTA inconsistent with Home", tab)
+ }
+ }
// Drop a root identity cookie for the member via the beacon (as a fresh
// sign-in on the app host would).
@@ -52,6 +59,13 @@ func TestRootOrgPageIdentityAwareCTA(t *testing.T) {
if strings.Contains(member, "Sign in to join") {
t.Fatalf("member root org page should not show the sign-in CTA")
}
+ // Same consistency for the member: "Go to organization" on every public tab.
+ for _, tab := range []string{"/repeaters", "/config"} {
+ page := readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug+tab, rootSess))
+ if !strings.Contains(page, "Go to organization") || strings.Contains(page, "Sign in to join") {
+ t.Fatalf("member root %s tab CTA inconsistent with Home", tab)
+ }
+ }
}
func readBody(t *testing.T, resp *http.Response) string {
diff --git a/internal/core/templates/org.html b/internal/core/templates/org.html
index a733878..9f6d9df 100644
--- a/internal/core/templates/org.html
+++ b/internal/core/templates/org.html
@@ -1,24 +1,5 @@
{{define "title"}}{{.Org.Name}} · MeshTender{{end}}
-{{define "header"}}
-
-
-
Organization · you're {{if .IsAdmin}}an admin{{else}}a member{{end}}
-
{{.Org.Name}}
-
-
-
-{{end}}
+{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}{{.Error}}
{{end}}
diff --git a/internal/core/templates/org_members.html b/internal/core/templates/org_members.html
index ece3440..678a167 100644
--- a/internal/core/templates/org_members.html
+++ b/internal/core/templates/org_members.html
@@ -1,12 +1,5 @@
{{define "title"}}Members · {{.Org.Name}} · MeshTender{{end}}
-{{define "header"}}
-
-
-
{{.Org.Name}}
-
Members
-
-
-{{end}}
+{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}{{.Error}}
{{end}}
diff --git a/internal/e2e/org_tabs_test.go b/internal/e2e/org_tabs_test.go
new file mode 100644
index 0000000..bb1d4e1
--- /dev/null
+++ b/internal/e2e/org_tabs_test.go
@@ -0,0 +1,52 @@
+//go:build browser
+
+package e2e
+
+import (
+ "strings"
+ "testing"
+
+ cdplog "github.com/chromedp/cdproto/log"
+ "github.com/chromedp/cdproto/network"
+ "github.com/chromedp/chromedp"
+)
+
+// TestE2EOrgActionsConsistent verifies the shared org header: the Actions menu is
+// present on a non-Home org tab (Members) and opens under the strict CSP
+// (Bootstrap data-bs-toggle, no inline JS), exposing the management items.
+func TestE2EOrgActionsConsistent(t *testing.T) {
+ srv := newE2EServer(t)
+ user, cookie := srv.login(t, "e2eorgtabs")
+ // CreateOrg makes the creator an admin member, so the Actions menu (with
+ // Edit configuration) renders.
+ org, err := srv.store.CreateOrg(srv.ctx, "Tabs Org", user.ID)
+ if err != nil {
+ t.Fatalf("create org: %v", err)
+ }
+
+ bctx, cancel, watch := startBrowser(t)
+ defer cancel()
+
+ membersURL := srv.appURL + "/orgs/" + org.Slug + "/members"
+ var items string
+ if err := chromedp.Run(bctx,
+ network.Enable(),
+ cdplog.Enable(),
+ setSessionCookie(cookie),
+ chromedp.Navigate(membersURL),
+ // The Actions button lives in the shared header, so it's here on the Members
+ // tab (not just Home). Opening it exercises Bootstrap JS under the CSP.
+ chromedp.WaitVisible(`[data-testid="org-actions"]`, chromedp.ByQuery),
+ chromedp.Click(`[data-testid="org-actions"]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`.dropdown-menu.show`, chromedp.ByQuery),
+ chromedp.Text(`.dropdown-menu.show`, &items, chromedp.ByQuery),
+ ); err != nil {
+ t.Fatalf("browser run against %s: %v", membersURL, err)
+ }
+ for _, want := range []string{"Edit configuration", "View public page", "Leave organization"} {
+ if !strings.Contains(items, want) {
+ t.Errorf("Actions menu on the Members tab missing %q; got %q", want, items)
+ }
+ }
+ watch.assertClean(t)
+}
diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go
index 0c61387..a13c5e5 100644
--- a/internal/marketing/orgs.go
+++ b/internal/marketing/orgs.go
@@ -57,6 +57,16 @@ func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "orgs.html", data)
}
+// publicCTA computes the public org page's call-to-action state for the current
+// viewer so it's consistent across all public tabs: a member gets "Go to
+// organization", a signed-in non-member gets "Join organization", and anyone else
+// "Sign in to join". The root host detects membership via the shared identity beacon.
+func (s *Handlers) publicCTA(r *http.Request, orgID int64) (canGoToOrg, canJoin bool) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ _, isMember, _ := s.Store.OrgRole(r.Context(), orgID, uid)
+ return isMember, uid != 0 && !isMember
+}
+
// renderOrgPublic renders the public-facing org page (name, description, admins,
// counts, and a map of repeaters opted into public display).
func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember, isAdmin bool) {
@@ -90,7 +100,11 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *
// The public view never exposes the Members tab — membership isn't public,
// only the admin list is — so build the nav as a non-member regardless of who
// is viewing (the tab also 404s on the root host, which has no members route).
- "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", false, isAdmin),
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
+ OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "home",
+ IsMember: false, IsAdmin: isAdmin, Manage: false,
+ CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
+ }),
"Admins": admins,
"MemberCount": memberCount,
"RepeaterCount": repeaterCount,
@@ -147,7 +161,11 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) {
s.NotFound(w, r)
return
}
- data := map[string]any{"Org": org, "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "config", false, false), "CanEdit": false}
+ canGoToOrg, canJoin := s.publicCTA(r, id)
+ data := map[string]any{"Org": org, "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
+ OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "config", Manage: false,
+ CanGoToOrg: canGoToOrg, CanJoin: canJoin,
+ }), "CanEdit": false}
var latP, lonP *float64
if lat, lon, ok := web.PreviewLatLon(r); ok {
latP, lonP = &lat, &lon
@@ -194,9 +212,13 @@ func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) {
last := reps[len(reps)-1]
rv.NextCursor = encodeRepCursor(last.Name, last.RepeaterID)
}
+ canGoToOrg, canJoin := s.publicCTA(r, id)
data := map[string]any{
- "Org": org,
- "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "repeaters", false, false),
+ "Org": org,
+ "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
+ OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "repeaters", Manage: false,
+ CanGoToOrg: canGoToOrg, CanJoin: canJoin,
+ }),
"Reps": rv,
"MapURL": orgMapURL(org.Slug),
}
diff --git a/internal/web/orgview.go b/internal/web/orgview.go
index 08e2209..d18cba8 100644
--- a/internal/web/orgview.go
+++ b/internal/web/orgview.go
@@ -123,21 +123,39 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
return cv, nil
}
-// OrgNav is the data the shared org-tabs sub-nav partial expects: the org slug,
-// which tab is active ("home" | "repeaters" | "members" | "config"), whether the
-// viewer is a member (the Members tab, which exposes personal info, only shows for
-// members), and whether to show the Configuration tab (hidden when the org has no
-// config, unless the viewer can edit it).
-func OrgNav(slug, active string, isMember, showConfig bool) map[string]any {
- return map[string]any{"Slug": slug, "Active": active, "IsMember": isMember, "ShowConfig": showConfig}
+// OrgNavArgs is the input to OrgNavFor and the data the shared org-header +
+// org-tabs partials expect.
+type OrgNavArgs struct {
+ OrgID int64
+ Name string
+ Slug string
+ Active string // "home" | "members" | "repeaters" | "config"
+ // IsMember gates the Members tab (personal info), so it's false on public views
+ // even when a member is previewing. IsAdmin gates the "Edit configuration"
+ // action.
+ IsMember bool
+ IsAdmin bool
+ // Manage selects the header's right-side controls: true renders the member
+ // Actions dropdown, false renders the public "Go to organization / Join" CTA.
+ Manage bool
+ // CanGoToOrg / CanJoin drive that CTA (used only when !Manage): a member
+ // previewing the public page gets "Go to organization", a signed-in non-member
+ // gets "Join organization", and anyone else gets "Sign in to join".
+ CanGoToOrg bool
+ CanJoin bool
}
-// OrgNavFor builds OrgNav, querying whether the org has any config so the
-// Configuration tab hides when empty — for everyone except admins, who always
-// see it so they can create the first profile/region.
-func (e *Env) OrgNavFor(ctx context.Context, orgID int64, slug, active string, isMember, isAdmin bool) map[string]any {
- hasConfig, _ := e.Store.OrgHasConfig(ctx, orgID)
- return OrgNav(slug, active, isMember, hasConfig || isAdmin)
+// OrgNavFor builds the org nav map, querying whether the org has any config so the
+// Configuration tab hides when empty — for everyone except admins, who always see
+// it so they can create the first profile/region.
+func (e *Env) OrgNavFor(ctx context.Context, a OrgNavArgs) map[string]any {
+ hasConfig, _ := e.Store.OrgHasConfig(ctx, a.OrgID)
+ return map[string]any{
+ "Name": a.Name, "Slug": a.Slug, "Active": a.Active,
+ "IsMember": a.IsMember, "IsAdmin": a.IsAdmin,
+ "ShowConfig": hasConfig || a.IsAdmin,
+ "Manage": a.Manage, "CanGoToOrg": a.CanGoToOrg, "CanJoin": a.CanJoin,
+ }
}
// RepeatersView is an org's repeaters for the Repeaters page. Full is true for the
diff --git a/internal/web/templates/org_config.html b/internal/web/templates/org_config.html
index bd441b3..e3fae0f 100644
--- a/internal/web/templates/org_config.html
+++ b/internal/web/templates/org_config.html
@@ -1,17 +1,5 @@
{{define "title"}}Configuration · {{.Org.Name}} · MeshTender{{end}}
-{{define "header"}}
-
-
-
{{.Org.Name}}
-
Recommended Configuration
-
- {{if .CanEdit}}
-
- {{end}}
-
-{{end}}
+{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
diff --git a/internal/web/templates/org_public.html b/internal/web/templates/org_public.html
index 2f04d86..017f536 100644
--- a/internal/web/templates/org_public.html
+++ b/internal/web/templates/org_public.html
@@ -1,21 +1,5 @@
{{define "title"}}{{.Org.Name}} · MeshTender{{end}}
-{{define "header"}}
-
-
-
Organization{{if .IsMember}} · public preview{{end}}
-
{{.Org.Name}}
-
-
-
-{{end}}
+{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
diff --git a/internal/web/templates/org_repeaters.html b/internal/web/templates/org_repeaters.html
index 7588fba..2a4b54f 100644
--- a/internal/web/templates/org_repeaters.html
+++ b/internal/web/templates/org_repeaters.html
@@ -1,12 +1,5 @@
{{define "title"}}Repeaters · {{.Org.Name}} · MeshTender{{end}}
-{{define "header"}}
-
-
-
{{.Org.Name}}
-
Repeaters
-
-
-{{end}}
+{{define "header"}}{{template "org-header" .}}{{end}}
{{/* rep-rows renders the repeater list items; shared by the full page and the
htmx "show more" fragment so appended pages match the first. */}}
{{define "rep-rows"}}
diff --git a/internal/web/templates/org_tabs.html b/internal/web/templates/org_tabs.html
index b547fa2..c5e6a1f 100644
--- a/internal/web/templates/org_tabs.html
+++ b/internal/web/templates/org_tabs.html
@@ -4,6 +4,40 @@ Active ("home" | "repeaters" | "members" | "config") and IsMember
missing map renders empty links (harmless for the empty-data compose test). Links
are same-host relative so they work on both the app host (signed-in) and the root
host (anonymous). */}}
+{{/* org-header is the shared chrome for every org tab, so the Actions menu (member
+ views) and the "Go to organization" CTA (public views) stay consistent across
+ tabs — the parallel to repeater-header. Pass the full page data (it reads
+ .Nav.* plus the renderer-injected .AppURL). .Nav.Manage picks Actions vs CTA. */}}
+{{define "org-header"}}
+
+
+
Organization{{if and (not .Nav.Manage) .Nav.CanGoToOrg}} · public preview{{end}}
+
{{.Nav.Name}}
+
+
+
+{{end}}
+
{{define "org-tabs"}}