mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 13:32:53 +00:00
Redesigned org page
This commit is contained in:
1 parent
51cc0695e6
commit
d4ff79fa64
18 files changed
+859
-241
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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. */}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
+9
-4
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in new issue
Block a user