From d4ff79fa64f7a97b87792c53c5b5d549465270c1 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Mon, 22 Jun 2026 15:24:38 -0400 Subject: [PATCH] Redesigned org page --- internal/core/config_profile.go | 103 ++++---- internal/core/org_permissions.go | 40 ++- internal/core/orgs.go | 83 ++++++- .../{config.html => config_edit.html} | 72 +----- internal/core/templates/org.html | 102 ++------ internal/core/templates/org_members.html | 97 ++++++++ ...permissions.html => permissions_edit.html} | 13 +- internal/core/web.go | 8 +- internal/marketing/marketing.go | 7 +- internal/marketing/orgs.go | 81 ++++++ internal/web/env.go | 6 +- internal/web/orgview.go | 232 ++++++++++++++++++ internal/web/static/app.css | 13 +- internal/web/templates/org_config.html | 73 ++++++ internal/web/templates/org_permissions.html | 45 ++++ internal/web/templates/org_public.html | 47 ++-- internal/web/templates/org_repeaters.html | 63 +++++ internal/web/templates/org_tabs.html | 15 ++ 18 files changed, 859 insertions(+), 241 deletions(-) rename internal/core/templates/{config.html => config_edit.html} (58%) create mode 100644 internal/core/templates/org_members.html rename internal/core/templates/{org_permissions.html => permissions_edit.html} (82%) create mode 100644 internal/web/orgview.go create mode 100644 internal/web/templates/org_config.html create mode 100644 internal/web/templates/org_permissions.html create mode 100644 internal/web/templates/org_repeaters.html create mode 100644 internal/web/templates/org_tabs.html diff --git a/internal/core/config_profile.go b/internal/core/config_profile.go index 3b6be40..77ef7c4 100644 --- a/internal/core/config_profile.go +++ b/internal/core/config_profile.go @@ -9,32 +9,15 @@ import ( "github.com/jleight/meshtender/internal/geo" "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" ) // maxConfigZones bounds how many zones one profile version may define, so a // malformed or abusive submission can't insert unbounded rows. const maxConfigZones = 50 -// requireOrgMember resolves {id} and verifies the current user belongs to the org -// (admin or member). Returns the org id, the role, and ok. -func (s *Handlers) requireOrgMember(w http.ResponseWriter, r *http.Request) (int64, string, bool) { - uid := s.Auth.CurrentUserID(r.Context()) - id, ok := s.orgID(r) - if !ok { - http.NotFound(w, r) - return 0, "", false - } - role, isMember, err := s.Store.OrgRole(r.Context(), id, uid) - if err != nil || !isMember { - http.NotFound(w, r) - return 0, "", false - } - return id, role, true -} - -// configZoneView is a zone rendered on the config page: the read-only block shows -// its steps; the admin editor reuses the same fields (rectangle corners derived -// from the stored geofence's bounds) to repopulate the form. +// configZoneView is a zone in the admin config editor: the form fields (rectangle +// corners derived from the stored geofence's bounds, one command per line). type configZoneView struct { Name string Priority int @@ -43,15 +26,54 @@ type configZoneView struct { MinLon string MaxLat string MaxLon string - StepsText string // editor: one command per line + StepsText string // editor: one command per line Steps []store.ConfigStep // read-only: rendered list } -// pageOrgConfig renders an org's recommended configuration. Any member sees the -// read-only reference; admins additionally get the editor. An optional ?lat=&lon= -// previews the resolved command list for a sample location. +// pageOrgConfig renders an org's recommended configuration read-only. It's +// visible to any signed-in user (org discoverability); admins get an Edit button +// linking to pageOrgConfigEdit. An optional ?lat=&lon= previews the resolved +// command list for a location. The root host serves the same page anonymously. func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) { - orgID, role, ok := s.requireOrgMember(w, r) + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + role, isMember, err := s.Store.OrgRole(r.Context(), id, uid) + if err != nil { + http.Error(w, "could not load org", http.StatusInternalServerError) + return + } + + data := map[string]any{ + "Org": org, + "Nav": web.OrgNav(org.Slug, "config", isMember), + "CanEdit": role == "admin", + } + var latP, lonP *float64 + if lat, lon, ok := web.PreviewLatLon(r); ok { + latP, lonP = &lat, &lon + data["PreviewLat"], data["PreviewLon"] = lat, lon + } + cv, err := web.BuildConfigView(r.Context(), s.Store, id, latP, lonP) + if err != nil { + http.Error(w, "could not load profile", http.StatusInternalServerError) + return + } + data["Config"] = cv + s.Render(w, r, "org_config.html", data) +} + +// pageOrgConfigEdit is the admin editor for the recommended configuration. +func (s *Handlers) pageOrgConfigEdit(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) if !ok { return } @@ -60,17 +82,15 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - data := map[string]any{ "Org": org, - "IsAdmin": role == "admin", + "Nav": web.OrgNav(org.Slug, "config", true), "EmptyZone": configZoneView{}, // prototype for the "add zone" cloner } - vid, version, err := s.Store.CurrentProfileVersion(r.Context(), orgID) switch { case errors.Is(err, store.ErrNotFound): - // No profile yet — the editor starts empty; the read view shows an empty state. + // No profile yet — the editor starts empty. case err != nil: http.Error(w, "could not load profile", http.StatusInternalServerError) return @@ -82,17 +102,10 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) { } data["HasProfile"] = true data["Version"] = version - data["BaseSteps"] = base data["BaseText"] = stepsToText(base) data["Zones"] = zoneViews(zones) - - if lat, lon, ok := previewLatLon(r); ok { - data["Preview"] = store.ResolveProfile(base, zones, &lat, &lon) - data["PreviewLat"], data["PreviewLon"] = lat, lon - } } - - s.Render(w, r, "config.html", data) + s.Render(w, r, "config_edit.html", data) } // handleSaveOrgConfig validates and publishes a new profile version (admin only). @@ -137,9 +150,9 @@ func (s *Handlers) handleSaveOrgConfig(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - s.Render(w, r, "config.html", map[string]any{ + s.Render(w, r, "config_edit.html", map[string]any{ "Org": org, - "IsAdmin": true, + "Nav": web.OrgNav(org.Slug, "config", true), "EmptyZone": configZoneView{}, "Errors": errs, "RiskyWarn": risky, @@ -306,18 +319,6 @@ func zoneViews(zones []store.Zone) []configZoneView { func formatCoord(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } -// previewLatLon parses the optional ?lat=&lon= preview coordinates. -func previewLatLon(r *http.Request) (lat, lon float64, ok bool) { - ls, ns := r.URL.Query().Get("lat"), r.URL.Query().Get("lon") - if ls == "" || ns == "" { - return 0, 0, false - } - var err1, err2 error - lat, err1 = strconv.ParseFloat(ls, 64) - lon, err2 = strconv.ParseFloat(ns, 64) - return lat, lon, err1 == nil && err2 == nil -} - // repeaterProfiles is a contributed org's recommended configuration resolved for a // specific repeater's location, for the console reference block. type repeaterProfiles struct { diff --git a/internal/core/org_permissions.go b/internal/core/org_permissions.go index e1641a5..28e6a2e 100644 --- a/internal/core/org_permissions.go +++ b/internal/core/org_permissions.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" ) // permGroup is a category of catalog commands for the org permission editor, @@ -37,7 +38,41 @@ func idSet(ids []int64) map[int64]bool { return m } +// pageOrgPermissions renders the org's requested-access policy read-only. Visible +// to any signed-in user (so prospective members can see what they'd consent to); +// admins get an Edit button. The root host serves the same page anonymously. func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + role, isMember, err := s.Store.OrgRole(r.Context(), id, uid) + if err != nil { + http.Error(w, "could not load org", http.StatusInternalServerError) + return + } + pv, err := web.BuildPermissionsView(r.Context(), s.Store, id) + if err != nil { + http.Error(w, "could not load policy", http.StatusInternalServerError) + return + } + s.Render(w, r, "org_permissions.html", map[string]any{ + "Org": org, + "Nav": web.OrgNav(org.Slug, "permissions", isMember), + "CanEdit": role == "admin", + "Perms": pv, + }) +} + +// pageOrgPermissionsEdit is the admin editor for the org's requested-access policy. +func (s *Handlers) pageOrgPermissionsEdit(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -62,8 +97,9 @@ func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load commands", http.StatusInternalServerError) return } - s.Render(w, r, "org_permissions.html", map[string]any{ + s.Render(w, r, "permissions_edit.html", map[string]any{ "Org": org, + "Nav": web.OrgNav(org.Slug, "permissions", true), "Version": version, "Groups": groupPermissions(catalog, idSet(adminIDs), idSet(memberIDs)), }) @@ -93,5 +129,5 @@ func (s *Handlers) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Reque http.Error(w, "could not publish", http.StatusInternalServerError) return } - http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/permissions", http.StatusSeeOther) } diff --git a/internal/core/orgs.go b/internal/core/orgs.go index 13ffaaf..e0fca82 100644 --- a/internal/core/orgs.go +++ b/internal/core/orgs.go @@ -118,6 +118,7 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) { isAdmin := role == "admin" data := map[string]any{ "Org": org, + "Nav": web.OrgNav(org.Slug, "home", true), "Role": role, "IsAdmin": isAdmin, "Members": members, @@ -157,6 +158,7 @@ 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": web.OrgNav(org.Slug, "home", isMember), "Admins": admins, "MemberCount": memberCount, "RepeaterCount": repeaterCount, @@ -168,6 +170,77 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org * }) } +// pageOrgMembers lists an org's members (with role management for admins). It's +// members-only: the list carries personal info (names/usernames), so non-members +// get a 404 — there is no public version of this page. +func (s *Handlers) pageOrgMembers(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + role, isMember, err := s.Store.OrgRole(r.Context(), id, uid) + if err != nil { + http.Error(w, "could not load org", http.StatusInternalServerError) + return + } + if !isMember { + http.NotFound(w, r) + return + } + members, err := s.Store.ListOrgMembers(r.Context(), id) + if err != nil { + http.Error(w, "could not load members", http.StatusInternalServerError) + return + } + s.Render(w, r, "org_members.html", map[string]any{ + "Org": org, + "Nav": web.OrgNav(org.Slug, "members", true), + "IsAdmin": role == "admin", + "Members": members, + "Self": uid, + "Error": r.URL.Query().Get("error"), + }) +} + +// pageOrgRepeaters lists an org's repeaters with a map. Members see every +// contributed repeater (with links); any other viewer sees only those opted into +// the public map. The root host serves the same page anonymously. +func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + _, isMember, err := s.Store.OrgRole(r.Context(), id, uid) + if err != nil { + http.Error(w, "could not load org", http.StatusInternalServerError) + return + } + rv, err := web.BuildRepeatersView(r.Context(), s.Store, id, isMember) + if err != nil { + http.Error(w, "could not load repeaters", http.StatusInternalServerError) + return + } + s.Render(w, r, "org_repeaters.html", map[string]any{ + "Org": org, + "Nav": web.OrgNav(org.Slug, "repeaters", isMember), + "Reps": rv, + }) +} + // handleEditOrg updates an org's slug, name, description, and region (admin only). func (s *Handlers) handleEditOrg(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOrgAdmin(w, r) @@ -260,6 +333,8 @@ func (s *Handlers) handleSetOrgMember(w http.ResponseWriter, r *http.Request) { if !ok { return } + membersURL := "/orgs/" + orgParam(r) + "/members" + memberErr := func(msg string) { web.RedirectErr(w, r, membersURL, msg) } targetID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64) if err != nil { http.NotFound(w, r) @@ -273,16 +348,16 @@ func (s *Handlers) handleSetOrgMember(w http.ResponseWriter, r *http.Request) { case "remove": err = s.Store.RemoveOrgMember(r.Context(), id, targetID) default: - orgErr(w, r, "Unknown action.") + memberErr("Unknown action.") return } if errors.Is(err, store.ErrLastAdmin) { - orgErr(w, r, "That would leave the org with no admin.") + memberErr("That would leave the org with no admin.") return } if err != nil { - orgErr(w, r, "Could not update member.") + memberErr("Could not update member.") return } - http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) + http.Redirect(w, r, membersURL, http.StatusSeeOther) } diff --git a/internal/core/templates/config.html b/internal/core/templates/config_edit.html similarity index 58% rename from internal/core/templates/config.html rename to internal/core/templates/config_edit.html index 97f8f6a..1e0982d 100644 --- a/internal/core/templates/config.html +++ b/internal/core/templates/config_edit.html @@ -1,16 +1,17 @@ -{{define "title"}}Recommended configuration · {{.Org.Name}} · MeshTender{{end}} +{{define "title"}}Edit configuration · {{.Org.Name}} · MeshTender{{end}} {{define "header"}}
{{.Org.Name}}
-

