diff --git a/internal/core/org_public_tabs_test.go b/internal/core/org_public_tabs_test.go new file mode 100644 index 0000000..8fcd9f6 --- /dev/null +++ b/internal/core/org_public_tabs_test.go @@ -0,0 +1,49 @@ +package core + +import ( + "strings" + "testing" +) + +// TestPublicOrgPageHidesMembersTab: the public org page never shows the Members +// tab — membership isn't public (only the admin list is), and the tab would 404 +// on the root host anyway. It must stay hidden even for a signed-in member +// viewing the page via the identity beacon. +func TestPublicOrgPageHidesMembersTab(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + u, err := st.CreateUser(ctx, "orgmember", "") + if err != nil { + t.Fatal(err) + } + org, err := st.CreateOrg(ctx, "Tabs Org", u.ID) // creator is an admin member + if err != nil { + t.Fatal(err) + } + membersLink := "/orgs/" + org.Slug + "/members" + + anon := readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug)) + if strings.Contains(anon, membersLink) { + t.Fatal("anonymous public org page shows a Members tab") + } + + // Drop a root identity cookie for the member (as a fresh sign-in would), then + // view the public page as that member. + loginID, _ := st.CreateLogin(ctx, u.ID) + code, _ := st.CreateAuthCode(ctx, u.ID, loginID, "/") + beacon := do(t, ts, h.root, "/session/beacon?code="+code) + beacon.Body.Close() + sess := cookieByName(beacon, "meshtender_session") + if sess == nil { + t.Fatal("beacon set no identity cookie") + } + + member := readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug, sess)) + if strings.Contains(member, membersLink) { + t.Fatal("member viewing the public org page still sees the Members tab") + } + // The tabs that ARE public should still be there. + if !strings.Contains(member, "/orgs/"+org.Slug+"/repeaters") { + t.Fatal("public org page missing the Repeaters tab") + } +} diff --git a/internal/core/orgs.go b/internal/core/orgs.go index bb0c047..34f32e2 100644 --- a/internal/core/orgs.go +++ b/internal/core/orgs.go @@ -170,8 +170,11 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org * } uid := s.Auth.CurrentUserID(r.Context()) s.Render(w, r, "org_public.html", map[string]any{ - "Org": org, - "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", isMember, isAdmin), + "Org": 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), "Admins": admins, "MemberCount": memberCount, "RepeaterCount": repeaterCount, diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go index d13d092..d97e944 100644 --- a/internal/marketing/orgs.go +++ b/internal/marketing/orgs.go @@ -85,8 +85,11 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org * } uid := s.Auth.CurrentUserID(r.Context()) s.Render(w, r, "org_public.html", map[string]any{ - "Org": org, - "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", isMember, isAdmin), + "Org": 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), "Admins": admins, "MemberCount": memberCount, "RepeaterCount": repeaterCount,