mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
Make org actions consistent
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
{{define "title"}}{{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Organization · you're {{if .IsAdmin}}an admin{{else}}a member{{end}}</div>
|
||||
<h2 class="page-title">{{.Org.Name}}</h2>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<div class="dropdown">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" href="/orgs/{{.Org.Slug}}?view=public">{{template "icon-world" "dropdown-item-icon"}}View public page</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<form method="post" action="/orgs/{{.Org.Slug}}/leave" class="m-0" data-confirm="Leave this organization?">
|
||||
<button type="submit" class="dropdown-item text-danger">{{template "icon-logout" "dropdown-item-icon"}}Leave organization</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "header"}}{{template "org-header" .}}{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{{define "title"}}Members · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}}</div>
|
||||
<h2 class="page-title">Members</h2>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "header"}}{{template "org-header" .}}{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
+31
-13
@@ -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
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
{{define "title"}}Configuration · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}}</div>
|
||||
<h2 class="page-title">Recommended Configuration</h2>
|
||||
</div>
|
||||
{{if .CanEdit}}
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">{{template "icon-pencil" "me-1"}}Edit</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "header"}}{{template "org-header" .}}{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
{{define "title"}}{{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Organization{{if .IsMember}} · public preview{{end}}</div>
|
||||
<h2 class="page-title">{{.Org.Name}}</h2>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
{{if .IsMember}}
|
||||
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Org.Slug}}">{{template "icon-arrow-right" "me-1"}}Go to organization</a>
|
||||
{{else if .CanJoin}}
|
||||
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Org.Slug}}/join">{{template "icon-users" "me-1"}}Join organization</a>
|
||||
{{else}}
|
||||
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Org.Slug}}/join">Sign in to join</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "header"}}{{template "org-header" .}}{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
<!-- Summary stats -->
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{{define "title"}}Repeaters · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}}</div>
|
||||
<h2 class="page-title">Repeaters</h2>
|
||||
</div>
|
||||
</div>
|
||||
{{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"}}
|
||||
|
||||
@@ -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"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Organization{{if and (not .Nav.Manage) .Nav.CanGoToOrg}} · public preview{{end}}</div>
|
||||
<h2 class="page-title">{{.Nav.Name}}</h2>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
{{if .Nav.Manage}}
|
||||
<div class="dropdown">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" data-testid="org-actions">Actions</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
{{if .Nav.IsAdmin}}<a class="dropdown-item" href="/orgs/{{.Nav.Slug}}/config/edit">{{template "icon-settings" "dropdown-item-icon"}}Edit configuration</a>{{end}}
|
||||
<a class="dropdown-item" href="/orgs/{{.Nav.Slug}}?view=public">{{template "icon-world" "dropdown-item-icon"}}View public page</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<form method="post" action="/orgs/{{.Nav.Slug}}/leave" class="m-0" data-confirm="Leave this organization?">
|
||||
<button type="submit" class="dropdown-item text-danger">{{template "icon-logout" "dropdown-item-icon"}}Leave organization</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{else if .Nav.CanGoToOrg}}
|
||||
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Nav.Slug}}">{{template "icon-arrow-right" "me-1"}}Go to organization</a>
|
||||
{{else if .Nav.CanJoin}}
|
||||
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Nav.Slug}}/join">{{template "icon-users" "me-1"}}Join organization</a>
|
||||
{{else}}
|
||||
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Nav.Slug}}/join">Sign in to join</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "org-tabs"}}
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item"><a class="nav-link{{if eq .Active "home"}} active{{end}}" href="/orgs/{{.Slug}}">{{template "icon-world" "me-1"}}Home</a></li>
|
||||
|
||||
Reference in New Issue
Block a user