Recommended configuration

+

Edit recommended configuration

{{end}} {{define "content"}} +{{template "org-tabs" .Nav}} {{if .Errors}}
@@ -29,18 +30,14 @@

- The commands an operator should run to bring a repeater in spec for this org. - {{if .IsAdmin}}This is a reference members can copy; those with a KISS modem can apply it from a repeater's - console. Region commands can vary by location — add zones with a geofence and they'll - only apply to repeaters inside them.{{else}}Copy these into a repeater's console, or apply them directly - from the console if you have a modem connected.{{end}} + The commands an operator should run to bring a repeater in spec for this org. This is a reference + members can copy; those with a KISS modem can apply it from a repeater's console. Region commands can + vary by location — add zones with a geofence and they'll only apply to repeaters inside them.

-{{if .IsAdmin}} -{{/* ---------- Admin editor ---------- */}} -
+

Base steps

@@ -69,7 +66,7 @@
- Cancel + Cancel
@@ -85,57 +82,6 @@ function addZone() { } function removeZone(btn) { btn.closest('.zone-block').remove(); } - -{{else}} -{{/* ---------- Read-only reference ---------- */}} -{{if .HasProfile}} -
-

Base steps

- v{{.Version}} -
-
{{template "step-list" .BaseSteps}}
-
-{{range .Zones}} -
-
-

