Redesigned org page

This commit is contained in:
Jonathon Leight committed 2026-06-22 15:24:38 -04:00
1 parent 51cc0695e6
commit d4ff79fa64
18 files changed
+859 -241

No files matched your search

+52 -51
View File
@@ -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 {
+38 -2
View File
@@ -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)
}
+79 -4
View File
@@ -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)
}
@@ -1,16 +1,17 @@
{{define "title"}}Recommended configuration · {{.Org.Name}} · MeshTender{{end}}
{{define "title"}}Edit 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>
<h2 class="page-title">Edit recommended configuration</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}">Back to organization</a>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Errors}}
<div class="alert alert-danger">
@@ -29,18 +30,14 @@
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
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 <strong>zones</strong> 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 <strong>zones</strong> with a geofence and they'll only apply to repeaters inside them.
</p>
</div>
</div>
{{if .IsAdmin}}
{{/* ---------- Admin editor ---------- */}}
<form method="post" action="/orgs/{{.Org.Slug}}/config">
<form method="post" action="/orgs/{{.Org.Slug}}/config/edit">
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Base steps</h3></div>
<div class="card-body">
@@ -69,7 +66,7 @@
</div>
<div class="btn-list">
<button type="submit" class="btn btn-primary">Publish new version</button>
<a class="btn" href="/orgs/{{.Org.Slug}}">Cancel</a>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
</div>
</div>
</div>
@@ -85,57 +82,6 @@ function addZone() {
}
function removeZone(btn) { btn.closest('.zone-block').remove(); }
</script>
{{else}}
{{/* ---------- Read-only reference ---------- */}}
{{if .HasProfile}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Base steps</h3>
<span class="card-subtitle ms-auto">v{{.Version}}</span>
</div>
<div class="card-body">{{template "step-list" .BaseSteps}}</div>
</div>
{{range .Zones}}
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">{{.Name}}</h3>
<span class="card-subtitle ms-auto">{{if .MatchAll}}everywhere{{else}}lat {{.MinLat}}…{{.MaxLat}}, lon {{.MinLon}}…{{.MaxLon}}{{end}} · priority {{.Priority}}</span>
</div>
<div class="card-body">{{template "step-list" .Steps}}</div>
</div>
{{end}}
{{else}}
<div class="card mt-3"><div class="card-body text-secondary">This organization hasn't published a recommended configuration yet.</div></div>
{{end}}
{{end}}
{{/* ---------- Location preview (admin convenience) ---------- */}}
{{if .HasProfile}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Preview for a location</h3></div>
<div class="card-body">
<form method="get" action="/orgs/{{.Org.Slug}}/config" class="row g-2 align-items-end">
<div class="col-auto"><label class="form-label" for="lat">Latitude</label>
<input class="form-control" type="text" id="lat" name="lat" value="{{if .PreviewLat}}{{.PreviewLat}}{{end}}"></div>
<div class="col-auto"><label class="form-label" for="lon">Longitude</label>
<input class="form-control" type="text" id="lon" name="lon" value="{{if .PreviewLon}}{{.PreviewLon}}{{end}}"></div>
<div class="col-auto"><button class="btn" type="submit">Resolve</button></div>
</form>
{{if .Preview}}
<div class="mt-3">
<div class="text-secondary mb-1">Commands a repeater at {{.PreviewLat}}, {{.PreviewLon}} should run:</div>
{{template "step-list" .Preview}}
</div>
{{end}}
</div>
</div>
{{end}}
{{end}}
{{/* step-list renders a sequence of ConfigStep as a copy-pasteable block. */}}
{{define "step-list"}}
{{if .}}<pre class="mb-0"><code>{{range .}}{{if .IsComment}}# {{.Comment}}{{else}}{{.CommandLine}}{{end}}
{{end}}</code></pre>{{else}}<div class="text-secondary">No steps.</div>{{end}}
{{end}}
{{/* zone-block is one editable zone in the admin form. */}}
+22 -80
View File
@@ -7,8 +7,6 @@
</div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-list">
{{if .IsAdmin}}<a class="btn" href="/orgs/{{.Org.Slug}}/permissions">{{template "icon-settings" "me-1"}}Edit permissions</a>{{end}}
<a class="btn" href="/orgs/{{.Org.Slug}}/config">{{template "icon-settings" "me-1"}}Recommended configuration</a>
<a class="btn" href="/orgs/{{.Org.Slug}}?view=public">{{template "icon-world" "me-1"}}Public page</a>
<form method="post" action="/orgs/{{.Org.Slug}}/leave" class="m-0" onsubmit="return confirm('Leave this organization?')">
<button type="submit" class="btn btn-ghost-danger">Leave</button>
@@ -18,6 +16,7 @@
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<!-- Summary stats -->
@@ -68,85 +67,8 @@
</div>
<div class="row row-cards mt-1">
<!-- Left column -->
<!-- Left column: details -->
<div class="col-lg-8">
{{if .HasMap}}
<div class="card">
<div class="card-header"><h3 class="card-title">Repeater map</h3></div>
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" style="height:300px" role="region" aria-label="Map of organization repeater locations"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
meshMap('map', [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
]);
</script>
</div>
</div>
{{end}}
<div class="card {{if .HasMap}}mt-3{{end}}">
<div class="card-header"><h3 class="card-title">Repeaters</h3></div>
{{if .Repeaters}}
<div class="list-group list-group-flush">
{{range .Repeaters}}
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-antenna" ""}}</span>
<div class="flex-fill text-truncate">
<a class="fw-bold text-reset text-decoration-none" href="/repeaters/{{.RepeaterPublicID}}">{{.Name}}</a>
<div class="text-secondary small">owner {{.OwnerName}}</div>
</div>
<a class="btn btn-sm" href="/repeaters/{{.RepeaterPublicID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
</div>
{{end}}
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.</p></div>
{{end}}
</div>
<div class="card mt-3 member-card">
<div class="card-header">
<h3 class="card-title">Members</h3>
<div class="card-actions">
<div class="btn-group" role="group" aria-label="Filter members">
<input type="radio" class="btn-check" name="member-filter" id="mf-all" value="all" autocomplete="off" checked>
<label for="mf-all" class="btn btn-sm">All</label>
<input type="radio" class="btn-check" name="member-filter" id="mf-member" value="member" autocomplete="off">
<label for="mf-member" class="btn btn-sm">Members</label>
<input type="radio" class="btn-check" name="member-filter" id="mf-admin" value="admin" autocomplete="off">
<label for="mf-admin" class="btn btn-sm">Admins</label>
</div>
</div>
</div>
<div class="list-group list-group-flush" id="member-list">
{{range .Members}}
<div class="list-group-item d-flex align-items-center flex-wrap gap-2" data-role="{{.Role}}">
<div class="flex-fill text-truncate">
<span class="fw-bold">{{.Name}}</span>
<span class="text-secondary">@{{.Username}}</span>
</div>
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
{{if and $.IsAdmin (ne .UserID $.Self)}}
<form method="post" action="/orgs/{{$.Org.Slug}}/members/{{.UserID}}" class="m-0 d-flex gap-2">
{{if eq .Role "admin"}}
<button type="submit" name="action" value="demote" class="btn btn-sm">Demote</button>
{{else}}
<button type="submit" name="action" value="promote" class="btn btn-sm">Make admin</button>
{{end}}
<button type="submit" name="action" value="remove" class="btn btn-sm btn-ghost-danger">Remove</button>
</form>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
<!-- Right column -->
<div class="col-lg-4">
{{if not .IsAdmin}}
<div class="card">
<div class="card-header"><h3 class="card-title">About</h3></div>
@@ -245,6 +167,26 @@
*/}}
{{end}}
</div>
<!-- Right column: map -->
<div class="col-lg-4">
{{if .HasMap}}
<div class="card">
<div class="card-header"><h3 class="card-title">Repeaters</h3></div>
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" style="height:260px" role="region" aria-label="Map of organization repeater locations"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
meshMap('map', [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
]);
</script>
</div>
</div>
{{end}}
</div>
</div>
<a class="back-link mt-3" href="/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
+97
View File
@@ -0,0 +1,97 @@
{{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 "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="row row-cards">
<!-- Left: member list -->
<div class="col-lg-8 col-xl-9">
<div class="card">
<div class="card-header"><h3 class="card-title">{{len .Members}} member{{if ne (len .Members) 1}}s{{end}}</h3></div>
<div class="list-group list-group-flush" id="member-list">
{{range .Members}}
<div class="list-group-item d-flex align-items-center flex-wrap gap-2" data-role="{{.Role}}" data-name="{{.Name}} @{{.Username}}">
<div class="flex-fill text-truncate">
<span class="fw-bold">{{.Name}}</span>
<span class="text-secondary">@{{.Username}}</span>
</div>
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
{{if and $.IsAdmin (ne .UserID $.Self)}}
<form method="post" action="/orgs/{{$.Org.Slug}}/members/{{.UserID}}" class="m-0 d-flex gap-2">
{{if eq .Role "admin"}}
<button type="submit" name="action" value="demote" class="btn btn-sm">Demote</button>
{{else}}
<button type="submit" name="action" value="promote" class="btn btn-sm">Make admin</button>
{{end}}
<button type="submit" name="action" value="remove" class="btn btn-sm btn-ghost-danger">Remove</button>
</form>
{{end}}
</div>
{{end}}
<div class="list-group-item text-secondary member-hidden" id="member-empty">No members match.</div>
</div>
</div>
</div>
<!-- Right: search + filter sidebar -->
<div class="col-lg-4 col-xl-3">
<div class="card">
<div class="card-header"><h3 class="card-title">Find members</h3></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="member-search">Search</label>
<input type="search" class="form-control" id="member-search" placeholder="Name or @username" autocomplete="off" aria-label="Search members">
</div>
<div class="form-label">Role</div>
<div class="btn-group-vertical w-100" role="group" aria-label="Filter members">
<input type="radio" class="btn-check" name="member-filter" id="mf-all" value="all" autocomplete="off" checked>
<label for="mf-all" class="btn">All</label>
<input type="radio" class="btn-check" name="member-filter" id="mf-member" value="member" autocomplete="off">
<label for="mf-member" class="btn">Members</label>
<input type="radio" class="btn-check" name="member-filter" id="mf-admin" value="admin" autocomplete="off">
<label for="mf-admin" class="btn">Admins</label>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
const list = document.getElementById('member-list');
const rows = Array.from(list.querySelectorAll('[data-role]'));
const empty = document.getElementById('member-empty');
const search = document.getElementById('member-search');
function role() {
const r = document.querySelector('input[name="member-filter"]:checked');
return r ? r.value : 'all';
}
function apply() {
const q = search.value.trim().toLowerCase();
const want = role();
let shown = 0;
rows.forEach(function (el) {
const okRole = want === 'all' || el.dataset.role === want;
const okText = q === '' || el.dataset.name.toLowerCase().indexOf(q) !== -1;
const show = okRole && okText;
el.classList.toggle('member-hidden', !show);
if (show) shown++;
});
empty.classList.toggle('member-hidden', shown !== 0);
}
search.addEventListener('input', apply);
document.querySelectorAll('input[name="member-filter"]').forEach(function (el) {
el.addEventListener('change', apply);
});
apply();
})();
</script>
{{end}}
@@ -1,13 +1,18 @@
{{define "title"}}Permissions · {{.Org.Name}} · MeshTender{{end}}
{{define "title"}}Edit requested access · {{.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">Permissions</h2>
<h2 class="page-title">Edit requested access</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/permissions">Back to requested access</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
@@ -19,7 +24,7 @@
</div>
</div>
<form method="post" action="/orgs/{{.Org.Slug}}/permissions">
<form method="post" action="/orgs/{{.Org.Slug}}/permissions/edit">
{{range .Groups}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">{{.Name}}</h3></div>
@@ -42,7 +47,7 @@
</div>
<div class="btn-list">
<button type="submit" class="btn btn-primary">Publish new version</button>
<a class="btn" href="/orgs/{{.Org.Slug}}">Cancel</a>
<a class="btn" href="/orgs/{{.Org.Slug}}/permissions">Cancel</a>
</div>
</div>
</div>
+6 -2
View File
@@ -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)
+5 -2
View File
@@ -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
}
+81
View File
@@ -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 {
+3 -3
View File
@@ -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
}
+232
View File
@@ -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]
})
}
+6 -7
View File
@@ -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;
}
+73
View File
@@ -0,0 +1,73 @@
{{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 "content"}}
{{template "org-tabs" .Nav}}
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
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 — <strong>zones</strong> apply only to repeaters inside their geofence.
</p>
</div>
</div>
{{if .Config.HasProfile}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Base steps</h3>
<span class="card-subtitle ms-auto">v{{.Config.Version}}</span>
</div>
<div class="card-body">{{template "step-list" .Config.BaseSteps}}</div>
</div>
{{range .Config.Zones}}
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">{{.Name}}</h3>
<span class="card-subtitle ms-auto">{{if .MatchAll}}everywhere{{else}}lat {{.MinLat}}…{{.MaxLat}}, lon {{.MinLon}}…{{.MaxLon}}{{end}} · priority {{.Priority}}</span>
</div>
<div class="card-body">{{template "step-list" .Steps}}</div>
</div>
{{end}}
{{/* ---------- Location preview ---------- */}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Preview for a location</h3></div>
<div class="card-body">
<form method="get" action="/orgs/{{.Org.Slug}}/config" class="row g-2 align-items-end">
<div class="col-auto"><label class="form-label" for="lat">Latitude</label>
<input class="form-control" type="text" id="lat" name="lat" value="{{if .PreviewLat}}{{.PreviewLat}}{{end}}"></div>
<div class="col-auto"><label class="form-label" for="lon">Longitude</label>
<input class="form-control" type="text" id="lon" name="lon" value="{{if .PreviewLon}}{{.PreviewLon}}{{end}}"></div>
<div class="col-auto"><button class="btn" type="submit">Resolve</button></div>
</form>
{{if .Config.Preview}}
<div class="mt-3">
<div class="text-secondary mb-1">Commands a repeater at {{.PreviewLat}}, {{.PreviewLon}} should run:</div>
{{template "step-list" .Config.Preview}}
</div>
{{end}}
</div>
</div>
{{else}}
<div class="card mt-3"><div class="card-body text-secondary">This organization hasn't published a recommended configuration yet.</div></div>
{{end}}
{{end}}
{{/* step-list renders a sequence of ConfigStep as a copy-pasteable block. */}}
{{define "step-list"}}
{{if .}}<pre class="mb-0"><code>{{range .}}{{if .IsComment}}# {{.Comment}}{{else}}{{.CommandLine}}{{end}}
{{end}}</code></pre>{{else}}<div class="text-secondary">No steps.</div>{{end}}
{{end}}
@@ -0,0 +1,45 @@
{{define "title"}}Requested access · {{.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">Requested access</h2>
</div>
{{if .CanEdit}}
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/permissions/edit">{{template "icon-pencil" "me-1"}}Edit</a>
</div>
{{end}}
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
The commands this organization may run on a repeater you contribute, by tier
(current policy <strong>v{{.Perms.Version}}</strong>). 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}}
</p>
</div>
</div>
{{range .Perms.Groups}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">{{.Name}}</h3></div>
<div class="card-body">
{{range .Commands}}
<div class="d-flex align-items-center flex-wrap gap-2 py-1">
<code class="flex-fill">{{.Template}}</code>
{{if .Risky}}<span class="badge bg-warning-lt">risky</span>{{end}}
{{if .Member}}<span class="badge bg-azure-lt">member</span>{{else if .Admin}}<span class="badge bg-success-lt">admin only</span>{{end}}
</div>
{{end}}
</div>
</div>
{{else}}
<div class="card mt-3"><div class="card-body text-secondary">This organization hasn't requested any access yet.</div></div>
{{end}}
{{end}}
+24 -23
View File
@@ -19,6 +19,7 @@
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
<!-- Summary stats -->
<div class="row row-cards">
<div class="col-sm-6 col-lg-3">
@@ -67,30 +68,8 @@
</div>
<div class="row row-cards mt-1">
<!-- Left column -->
<!-- Left column: details -->
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">Repeater map</h3></div>
{{if .HasMap}}
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" style="height:340px" role="region" aria-label="Map of organization repeater locations"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
meshMap('map', [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
]);
</script>
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No repeaters are shown publicly. Members can opt a repeater into the public map when editing it.</p></div>
{{end}}
</div>
</div>
<!-- Right column -->
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">About</h3></div>
<div class="card-body">
@@ -110,6 +89,28 @@
</div>
</div>
</div>
<!-- Right column: map -->
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Repeaters</h3></div>
{{if .HasMap}}
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" style="height:260px" role="region" aria-label="Map of organization repeater locations"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
meshMap('map', [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
]);
</script>
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No repeaters are shown publicly.</p></div>
{{end}}
</div>
</div>
</div>
<a class="back-link mt-3" href="{{.RootURL}}/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
+63
View File
@@ -0,0 +1,63 @@
{{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 "content"}}
{{template "org-tabs" .Nav}}
<div class="row row-cards">
<!-- Left: list -->
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">Contributed repeaters</h3></div>
{{if .Reps.Repeaters}}
<div class="list-group list-group-flush">
{{range .Reps.Repeaters}}
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-antenna" ""}}</span>
<div class="flex-fill text-truncate">
{{if $.Reps.Full}}<a class="fw-bold text-reset text-decoration-none" href="/repeaters/{{.RepeaterPublicID}}">{{.Name}}</a>
<div class="text-secondary small">owner {{.OwnerName}}</div>
{{else}}<span class="fw-bold">{{.Name}}</span>{{end}}
</div>
{{if $.Reps.Full}}<a class="btn btn-sm" href="/repeaters/{{.RepeaterPublicID}}/console">{{template "icon-terminal" "me-1"}}Console</a>{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">
{{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}}
</p></div>
{{end}}
</div>
</div>
<!-- Right: map -->
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Map</h3></div>
{{if .Reps.HasMap}}
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" style="height:300px" role="region" aria-label="Map of organization repeater locations"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
meshMap('map', [
{{range .Reps.Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
]);
</script>
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No repeater locations to map.</p></div>
{{end}}
</div>
</div>
</div>
{{end}}
+15
View File
@@ -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"}}
<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>
{{if .IsMember}}<li class="nav-item"><a class="nav-link{{if eq .Active "members"}} active{{end}}" href="/orgs/{{.Slug}}/members">{{template "icon-users" "me-1"}}Members</a></li>{{end}}
<li class="nav-item"><a class="nav-link{{if eq .Active "repeaters"}} active{{end}}" href="/orgs/{{.Slug}}/repeaters">{{template "icon-antenna" "me-1"}}Repeaters</a></li>
<li class="nav-item"><a class="nav-link{{if eq .Active "config"}} active{{end}}" href="/orgs/{{.Slug}}/config">{{template "icon-settings" "me-1"}}Configuration</a></li>
<li class="nav-item"><a class="nav-link{{if eq .Active "permissions"}} active{{end}}" href="/orgs/{{.Slug}}/permissions">{{template "icon-list" "me-1"}}Requested access</a></li>
</ul>
{{end}}