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}} -
- {{template "icon-pencil" "me-1"}}Edit -
- {{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}}

-
-
- {{if .IsMember}} - {{template "icon-arrow-right" "me-1"}}Go to organization - {{else if .CanJoin}} - {{template "icon-users" "me-1"}}Join organization - {{else}} - Sign in to join - {{end}} -
-
-{{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}}

+
+
+ {{if .Nav.Manage}} + + {{else if .Nav.CanGoToOrg}} + {{template "icon-arrow-right" "me-1"}}Go to organization + {{else if .Nav.CanJoin}} + {{template "icon-users" "me-1"}}Join organization + {{else}} + Sign in to join + {{end}} +
+
+{{end}} + {{define "org-tabs"}}