{{.Name}}

- {{if .MatchAll}}everywhere{{else}}lat {{.MinLat}}…{{.MaxLat}}, lon {{.MinLon}}…{{.MaxLon}}{{end}} · priority {{.Priority}} -
-
{{template "step-list" .Steps}}
-
-{{end}} -{{else}} -
This organization hasn't published a recommended configuration yet.
-{{end}} -{{end}} - -{{/* ---------- Location preview (admin convenience) ---------- */}} -{{if .HasProfile}} -
-

Preview for a location

-
- -
-
-
-
-
- - {{if .Preview}} -
-
Commands a repeater at {{.PreviewLat}}, {{.PreviewLon}} should run:
- {{template "step-list" .Preview}} -
- {{end}} -
-
-{{end}} -{{end}} - -{{/* step-list renders a sequence of ConfigStep as a copy-pasteable block. */}} -{{define "step-list"}} -{{if .}}
{{range .}}{{if .IsComment}}# {{.Comment}}{{else}}{{.CommandLine}}{{end}}
-{{end}}
{{else}}
No steps.
{{end}} {{end}} {{/* zone-block is one editable zone in the admin form. */}} diff --git a/internal/core/templates/org.html b/internal/core/templates/org.html index 1927859..2e54937 100644 --- a/internal/core/templates/org.html +++ b/internal/core/templates/org.html @@ -7,8 +7,6 @@
{{end}} {{define "content"}} +{{template "org-tabs" .Nav}} {{if .Error}}
{{.Error}}
{{end}} @@ -68,85 +67,8 @@
- +
- {{if .HasMap}} -
-

