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 ---------- */}}
-
@@ -85,57 +82,6 @@ function addZone() {
}
function removeZone(btn) { btn.closest('.zone-block').remove(); }
-
-{{else}}
-{{/* ---------- Read-only reference ---------- */}}
-{{if .HasProfile}}
-
-
-
{{template "step-list" .BaseSteps}}
-
-{{range .Zones}}
-
-
-
{{template "step-list" .Steps}}
-
-{{end}}
-{{else}}
-This organization hasn't published a recommended configuration yet.
-{{end}}
-{{end}}
-
-{{/* ---------- Location preview (admin convenience) ---------- */}}
-{{if .HasProfile}}
-
-
-
-
- {{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}}
-
- {{end}}
-
-
-
- {{if .Repeaters}}
-
- {{range .Repeaters}}
-
- {{end}}
-
- {{else}}
-
No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.
- {{end}}
-
-
-
-
-
- {{range .Members}}
-
-
- {{.Name}}
- @{{.Username}}
-
- {{if eq .Role "admin"}}
admin{{else}}
member{{end}}
- {{if and $.IsAdmin (ne .UserID $.Self)}}
-
- {{end}}
-
- {{end}}
-
-
-
-
-
-
{{if not .IsAdmin}}
@@ -245,6 +167,26 @@
*/}}
{{end}}
+
+
+
+ {{if .HasMap}}
+
+ {{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}}
+
+
+
+
+
+
+
+ {{range .Members}}
+
+
+ {{.Name}}
+ @{{.Username}}
+
+ {{if eq .Role "admin"}}
admin{{else}}
member{{end}}
+ {{if and $.IsAdmin (ne .UserID $.Self)}}
+
+ {{end}}
+
+ {{end}}
+
No members match.
+
+
+
+
+
+
+
+
+
+{{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}}
+
-
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}}
+
+
+
{{template "step-list" .Config.BaseSteps}}
+
+{{range .Config.Zones}}
+
+
+
{{template "step-list" .Steps}}
+
+{{end}}
+
+{{/* ---------- Location preview ---------- */}}
+
+
+
+
+ {{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}}
+
+
+
+ {{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 @@
-
+
-
-
- {{if .HasMap}}
-
- {{else}}
-
No repeaters are shown publicly. Members can opt a repeater into the public map when editing it.
- {{end}}
-
-
-
-
-
+
+
+
+
+
+ {{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}}
+
+
+
+
+
+
+ {{if .Reps.Repeaters}}
+
+ {{range .Reps.Repeaters}}
+
+ {{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}}
+
+
+
+
+
+
+
+ {{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}}