Repeater map

-
- -
- - - -
-
- {{end}} - -
-

Repeaters

- {{if .Repeaters}} -
- {{range .Repeaters}} -
- {{template "icon-antenna" ""}} -
- {{.Name}} -
owner {{.OwnerName}}
-
- {{template "icon-terminal" "me-1"}}Console -
- {{end}} -
- {{else}} -

No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.

- {{end}} -
- -
-
-

Members

-
-
- - - - - - -
-
-
-
- {{range .Members}} -
-
- {{.Name}} - @{{.Username}} -
- {{if eq .Role "admin"}}admin{{else}}member{{end}} - {{if and $.IsAdmin (ne .UserID $.Self)}} - - {{if eq .Role "admin"}} - - {{else}} - - {{end}} - - - {{end}} -
- {{end}} -
-
-
- - -
{{if not .IsAdmin}}

About

@@ -245,6 +167,26 @@ */}} {{end}}
+ + +
+ {{if .HasMap}} +
+

Repeaters

+
+ +
+ + + +
+
+ {{end}} +
{{template "icon-arrow-left" "me-1"}}All organizations diff --git a/internal/core/templates/org_members.html b/internal/core/templates/org_members.html new file mode 100644 index 0000000..1f24057 --- /dev/null +++ b/internal/core/templates/org_members.html @@ -0,0 +1,97 @@ +{{define "title"}}Members · {{.Org.Name}} · MeshTender{{end}} +{{define "header"}} +
+
+
{{.Org.Name}}
+

Members

+
+
+{{end}} +{{define "content"}} +{{template "org-tabs" .Nav}} +{{if .Error}}
{{.Error}}
{{end}} + +
+ +
+
+

{{len .Members}} member{{if ne (len .Members) 1}}s{{end}}

+
+ {{range .Members}} +
+
+ {{.Name}} + @{{.Username}} +
+ {{if eq .Role "admin"}}admin{{else}}member{{end}} + {{if and $.IsAdmin (ne .UserID $.Self)}} +
+ {{if eq .Role "admin"}} + + {{else}} + + {{end}} + +
+ {{end}} +
+ {{end}} +
No members match.
+
+
+
+ + +
+
+

Find members

+
+
+ + +
+
Role
+
+ + + + + + +
+
+
+
+
+ + +{{end}} diff --git a/internal/core/templates/org_permissions.html b/internal/core/templates/permissions_edit.html similarity index 82% rename from internal/core/templates/org_permissions.html rename to internal/core/templates/permissions_edit.html index 047ca9f..18bb5fb 100644 --- a/internal/core/templates/org_permissions.html +++ b/internal/core/templates/permissions_edit.html @@ -1,13 +1,18 @@ -{{define "title"}}Permissions · {{.Org.Name}} · MeshTender{{end}} +{{define "title"}}Edit requested access · {{.Org.Name}} · MeshTender{{end}} {{define "header"}}
{{.Org.Name}}
-

Permissions

+

Edit requested access

+
+
{{end}} {{define "content"}} +{{template "org-tabs" .Nav}} +

@@ -19,7 +24,7 @@

-
+ {{range .Groups}}

{{.Name}}

@@ -42,7 +47,7 @@
- Cancel + Cancel
diff --git a/internal/core/web.go b/internal/core/web.go index 7ccdbfb..adecf58 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -119,6 +119,8 @@ func (s *Handlers) appRouter() chi.Router { r.Use(s.Auth.RequireUser) r.Get("/orgs", s.pageMyOrgs) // your organizations; discovery is on root r.Get("/orgs/{id}", s.pageOrg) // member view (non-members redirect to root) + r.Get("/orgs/{id}/members", s.pageOrgMembers) + r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters) r.Get("/repeaters", s.pageRepeaters) r.Post("/logout", s.handleLogout) r.Get("/repeaters/add", s.pageAddRepeater) @@ -153,9 +155,11 @@ func (s *Handlers) appRouter() chi.Router { r.Post("/orgs/{id}/leave", s.handleLeaveOrg) r.Post("/orgs/{id}/members/{userID}", s.handleSetOrgMember) r.Get("/orgs/{id}/permissions", s.pageOrgPermissions) - r.Post("/orgs/{id}/permissions", s.handleSaveOrgPermissions) + r.Get("/orgs/{id}/permissions/edit", s.pageOrgPermissionsEdit) + r.Post("/orgs/{id}/permissions/edit", s.handleSaveOrgPermissions) r.Get("/orgs/{id}/config", s.pageOrgConfig) - r.Post("/orgs/{id}/config", s.handleSaveOrgConfig) + r.Get("/orgs/{id}/config/edit", s.pageOrgConfigEdit) + r.Post("/orgs/{id}/config/edit", s.handleSaveOrgConfig) // Custom org domains are hidden for now — the hosting/TLS infrastructure // isn't in place yet. Leave the handlers in tree but don't expose them. // r.Post("/orgs/{id}/domains", s.handleAddOrgDomain) diff --git a/internal/marketing/marketing.go b/internal/marketing/marketing.go index 4bcdc25..62a175c 100644 --- a/internal/marketing/marketing.go +++ b/internal/marketing/marketing.go @@ -41,8 +41,11 @@ func (s *Handlers) Routes() chi.Router { r.Use(s.Auth.Sessions.LoadAndSave) s.SharedRoutes(r) r.Get("/", s.pageLanding) - r.Get("/orgs", s.pageOrgs) // public organization directory - r.Get("/orgs/{id}", s.pageOrgPublic) // public org page + r.Get("/orgs", s.pageOrgs) // public organization directory + r.Get("/orgs/{id}", s.pageOrgPublic) // public org page + r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters) // public repeater list + map + r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config + r.Get("/orgs/{id}/permissions", s.pageOrgPermissions) // public requested access return r } diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go index 19fedbc..dc75c8c 100644 --- a/internal/marketing/orgs.go +++ b/internal/marketing/orgs.go @@ -8,6 +8,7 @@ import ( "time" "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" ) // pageOrgs is the public organization directory. It renders anonymously — the @@ -78,6 +79,7 @@ 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": web.OrgNav(org.Slug, "home", isMember), "Admins": admins, "MemberCount": memberCount, "RepeaterCount": repeaterCount, @@ -89,6 +91,85 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org * }) } +// pageOrgConfig renders an org's recommended configuration read-only for anonymous +// visitors (the app host serves the same shared page to signed-in users). +func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) { + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + data := map[string]any{"Org": org, "Nav": web.OrgNav(org.Slug, "config", false), "CanEdit": false} + var latP, lonP *float64 + if lat, lon, ok := web.PreviewLatLon(r); ok { + latP, lonP = &lat, &lon + data["PreviewLat"], data["PreviewLon"] = lat, lon + } + cv, err := web.BuildConfigView(r.Context(), s.Store, id, latP, lonP) + if err != nil { + http.Error(w, "could not load profile", http.StatusInternalServerError) + return + } + data["Config"] = cv + s.Render(w, r, "org_config.html", data) +} + +// pageOrgPermissions renders an org's requested-access policy read-only for +// anonymous visitors (so prospective members can see it before joining). +func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) { + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + pv, err := web.BuildPermissionsView(r.Context(), s.Store, id) + if err != nil { + http.Error(w, "could not load policy", http.StatusInternalServerError) + return + } + s.Render(w, r, "org_permissions.html", map[string]any{ + "Org": org, + "Nav": web.OrgNav(org.Slug, "permissions", false), + "CanEdit": false, + "Perms": pv, + }) +} + +// pageOrgRepeaters renders an org's public repeaters (those opted into the public +// map) with a map, for anonymous visitors. +func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) { + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + rv, err := web.BuildRepeatersView(r.Context(), s.Store, id, false) + if err != nil { + http.Error(w, "could not load repeaters", http.StatusInternalServerError) + return + } + s.Render(w, r, "org_repeaters.html", map[string]any{ + "Org": org, + "Nav": web.OrgNav(org.Slug, "repeaters", false), + "Reps": rv, + }) +} + // orgCursor is an opaque directory position: the sort and search it belongs to, // plus the sort key and id of the last org on the current page. type orgCursor struct { diff --git a/internal/web/env.go b/internal/web/env.go index eaf8e43..ffe04a9 100644 --- a/internal/web/env.go +++ b/internal/web/env.go @@ -22,14 +22,14 @@ import ( "github.com/jleight/meshtender/internal/store" ) -//go:embed templates/base.html templates/icons.html templates/org_public.html +//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/org_public.html templates/org_config.html templates/org_permissions.html templates/org_repeaters.html var sharedTemplatesFS embed.FS // sharedPages are full content pages (not just layout partials) that more than // one surface renders. They're composed onto the base layout for every surface, // so the root host (anonymous) and the app host (signed-in) can render the same // public org page without duplicating the template. -var sharedPages = []string{"templates/org_public.html"} +var sharedPages = []string{"templates/org_public.html", "templates/org_config.html", "templates/org_permissions.html", "templates/org_repeaters.html"} //go:embed static/* var staticFS embed.FS @@ -110,7 +110,7 @@ type Renderer struct { // each of the surface's own *.html pages onto it. Each page redefines the // content/title/header blocks, so every page gets its own cloned template set. func NewRenderer(cfg *config.Config, surfaceTemplates fs.FS) (*Renderer, error) { - base, err := template.New("").ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html") + base, err := template.New("").ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html", "templates/org_tabs.html") if err != nil { return nil, err } diff --git a/internal/web/orgview.go b/internal/web/orgview.go new file mode 100644 index 0000000..917431c --- /dev/null +++ b/internal/web/orgview.go @@ -0,0 +1,232 @@ +package web + +import ( + "context" + "errors" + "net/http" + "sort" + "strconv" + + "github.com/jleight/meshtender/internal/store" +) + +// This file builds the read-only org "Configuration" and "Requested access" +// views. They live here (not in core) so both the app host (signed-in) and the +// root/marketing host (anonymous) can render the same shared templates — the +// same split that lets both surfaces render org_public.html. + +// ZoneView is a profile zone rendered read-only: the geofence reduced to its +// bounding box as display strings (empty when the zone matches everywhere). +type ZoneView struct { + Name string + Priority int + MatchAll bool + MinLat string + MinLon string + MaxLat string + MaxLon string + Steps []store.ConfigStep +} + +// ConfigView is an org's recommended configuration for display. Preview holds +// the commands resolved for the optional preview location (nil when no location +// was requested or no profile exists). +type ConfigView struct { + HasProfile bool + Version int + BaseSteps []store.ConfigStep + Zones []ZoneView + Preview []store.ConfigStep +} + +// BuildConfigView loads the org's current published config profile for read-only +// display. An org with no published profile yields HasProfile=false (not an error). +// When lat/lon are non-nil, Preview is the command list resolved for that location. +func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, lat, lon *float64) (ConfigView, error) { + vid, version, err := st.CurrentProfileVersion(ctx, orgID) + if errors.Is(err, store.ErrNotFound) { + return ConfigView{}, nil + } + if err != nil { + return ConfigView{}, err + } + base, zones, err := st.ProfileVersion(ctx, vid) + if err != nil { + return ConfigView{}, err + } + cv := ConfigView{HasProfile: true, Version: version, BaseSteps: base} + if lat != nil && lon != nil { + cv.Preview = store.ResolveProfile(base, zones, lat, lon) + } + for _, z := range zones { + zv := ZoneView{Name: z.Name, Priority: z.Priority, Steps: z.Steps} + if minLat, minLon, maxLat, maxLon, ok := z.Geofence.Bounds(); ok { + zv.MinLat = formatCoord(minLat) + zv.MinLon = formatCoord(minLon) + zv.MaxLat = formatCoord(maxLat) + zv.MaxLon = formatCoord(maxLon) + } else { + zv.MatchAll = true + } + cv.Zones = append(cv.Zones, zv) + } + return cv, nil +} + +func formatCoord(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } + +// OrgNav is the data the shared org-tabs sub-nav partial expects: the org slug, +// which tab is active ("home" | "repeaters" | "members" | "config" | +// "permissions"), and whether the viewer is a member (the Members tab, which +// exposes personal info, only shows for members). +func OrgNav(slug, active string, isMember bool) map[string]any { + return map[string]any{"Slug": slug, "Active": active, "IsMember": isMember} +} + +// RepeatersView is an org's repeaters for the Repeaters page. Full is true for the +// member view (every contributed repeater, with owner and links); false is the +// public view (only repeaters opted into the public map, no links). +type RepeatersView struct { + Repeaters []store.OrgRepeaterInfo + HasMap bool + Full bool +} + +// BuildRepeatersView loads an org's repeaters for display. full selects the member +// view (all contributed repeaters) vs the public view (public-map repeaters only). +func BuildRepeatersView(ctx context.Context, st *store.Store, orgID int64, full bool) (RepeatersView, error) { + var reps []store.OrgRepeaterInfo + var err error + if full { + reps, err = st.ListOrgRepeaters(ctx, orgID) + } else { + reps, err = st.ListPublicMapRepeaters(ctx, orgID) + } + if err != nil { + return RepeatersView{}, err + } + hasMap := false + for _, rp := range reps { + if rp.HasLocation { + hasMap = true + break + } + } + return RepeatersView{Repeaters: reps, HasMap: hasMap, Full: full}, nil +} + +// PreviewLatLon parses the optional ?lat=&lon= config-preview coordinates. +func PreviewLatLon(r *http.Request) (lat, lon float64, ok bool) { + ls, ns := r.URL.Query().Get("lat"), r.URL.Query().Get("lon") + if ls == "" || ns == "" { + return 0, 0, false + } + var err1, err2 error + lat, err1 = strconv.ParseFloat(ls, 64) + lon, err2 = strconv.ParseFloat(ns, 64) + return lat, lon, err1 == nil && err2 == nil +} + +// PermCmd is one command in the read-only permissions view, flagged with the +// tiers (member/admin) the org's current policy allows it for. +type PermCmd struct { + Template string + Risky bool + Member bool + Admin bool +} + +// PermGroup is a feature area's commands for the permissions view. +type PermGroup struct { + Name string + Commands []PermCmd +} + +// PermissionsView is an org's current requested-access policy for display. +type PermissionsView struct { + Version int + Groups []PermGroup + HasRisky bool // any granted command is risky (drives the warning copy) +} + +// BuildPermissionsView loads the org's current permission version and buckets the +// command catalog by feature, flagging each command's member/admin allowance. +// Only commands granted to at least one tier are shown. Admins effectively also +// get everything members get, so a member-granted command is marked admin too. +func BuildPermissionsView(ctx context.Context, st *store.Store, orgID int64) (PermissionsView, error) { + versionID, version, err := st.CurrentVersion(ctx, orgID) + if err != nil { + return PermissionsView{}, err + } + adminIDs, memberIDs, err := st.VersionCommandIDs(ctx, versionID) + if err != nil { + return PermissionsView{}, err + } + catalog, err := st.ListCommands(ctx) + if err != nil { + return PermissionsView{}, err + } + admin := idSet(adminIDs) + member := idSet(memberIDs) + + byFeature := map[string][]PermCmd{} + var present []string + for _, c := range catalog { + isMember := member[c.ID] + isAdmin := admin[c.ID] || isMember + if !isMember && !isAdmin { + continue + } + if _, ok := byFeature[c.Feature]; !ok { + present = append(present, c.Feature) + } + byFeature[c.Feature] = append(byFeature[c.Feature], PermCmd{ + Template: c.Template, Risky: c.Risky, Member: isMember, Admin: isAdmin, + }) + } + orderFeatures(present) + pv := PermissionsView{Version: version} + for _, c := range catalog { + if c.Risky && (member[c.ID] || admin[c.ID]) { + pv.HasRisky = true + break + } + } + for _, f := range present { + pv.Groups = append(pv.Groups, PermGroup{Name: f, Commands: byFeature[f]}) + } + return pv, nil +} + +func idSet(ids []int64) map[int64]bool { + m := make(map[int64]bool, len(ids)) + for _, id := range ids { + m[id] = true + } + return m +} + +// featureOrder mirrors core's command_features.go display order so the public +// permissions view groups features the same way the in-app review/editor does. +// Unknown features sort after these, alphabetically. +var featureOrder = []string{"Radio", "Routing", "Advertising", "Location", "GPS", "Clock", + "Region", "Neighbors", "Sensors", "Identity", "Access", "Power", "Diagnostics", "Firmware"} + +func featureRank(f string) int { + for i, x := range featureOrder { + if x == f { + return i + } + } + return len(featureOrder) +} + +func orderFeatures(present []string) { + sort.SliceStable(present, func(i, j int) bool { + ri, rj := featureRank(present[i]), featureRank(present[j]) + if ri != rj { + return ri < rj + } + return present[i] < present[j] + }) +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 00140f2..4f24b66 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -95,13 +95,12 @@ code.pubkey { .form-check-input.cc-member:checked { background-color: var(--tblr-blue); border-color: var(--tblr-blue); } .form-check-input.cc-admin:checked { background-color: var(--tblr-orange); border-color: var(--tblr-orange); } -/* ---------- Members role filter (org page) ---------- - The All/Members/Admins button group filters the members list purely in CSS: - the checked radio in the card header drives which rows show via :has(). Rows - carry `.d-flex` (display:flex !important), so the hide must be `!important` - and specific enough to win — which this selector easily is. */ -.member-card:has(#mf-admin:checked) #member-list [data-role="member"], -.member-card:has(#mf-member:checked) #member-list [data-role="admin"] { +/* ---------- Members list filter (org members page) ---------- + The members page filters rows by role + search in JS (text search can't be + pure CSS). Rows carry `.d-flex` (display:flex !important), so the JS toggles + this `!important` hide class rather than an inline style, which `.d-flex` + would otherwise win over. */ +#member-list .member-hidden { display: none !important; } diff --git a/internal/web/templates/org_config.html b/internal/web/templates/org_config.html new file mode 100644 index 0000000..c7005b1 --- /dev/null +++ b/internal/web/templates/org_config.html @@ -0,0 +1,73 @@ +{{define "title"}}Configuration · {{.Org.Name}} · MeshTender{{end}} +{{define "header"}} +
+
+
{{.Org.Name}}
+

Recommended configuration

+
+ {{if .CanEdit}} + + {{end}} +
+{{end}} +{{define "content"}} +{{template "org-tabs" .Nav}} + +
+
+

+ The commands an operator should run to bring a repeater in spec for this organization. + Copy them into a repeater's console, or apply them directly from the console with a modem connected. + Region commands can vary by location — zones apply only to repeaters inside their geofence. +

+
+
+ +{{if .Config.HasProfile}} +
+

Base steps

+ v{{.Config.Version}} +
+
{{template "step-list" .Config.BaseSteps}}
+
+{{range .Config.Zones}} +
+
+

{{.Name}}

+ {{if .MatchAll}}everywhere{{else}}lat {{.MinLat}}…{{.MaxLat}}, lon {{.MinLon}}…{{.MaxLon}}{{end}} · priority {{.Priority}} +
+
{{template "step-list" .Steps}}
+
+{{end}} + +{{/* ---------- Location preview ---------- */}} +
+

Preview for a location

+
+ +
+
+
+
+
+ + {{if .Config.Preview}} +
+
Commands a repeater at {{.PreviewLat}}, {{.PreviewLon}} should run:
+ {{template "step-list" .Config.Preview}} +
+ {{end}} +
+
+{{else}} +
This organization hasn't published a recommended configuration yet.
+{{end}} +{{end}} + +{{/* step-list renders a sequence of ConfigStep as a copy-pasteable block. */}} +{{define "step-list"}} +{{if .}}
{{range .}}{{if .IsComment}}# {{.Comment}}{{else}}{{.CommandLine}}{{end}}
+{{end}}
{{else}}
No steps.
{{end}} +{{end}} diff --git a/internal/web/templates/org_permissions.html b/internal/web/templates/org_permissions.html new file mode 100644 index 0000000..32eaf86 --- /dev/null +++ b/internal/web/templates/org_permissions.html @@ -0,0 +1,45 @@ +{{define "title"}}Requested access · {{.Org.Name}} · MeshTender{{end}} +{{define "header"}} +
+
+
{{.Org.Name}}
+

Requested access

+
+ {{if .CanEdit}} + + {{end}} +
+{{end}} +{{define "content"}} +{{template "org-tabs" .Nav}} + +
+
+

+ The commands this organization may run on a repeater you contribute, by tier + (current policy v{{.Perms.Version}}). Members consent to this when they + contribute a repeater; you stay on the version you consented to until you re-consent to changes. + Admins also get everything members get.{{if .Perms.HasRisky}} Risky commands can take over or brick a node.{{end}} +

+
+
+ +{{range .Perms.Groups}} +
+

{{.Name}}

+
+ {{range .Commands}} +
+ {{.Template}} + {{if .Risky}}risky{{end}} + {{if .Member}}member{{else if .Admin}}admin only{{end}} +
+ {{end}} +
+
+{{else}} +
This organization hasn't requested any access yet.
+{{end}} +{{end}} diff --git a/internal/web/templates/org_public.html b/internal/web/templates/org_public.html index 9950dee..6d7d729 100644 --- a/internal/web/templates/org_public.html +++ b/internal/web/templates/org_public.html @@ -19,6 +19,7 @@ {{end}} {{define "content"}} +{{template "org-tabs" .Nav}}
@@ -67,30 +68,8 @@
- +
-
-

Repeater map

- {{if .HasMap}} -
- -
- - - -
- {{else}} -

No repeaters are shown publicly. Members can opt a repeater into the public map when editing it.

- {{end}} -
-
- - -

About

@@ -110,6 +89,28 @@
+ + +
+
+

Repeaters

+ {{if .HasMap}} +
+ +
+ + + +
+ {{else}} +

No repeaters are shown publicly.

+ {{end}} +
+
{{template "icon-arrow-left" "me-1"}}All organizations diff --git a/internal/web/templates/org_repeaters.html b/internal/web/templates/org_repeaters.html new file mode 100644 index 0000000..124e2a5 --- /dev/null +++ b/internal/web/templates/org_repeaters.html @@ -0,0 +1,63 @@ +{{define "title"}}Repeaters · {{.Org.Name}} · MeshTender{{end}} +{{define "header"}} +
+
+
{{.Org.Name}}
+

Repeaters

+
+
+{{end}} +{{define "content"}} +{{template "org-tabs" .Nav}} + +
+ +
+
+

Contributed repeaters

+ {{if .Reps.Repeaters}} +
+ {{range .Reps.Repeaters}} +
+ {{template "icon-antenna" ""}} +
+ {{if $.Reps.Full}}{{.Name}} +
owner {{.OwnerName}}
+ {{else}}{{.Name}}{{end}} +
+ {{if $.Reps.Full}}{{template "icon-terminal" "me-1"}}Console{{end}} +
+ {{end}} +
+ {{else}} +

+ {{if .Reps.Full}}No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page. + {{else}}No repeaters are shown publicly. Members can opt a repeater into the public map when editing it.{{end}} +

+ {{end}} +
+
+ + +
+
+

Map

+ {{if .Reps.HasMap}} +
+ +
+ + + +
+ {{else}} +

No repeater locations to map.

+ {{end}} +
+
+
+{{end}} diff --git a/internal/web/templates/org_tabs.html b/internal/web/templates/org_tabs.html new file mode 100644 index 0000000..095b144 --- /dev/null +++ b/internal/web/templates/org_tabs.html @@ -0,0 +1,15 @@ +{{/* org-tabs renders the organization sub-navigation. Data: a map with Slug, +Active ("home" | "repeaters" | "members" | "config" | "permissions") and IsMember +(the Members tab only shows for members — it exposes personal info). Nil-safe: a +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). */}} +{{define "org-tabs"}} + +{{end}}