Cleanup pass on org config edit page

This commit is contained in:
Jonathon Leight committed 2026-07-01 18:32:25 -04:00
1 parent a04d3bf2d0
commit e3c849da05
14 files changed
+898 -281

No files matched your search

+40 -11
View File
@@ -31,18 +31,27 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
t.Fatalf("create org: %v", err)
}
form := url.Values{
"profile_name": {"ESP32", "nRF52"},
"profile_steps": {"# esp base", "# nrf base"},
"region_display": {"Mountains"},
"region_token": {"mtns"},
"region_layer": {"2"},
"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`},
// Two profiles, each created on its own page.
for _, p := range []struct{ name, steps string }{{"ESP32", "# esp base"}, {"nRF52", "# nrf base"}} {
save := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles",
url.Values{"profile_name": {p.name}, "profile_steps": {p.steps}}, sess)
save.Body.Close()
if save.StatusCode != http.StatusSeeOther {
t.Fatalf("create profile %s status = %d, want 303", p.name, save.StatusCode)
}
}
save := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", form, sess)
save.Body.Close()
if save.StatusCode != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303", save.StatusCode)
// A geofenced region, saved on the region page.
rsave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", url.Values{
"region_display": {"Mountains"},
"region_token": {"mtns"},
"region_layer": {"2"},
"region_allow_flood": {"1"},
"root_allow_flood": {"1"},
"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`},
}, sess)
rsave.Body.Close()
if rsave.StatusCode != http.StatusSeeOther {
t.Fatalf("save regions status = %d, want 303", rsave.StatusCode)
}
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
@@ -63,6 +72,26 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
t.Fatalf("?profile=nRF52 should show nRF52's base settings")
}
// The admin editor pages render: the hub lists the profiles + region summary,
// the new-profile page shows the base-settings editor, and the region page has
// the map + a region row.
hub := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", sess))
for _, want := range []string{"ESP32", "nRF52", "Add profile", "Edit regions", "1 region"} {
if !strings.Contains(hub, want) {
t.Fatalf("config hub missing %q", want)
}
}
newProf := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles/new", sess))
if !strings.Contains(newProf, "Base settings") || !strings.Contains(newProf, "Create profile") {
t.Fatalf("new-profile page missing its form")
}
rgn := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", sess))
for _, want := range []string{"region-map", "Mountains", "mtns", "Add region", "everywhere", "Allow flood"} {
if !strings.Contains(rgn, want) {
t.Fatalf("regions editor missing %q", want)
}
}
// A non-admin member of a config-less org does NOT see the Configuration tab.
plain, _ := st.CreateUser(ctx, "plainmember", "")
noCfg, _ := st.CreateOrg(ctx, "NoCfg Org", admin.ID)
+216 -95
View File
@@ -1,29 +1,22 @@
package core
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jleight/meshtender/internal/geo"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/web"
)
// Bounds on how many profiles/regions one save may define, so a malformed or
// abusive submission can't insert unbounded rows.
const (
maxConfigProfiles = 50
maxConfigRegions = 50
)
// configProfileView is a named base-settings profile in the admin editor.
type configProfileView struct {
Name string
StepsText string // editor: one command per line
Steps []store.ConfigStep // read-only: rendered list
}
// Bound on how many regions one save may define, so a malformed or abusive
// submission can't insert unbounded rows. (Profiles are added one at a time.)
const maxConfigRegions = 50
// configRegionView is a region in the admin editor: its display name, MeshCore
// token, layer (depth / region def order), and the raw GeoJSON geofence the map
@@ -33,6 +26,7 @@ type configRegionView struct {
Token string
Layer int
Primary bool
AllowFlood bool
GeofenceJSON string
}
@@ -77,8 +71,10 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "org_config.html", data)
}
// pageOrgConfigEdit is the admin editor for the org's profiles and regions.
func (s *Handlers) pageOrgConfigEdit(w http.ResponseWriter, r *http.Request) {
// pageConfigHub is the admin configuration overview: a list of profiles (each
// edited on its own page) and a summary of the org's regions (edited on the map
// page). It replaces the old single mega-form.
func (s *Handlers) pageConfigHub(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
@@ -98,20 +94,189 @@ func (s *Handlers) pageOrgConfigEdit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load config", http.StatusInternalServerError)
return
}
var primary string
for _, z := range regions {
if z.Primary {
primary = z.DisplayName
break
}
}
s.Render(w, r, "config_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
"EmptyProfile": configProfileView{},
"EmptyRegion": configRegionView{},
"Profiles": profileViews(profiles),
"Regions": regionViews(regions),
"Org": org,
"Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
"Profiles": profiles,
"RegionCount": len(regions),
"PrimaryRegion": primary,
})
}
// handleSaveOrgConfig validates and replaces the org's profiles + regions (admin
// only). Any unknown command line blocks the save and the editor is re-rendered
// with the errors and the entered text preserved.
func (s *Handlers) handleSaveOrgConfig(w http.ResponseWriter, r *http.Request) {
// pageProfileEdit renders the single-profile editor: blank for the /new route, or
// pre-filled when a {pid} is present. 404s if the profile isn't this org's.
func (s *Handlers) pageProfileEdit(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
http.NotFound(w, r)
return
}
var pid int64
var name, stepsText string
if raw := chi.URLParam(r, "pid"); raw != "" {
pid, err = strconv.ParseInt(raw, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
p, err := s.Store.GetProfile(r.Context(), orgID, pid)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "could not load profile", http.StatusInternalServerError)
return
}
name, stepsText = p.Name, stepsToText(p.Steps)
}
s.renderProfileEdit(w, r, org, pid, name, stepsText, nil, nil)
}
// renderProfileEdit renders the profile editor page (shared by the initial GET
// and the error re-render). pid 0 means a new profile.
func (s *Handlers) renderProfileEdit(w http.ResponseWriter, r *http.Request, org *store.Org, pid int64, name, stepsText string, errs, risky []string) {
s.Render(w, r, "config_profile_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "config", true, true),
"ProfileID": pid,
"Name": name,
"StepsText": stepsText,
"Errors": errs,
"RiskyWarn": risky,
})
}
// handleCreateProfile validates and inserts a new profile.
func (s *Handlers) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
s.saveProfile(w, r, orgID, 0)
}
// handleUpdateProfile validates and updates an existing profile.
func (s *Handlers) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
s.saveProfile(w, r, orgID, pid)
}
// saveProfile parses the single-profile form and creates (pid 0) or updates it.
// On a validation error it re-renders the editor with the entered text preserved;
// on a duplicate name it reports a friendly message the same way.
func (s *Handlers) saveProfile(w http.ResponseWriter, r *http.Request, orgID, pid int64) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
http.NotFound(w, r)
return
}
catalog, err := s.Store.ListCommands(r.Context())
if err != nil {
http.Error(w, "could not load commands", http.StatusInternalServerError)
return
}
name := strings.TrimSpace(r.FormValue("profile_name"))
stepsText := r.FormValue("profile_steps")
var errs, risky []string
if name == "" {
errs = append(errs, "Give the profile a name.")
}
_, steps := parseConfigSteps(stepsText, catalog, "profile", &errs, &risky)
if len(errs) == 0 {
if pid == 0 {
_, err = s.Store.CreateProfile(r.Context(), orgID, name, steps)
} else {
err = s.Store.UpdateProfile(r.Context(), orgID, pid, name, steps)
}
switch {
case errors.Is(err, store.ErrDuplicate):
errs = append(errs, fmt.Sprintf("A profile named %q already exists.", name))
case errors.Is(err, store.ErrNotFound):
http.NotFound(w, r)
return
case err != nil:
http.Error(w, "could not save profile", http.StatusInternalServerError)
return
default:
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config/edit", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
return
}
}
s.renderProfileEdit(w, r, org, pid, name, stepsText, errs, risky)
}
// handleDeleteProfile removes a profile and returns to the config hub.
func (s *Handlers) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.Store.DeleteProfile(r.Context(), orgID, pid); err != nil && !errors.Is(err, store.ErrNotFound) {
http.Error(w, "could not delete profile", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config/edit", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// pageRegionsEdit renders the region map editor (map + side list).
func (s *Handlers) pageRegionsEdit(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
http.NotFound(w, r)
return
}
regions, err := s.Store.ListRegions(r.Context(), orgID)
if err != nil {
http.Error(w, "could not load regions", http.StatusInternalServerError)
return
}
s.Render(w, r, "config_regions_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
"EmptyRegion": configRegionView{AllowFlood: true},
"Regions": regionViews(regions),
"RootAllowFlood": org.RootAllowFlood,
})
}
// handleSaveRegions validates and atomically replaces just the org's regions
// (profiles are left untouched). On error the editor is re-rendered with input
// preserved.
func (s *Handlers) handleSaveRegions(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
@@ -120,79 +285,32 @@ func (s *Handlers) handleSaveOrgConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
catalog, err := s.Store.ListCommands(r.Context())
if err != nil {
http.Error(w, "could not load commands", http.StatusInternalServerError)
return
}
var errs, risky []string
profiles, profileVs := s.parseProfiles(r, catalog, &errs, &risky)
rootAllowFlood := r.FormValue("root_allow_flood") == "1"
var errs []string
regions, regionVs := s.parseRegions(r, &errs)
if len(errs) > 0 {
org, gerr := s.Store.GetOrg(r.Context(), orgID)
if gerr != nil {
http.NotFound(w, r)
return
}
s.Render(w, r, "config_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
"EmptyProfile": configProfileView{},
"EmptyRegion": configRegionView{},
"Errors": errs,
"RiskyWarn": risky,
"Profiles": profileVs,
"Regions": regionVs,
s.Render(w, r, "config_regions_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
"EmptyRegion": configRegionView{AllowFlood: true},
"Errors": errs,
"Regions": regionVs,
"RootAllowFlood": rootAllowFlood,
})
return
}
if err := s.Store.ReplaceOrgConfig(r.Context(), orgID, profiles, regions); err != nil {
if err := s.Store.ReplaceRegions(r.Context(), orgID, regions, rootAllowFlood); err != nil {
http.Error(w, "could not save", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// parseProfiles reads the repeated profile blocks from the form into store inputs
// and editor views (for re-display on error). Empty blocks are ignored; a block
// with steps but no name, or a duplicate name, is an error.
func (s *Handlers) parseProfiles(r *http.Request, catalog []*store.Command, errs, risky *[]string) ([]store.ProfileInput, []configProfileView) {
names := r.Form["profile_name"]
if len(names) > maxConfigProfiles {
*errs = append(*errs, fmt.Sprintf("Too many profiles (max %d).", maxConfigProfiles))
}
seen := map[string]bool{}
var ins []store.ProfileInput
var views []configProfileView
for i := range names {
name := strings.TrimSpace(formAt(r, "profile_name", i))
stepsText := formAt(r, "profile_steps", i)
if name == "" && strings.TrimSpace(stepsText) == "" {
continue // empty block
}
view := configProfileView{Name: name, StepsText: stepsText}
if name == "" {
*errs = append(*errs, "A profile is missing a name.")
views = append(views, view)
continue
}
if seen[strings.ToLower(name)] {
*errs = append(*errs, fmt.Sprintf("Duplicate profile name %q.", name))
views = append(views, view)
continue
}
seen[strings.ToLower(name)] = true
steps, storeSteps := parseConfigSteps(stepsText, catalog, fmt.Sprintf("profile %q", name), errs, risky)
view.Steps = steps
views = append(views, view)
ins = append(ins, store.ProfileInput{Name: name, Steps: storeSteps})
}
return ins, views
}
// parseRegions reads the repeated region blocks from the form into store inputs
// and editor views. A region needs a token (its MeshCore name); the display name
// defaults to the token when left blank. Tokens must be unique and use only
@@ -213,10 +331,11 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region
geojson := strings.TrimSpace(formAt(r, "region_geojson", i))
layer, _ := strconv.Atoi(formAt(r, "region_layer", i))
primary := formAt(r, "region_primary", i) == "1"
allowFlood := formAt(r, "region_allow_flood", i) == "1"
if token == "" && display == "" && geojson == "" {
continue // empty block
}
zv := configRegionView{DisplayName: display, Token: token, Layer: layer, Primary: primary, GeofenceJSON: geojson}
zv := configRegionView{DisplayName: display, Token: token, Layer: layer, Primary: primary, AllowFlood: allowFlood, GeofenceJSON: geojson}
if token == "" {
*errs = append(*errs, "A region is missing its short name.")
views = append(views, zv)
@@ -237,6 +356,13 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region
display = token
zv.DisplayName = display
}
// Non-root regions must have a drawn area; the root region (*) is the only
// "applies everywhere" region and lives on the org, not in this list.
if geojson == "" {
*errs = append(*errs, fmt.Sprintf("Region %q needs a drawn area — outline it on the map, or rely on the root region.", token))
views = append(views, zv)
continue
}
geofence, ok := regionGeofence(zv, token, errs)
if !ok {
views = append(views, zv)
@@ -249,7 +375,7 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region
primaryTaken = true
}
views = append(views, zv)
ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, Primary: primary, GeofenceJSON: geofence})
ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, Primary: primary, AllowFlood: allowFlood, GeofenceJSON: geofence})
}
return ins, views
}
@@ -348,15 +474,6 @@ func stepsToText(steps []store.ConfigStep) string {
return b.String()
}
// profileViews converts stored profiles into editor/read views.
func profileViews(profiles []store.Profile) []configProfileView {
out := make([]configProfileView, 0, len(profiles))
for _, p := range profiles {
out = append(out, configProfileView{Name: p.Name, StepsText: stepsToText(p.Steps), Steps: p.Steps})
}
return out
}
// regionViews converts stored regions into editor views, carrying each geofence's
// raw GeoJSON through verbatim so the map editor round-trips arbitrary polygons.
func regionViews(regions []store.Region) []configRegionView {
@@ -364,7 +481,7 @@ func regionViews(regions []store.Region) []configRegionView {
for _, z := range regions {
out = append(out, configRegionView{
DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, Primary: z.Primary,
GeofenceJSON: string(z.GeofenceJSON),
AllowFlood: z.AllowFlood, GeofenceJSON: string(z.GeofenceJSON),
})
}
return out
@@ -393,7 +510,11 @@ func (s *Handlers) resolvedProfilesForRepeater(r *http.Request, rep *store.Repea
if err != nil {
continue
}
cmds := store.RegionDefCommands(regions, rep.Latitude, rep.Longitude)
rootAllow, err := s.Store.RootAllowFlood(r.Context(), o.OrgID)
if err != nil {
continue
}
cmds := store.RegionDefCommands(regions, rootAllow, rep.Latitude, rep.Longitude)
if len(cmds) == 0 {
continue
}
+6 -1
View File
@@ -97,7 +97,12 @@ func (s *Handlers) handleSetupCommands(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load regions", http.StatusInternalServerError)
return
}
cmds = append(cmds, store.RegionDefCommands(regions, req.Lat, req.Lon)...)
rootAllow, err := s.Store.RootAllowFlood(r.Context(), req.OrgID)
if err != nil {
http.Error(w, "could not load regions", http.StatusInternalServerError)
return
}
cmds = append(cmds, store.RegionDefCommands(regions, rootAllow, req.Lat, req.Lon)...)
} else {
if radio.FreqMHz <= 0 || radio.BwKHz <= 0 {
http.Error(w, "radio settings are required", http.StatusBadRequest)
+20 -11
View File
@@ -53,18 +53,26 @@ func TestSerialSetupFlow(t *testing.T) {
// A profile whose steps set the radio, plus a geofenced region (lon 30-40,
// lat 10-20). Steps resolve against the seeded command catalog.
form := url.Values{
"profile_name": {"Std"},
"profile_steps": {"set radio 910.525,62.5,7,5\nset tx 22"},
"region_display": {"Zone"},
"region_token": {"zone"},
"region_layer": {"1"},
"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`},
}
save := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", form, sess)
save := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles", url.Values{
"profile_name": {"Std"},
"profile_steps": {"set radio 910.525,62.5,7,5\nset tx 22"},
}, sess)
save.Body.Close()
if save.StatusCode != http.StatusSeeOther {
t.Fatalf("config save status = %d, want 303", save.StatusCode)
t.Fatalf("profile save status = %d, want 303", save.StatusCode)
}
// Scoped-flooding pattern: allow flood inside the zone, deny at the root
// (root_allow_flood omitted = unchecked = deny).
rsave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", url.Values{
"region_display": {"Zone"},
"region_token": {"zone"},
"region_layer": {"1"},
"region_allow_flood": {"1"},
"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`},
}, sess)
rsave.Body.Close()
if rsave.StatusCode != http.StatusSeeOther {
t.Fatalf("region save status = %d, want 303", rsave.StatusCode)
}
// Build the command list for a point inside the region.
@@ -91,7 +99,8 @@ func TestSerialSetupFlow(t *testing.T) {
joined := strings.Join(cmdResp.Commands, "\n")
for _, want := range []string{
"set name Hilltop", "set radio 910.525,62.5,7,5", "region def zone", "region save",
"set name Hilltop", "set radio 910.525,62.5,7,5", "region def zone",
"region denyf *", "region allowf zone", "region save",
"set lat 15.000000", "set lon 35.000000", "reboot",
} {
if !strings.Contains(joined, want) {
+45 -111
View File
@@ -1,9 +1,9 @@
{{define "title"}}Edit configuration · {{.Org.Name}} · MeshTender{{end}}
{{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">Edit Configuration</h2>
<h2 class="page-title">Edit configuration</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
@@ -13,118 +13,52 @@
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Errors}}
<div class="alert alert-danger">
<div class="fw-bold">Nothing was saved — fix these first:</div>
<ul class="mb-0">{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
</div>
{{end}}
{{if .RiskyWarn}}
<div class="alert alert-warning">
<div class="fw-bold">Risky commands in this configuration</div>
<p class="mb-1">These can take over or brick a node — included on purpose?</p>
<ul class="mb-0">{{range .RiskyWarn}}<li><code>{{.}}</code></li>{{end}}</ul>
</div>
{{end}}
<form method="post" action="/orgs/{{.Org.Slug}}/config/edit">
<div class="card">
<div class="card-header"><h3 class="card-title">Profiles</h3></div>
<div class="card-body">
<p class="text-secondary">Named sets of base settings — e.g. one per board family. Members pick the profile matching their hardware. One command per line; lines starting with <code>#</code> are notes.</p>
<div id="profiles">
{{range .Profiles}}{{template "profile-block" .}}{{end}}
<div class="card">
<div class="card-header">
<div>
<h3 class="card-title">Profiles</h3>
<div class="text-secondary small">Named sets of base settings — e.g. one per board family. Members pick the profile matching their use case.</div>
</div>
<div class="card-actions">
<a class="btn btn-primary" href="/orgs/{{.Org.Slug}}/config/profiles/new">{{template "icon-plus" "me-1"}}Add profile</a>
</div>
</div>
{{if .Profiles}}
<div class="list-group list-group-flush">
{{range .Profiles}}
<div class="list-group-item d-flex align-items-center gap-3">
<div class="flex-fill" style="min-width:0">
<div class="fw-bold text-truncate">{{.Name}}</div>
<div class="text-secondary small">{{len .Steps}} step{{if ne (len .Steps) 1}}s{{end}}</div>
</div>
<button type="button" class="btn btn-outline-primary mt-2" onclick="addBlock('profile')">{{template "icon-plus" "me-1"}}Add profile</button>
<a class="btn btn-sm" href="/orgs/{{$.Org.Slug}}/config/profiles/{{.ID}}/edit">Edit</a>
<form method="post" action="/orgs/{{$.Org.Slug}}/config/profiles/{{.ID}}/delete" class="m-0" onsubmit="return confirm('Delete the profile “{{.Name}}”?')">
<button type="submit" class="btn btn-sm btn-ghost-danger">Delete</button>
</form>
</div>
{{end}}
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No profiles yet. Add one to give members a base-settings preset for their hardware.</p></div>
{{end}}
</div>
<div class="card mt-3">
<div class="card-header">
<div>
<h3 class="card-title">Regions</h3>
<div class="text-secondary small">Named areas drawn on a map. A repeater is auto-assigned to every region whose area contains it, forming its <code>region def</code>.</div>
</div>
<div class="card-actions">
<a class="btn btn-primary" href="/orgs/{{.Org.Slug}}/config/regions">{{template "icon-map-pin" "me-1"}}Edit regions</a>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Regions</h3></div>
<div class="card-body">
<p class="text-secondary">A hierarchy of named areas. Each region has a <strong>display name</strong> (e.g. Buffalo), a short <strong>name</strong> used by MeshCore (e.g. <code>buf</code>), and a <strong>layer</strong> for its depth. Select a region below, then use the map tools to outline where it applies — a region with no shape applies everywhere. A repeater is assigned to every region whose area contains it; sorted by layer, their names form a <code>region def</code> command (e.g. <code>region def us ny buf</code>).</p>
<link rel="stylesheet" href="/static/leaflet.css">
<link rel="stylesheet" href="/static/leaflet-geoman.css">
<p class="text-secondary small mb-1">Drawing area for: <strong id="region-map-active">—</strong></p>
<div id="region-map" style="height:420px" role="region" aria-label="Map for drawing region boundaries"></div>
<div id="regions" class="mt-3">
{{range .Regions}}{{template "region-block" .}}{{end}}
</div>
<button type="button" class="btn btn-outline-primary mt-2" onclick="addBlock('region')">{{template "icon-plus" "me-1"}}Add region</button>
</div>
<div class="card-body">
{{if .RegionCount}}
<div class="text-secondary">{{.RegionCount}} region{{if ne .RegionCount 1}}s{{end}} defined{{if .PrimaryRegion}} · primary: <span class="text-body">{{.PrimaryRegion}}</span>{{end}}.</div>
{{else}}
<p class="text-secondary mb-0">No regions yet. Add regions to apply location-based settings automatically.</p>
{{end}}
</div>
<div class="card mt-3">
<div class="card-body">
<div class="btn-list">
<button type="submit" class="btn btn-primary">Save configuration</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
</div>
</div>
</div>
</form>
{{/* Hidden prototypes cloned by addBlock(); kept out of the form via template tags. */}}
<template id="profile-proto">{{template "profile-block" .EmptyProfile}}</template>
<template id="region-proto">{{template "region-block" .EmptyRegion}}</template>
<script>
function addBlock(kind) {
const proto = document.getElementById(kind + '-proto');
document.getElementById(kind + 's').appendChild(proto.content.cloneNode(true));
}
function removeBlock(btn, cls) { btn.closest('.' + cls).remove(); }
// Only one region may be primary: toggle the clicked one and clear the rest.
function setPrimaryRegion(btn) {
const block = btn.closest('.region-block');
const makePrimary = block.querySelector('input[name="region_primary"]').value !== '1';
document.querySelectorAll('#regions .region-block').forEach(function (b) {
const on = b === block && makePrimary;
b.querySelector('input[name="region_primary"]').value = on ? '1' : '';
const pb = b.querySelector('.region-primary-btn');
pb.classList.toggle('btn-primary', on);
pb.textContent = on ? '★ Primary' : 'Make primary';
});
}
</script>
<script src="/static/leaflet.js"></script>
<script src="/static/leaflet-geoman.js"></script>
<script src="/static/regionmap.js"></script>
<script>initRegionEditor('region-map', 'regions');</script>
{{end}}
{{/* profile-block is one editable named profile. */}}
{{define "profile-block"}}
<div class="profile-block border rounded p-3 mb-3">
<label class="form-label">Profile name</label>
<input class="form-control" name="profile_name" value="{{.Name}}" placeholder="ESP32">
<div class="mt-2"><label class="form-label">Base settings</label>
<textarea class="form-control font-monospace" name="profile_steps" rows="5" spellcheck="false" placeholder="set tx 22&#10;# notes start with #">{{.StepsText}}</textarea>
</div>
<button type="button" class="btn btn-ghost-danger btn-sm mt-2" onclick="removeBlock(this,'profile-block')">Remove profile</button>
</div>
{{end}}
{{/* region-block is one node of the region hierarchy in the admin form. Its area
is drawn on the shared map above; region_geojson carries the GeoJSON the map
writes. Display name is the human label; Name is the MeshCore token used in
region def; Layer is its depth/order in the chain. */}}
{{define "region-block"}}
<div class="region-block border rounded p-3 mb-3">
<div class="row g-2 align-items-end">
<div class="col-md"><label class="form-label">Display name</label>
<input class="form-control" name="region_display" value="{{.DisplayName}}" placeholder="Buffalo"></div>
<div class="col-md-auto"><label class="form-label">Name</label>
<input class="form-control font-monospace" name="region_token" value="{{.Token}}" placeholder="buf" style="width:8rem"></div>
<div class="col-md-auto"><label class="form-label">Layer</label>
<input class="form-control" type="number" name="region_layer" value="{{.Layer}}" style="width:5rem"></div>
<div class="col-md-auto">
<input type="hidden" name="region_primary" value="{{if .Primary}}1{{end}}">
<button type="button" class="btn btn-sm region-primary-btn{{if .Primary}} btn-primary{{end}}" onclick="setPrimaryRegion(this)">{{if .Primary}}★ Primary{{else}}Make primary{{end}}</button>
<span class="badge bg-blue-lt region-shape-status ms-1">Applies everywhere</span>
<button type="button" class="btn btn-sm btn-ghost-secondary region-clear ms-1">Clear area</button>
</div>
</div>
<input type="hidden" name="region_geojson" value="{{.GeofenceJSON}}">
<button type="button" class="btn btn-ghost-danger btn-sm mt-2" onclick="removeBlock(this,'region-block')">Remove region</button>
</div>
{{end}}
@@ -0,0 +1,60 @@
{{define "title"}}{{if .ProfileID}}Edit profile{{else}}Add profile{{end}} · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}} · Configuration</div>
<h2 class="page-title">{{if .ProfileID}}Edit profile{{else}}Add profile{{end}}</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">Back to configuration</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Errors}}
<div class="alert alert-danger">
<div class="fw-bold">Nothing was saved — fix these first:</div>
<ul class="mb-0">{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
</div>
{{end}}
{{if .RiskyWarn}}
<div class="alert alert-warning">
<div class="fw-bold">Risky commands in this profile</div>
<p class="mb-1">These can take over or brick a node — included on purpose?</p>
<ul class="mb-0">{{range .RiskyWarn}}<li><code>{{.}}</code></li>{{end}}</ul>
</div>
{{end}}
<div class="card">
<div class="card-body">
<form method="post" action="{{if .ProfileID}}/orgs/{{.Org.Slug}}/config/profiles/{{.ProfileID}}{{else}}/orgs/{{.Org.Slug}}/config/profiles{{end}}">
<div class="mb-3">
<label class="form-label" for="profile_name">Profile name</label>
<input class="form-control" id="profile_name" name="profile_name" value="{{.Name}}" placeholder="ESP32" maxlength="60" autofocus>
</div>
<div class="mb-1">
<label class="form-label" for="profile_steps">Base settings</label>
<textarea class="form-control font-monospace" id="profile_steps" name="profile_steps" rows="10" spellcheck="false" placeholder="set tx 22&#10;# notes start with #">{{.StepsText}}</textarea>
<small class="form-hint">One command per line; lines starting with <code>#</code> are notes.</small>
</div>
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">{{if .ProfileID}}Save profile{{else}}Create profile{{end}}</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">Cancel</a>
</div>
</form>
</div>
</div>
{{if .ProfileID}}
<div class="card mt-3">
<div class="card-body">
<form method="post" action="/orgs/{{.Org.Slug}}/config/profiles/{{.ProfileID}}/delete" onsubmit="return confirm('Delete this profile?')">
<button type="submit" class="btn btn-ghost-danger">Delete profile</button>
<small class="form-hint mt-1">Removes this profile and its base settings.</small>
</form>
</div>
</div>
{{end}}
{{end}}
@@ -0,0 +1,124 @@
{{define "title"}}Edit regions · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}} · Configuration</div>
<h2 class="page-title">Edit regions</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">Back to configuration</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Errors}}
<div class="alert alert-danger">
<div class="fw-bold">Nothing was saved — fix these first:</div>
<ul class="mb-0">{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
</div>
{{end}}
<link rel="stylesheet" href="/static/leaflet.css">
<link rel="stylesheet" href="/static/leaflet-geoman.css">
<style>
@media (min-width: 992px) { .region-map-sticky { position: sticky; top: 1rem; } }
</style>
<p class="text-secondary">Each region has a <strong>display name</strong> (e.g. Buffalo), a short <strong>name</strong> used by MeshCore (e.g. <code>buf</code>), and a <strong>level</strong> for its depth (lower = broader, e.g. country 1 → state 2 → city 3). Pick a region in the list, then use the map tools to outline where it applies — every region needs an area (the root region at the top of the list is the one that applies everywhere).</p>
<form method="post" action="/orgs/{{.Org.Slug}}/config/regions">
<div class="row g-3">
<div class="col-lg-7">
<div class="region-map-sticky">
<div id="region-map" style="height:520px; border-radius:var(--tblr-border-radius)" role="region" aria-label="Map for drawing region boundaries"></div>
<p class="text-secondary small mt-2 mb-0">Draw with the tools at the top-left of the map. New shapes fill the selected region; drawing again adds a new region.</p>
</div>
</div>
<div class="col-lg-5">
{{/* The root region (*) is pinned first in the list. It is not a .region-block,
so the map editor ignores it; its only setting is flood policy. */}}
<div class="card card-sm mb-2">
<div class="card-body d-flex align-items-center gap-3">
<div class="flex-fill" style="min-width:0">
<div class="fw-bold">Root <span class="text-secondary fw-normal">· <code>*</code> · everywhere</span></div>
<div class="text-secondary small">Applies to every repeater. Deny flood to scope flooding to your defined regions.</div>
</div>
<label class="form-check form-switch m-0 flex-shrink-0 text-nowrap">
<input type="hidden" name="root_allow_flood" value="{{if .RootAllowFlood}}1{{end}}">
<input class="form-check-input" type="checkbox" {{if .RootAllowFlood}}checked{{end}} onchange="this.previousElementSibling.value = this.checked ? '1' : ''">
<span class="form-check-label">Allow flood</span>
</label>
</div>
</div>
<div id="regions">
{{range .Regions}}{{template "region-block" .}}{{end}}
</div>
<button type="button" class="btn btn-outline-primary w-100" onclick="addBlock('region')">{{template "icon-plus" "me-1"}}Add region</button>
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">Save regions</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
</div>
</div>
</div>
</form>
{{/* Hidden prototype cloned by addBlock(); kept out of the form via the template tag. */}}
<template id="region-proto">{{template "region-block" .EmptyRegion}}</template>
<script>
function addBlock(kind) {
const proto = document.getElementById(kind + '-proto');
document.getElementById(kind + 's').appendChild(proto.content.cloneNode(true));
}
function removeBlock(btn, cls) { btn.closest('.' + cls).remove(); }
// Only one region may be primary: toggle the clicked one and clear the rest.
function setPrimaryRegion(btn) {
const block = btn.closest('.region-block');
const makePrimary = block.querySelector('input[name="region_primary"]').value !== '1';
document.querySelectorAll('#regions .region-block').forEach(function (b) {
const on = b === block && makePrimary;
b.querySelector('input[name="region_primary"]').value = on ? '1' : '';
const pb = b.querySelector('.region-primary-btn');
pb.classList.toggle('btn-primary', on);
pb.textContent = on ? '★ Primary' : 'Make primary';
});
}
</script>
<script src="/static/leaflet.js"></script>
<script src="/static/leaflet-geoman.js"></script>
<script src="/static/regionmap.js"></script>
<script>initRegionEditor('region-map', 'regions');</script>
{{end}}
{{/* region-block is one node of the region hierarchy, shown as a card in the side
list. Its area is drawn on the map; region_geojson carries the GeoJSON the map
writes. regionmap.js keys off .region-block / region_geojson /
.region-clear / .region-active. */}}
{{define "region-block"}}
<div class="region-block card card-sm mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-12"><label class="form-label mb-1">Display name</label>
<input class="form-control" name="region_display" value="{{.DisplayName}}" placeholder="Buffalo"></div>
<div class="col"><label class="form-label mb-1">Name</label>
<input class="form-control font-monospace" name="region_token" value="{{.Token}}" placeholder="buf"></div>
<div class="col-auto"><label class="form-label mb-1">Level</label>
<input class="form-control" type="number" name="region_layer" value="{{.Layer}}" style="width:5rem"></div>
</div>
<div class="d-flex align-items-center flex-wrap gap-2 mt-2">
<button type="button" class="btn btn-sm region-edit-btn">Edit</button>
<input type="hidden" name="region_primary" value="{{if .Primary}}1{{end}}">
<button type="button" class="btn btn-sm region-primary-btn{{if .Primary}} btn-primary{{end}}" onclick="setPrimaryRegion(this)">{{if .Primary}}★ Primary{{else}}Make primary{{end}}</button>
<label class="form-check form-check-inline m-0">
<input type="hidden" name="region_allow_flood" value="{{if .AllowFlood}}1{{end}}">
<input class="form-check-input" type="checkbox" {{if .AllowFlood}}checked{{end}} onchange="this.previousElementSibling.value = this.checked ? '1' : ''">
<span class="form-check-label">Allow flood</span>
</label>
<button type="button" class="btn btn-sm btn-ghost-secondary region-clear">Clear area</button>
<button type="button" class="btn btn-sm btn-ghost-danger ms-auto" onclick="removeBlock(this,'region-block')">Remove</button>
</div>
<input type="hidden" name="region_geojson" value="{{.GeofenceJSON}}">
</div>
</div>
{{end}}
+8 -2
View File
@@ -165,8 +165,14 @@ func (s *Handlers) appRouter() chi.Router {
r.Get("/orgs/{id}/my-commands", s.pageOrgCommands)
r.Post("/orgs/{id}/my-commands", s.handleSaveOrgCommands)
r.Get("/orgs/{id}/config", s.pageOrgConfig)
r.Get("/orgs/{id}/config/edit", s.pageOrgConfigEdit)
r.Post("/orgs/{id}/config/edit", s.handleSaveOrgConfig)
r.Get("/orgs/{id}/config/edit", s.pageConfigHub)
r.Get("/orgs/{id}/config/profiles/new", s.pageProfileEdit)
r.Post("/orgs/{id}/config/profiles", s.handleCreateProfile)
r.Get("/orgs/{id}/config/profiles/{pid}/edit", s.pageProfileEdit)
r.Post("/orgs/{id}/config/profiles/{pid}", s.handleUpdateProfile)
r.Post("/orgs/{id}/config/profiles/{pid}/delete", s.handleDeleteProfile)
r.Get("/orgs/{id}/config/regions", s.pageRegionsEdit)
r.Post("/orgs/{id}/config/regions", s.handleSaveRegions)
// 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)
+175 -33
View File
@@ -52,6 +52,7 @@ type Region struct {
DisplayName string
Layer int
Primary bool // the org's primary region (frames the config preview map)
AllowFlood bool // whether flooding is allowed in this region (region allowf/denyf)
Geofence *geo.Shape
GeofenceJSON []byte
}
@@ -67,6 +68,7 @@ type RegionInput struct {
DisplayName string
Layer int
Primary bool
AllowFlood bool
GeofenceJSON []byte
}
@@ -128,14 +130,14 @@ func (s *Store) ListProfiles(ctx context.Context, orgID int64) ([]Profile, error
// the order their tokens appear in a `region def` chain.
func (s *Store) ListRegions(ctx context.Context, orgID int64) ([]Region, error) {
rrows, err := s.pool.Query(ctx,
`SELECT id, token, display_name, layer, is_primary, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID)
`SELECT id, token, display_name, layer, is_primary, allow_flood, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID)
if err != nil {
return nil, fmt.Errorf("list regions: %w", err)
}
regions, err := collectRows(rrows, func(r pgx.Row) (Region, error) {
var z Region
var raw []byte
if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &z.Primary, &raw); err != nil {
if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &z.Primary, &z.AllowFlood, &raw); err != nil {
return Region{}, err
}
if z.Geofence, err = geo.Parse(raw); err != nil {
@@ -155,39 +157,159 @@ func (s *Store) ListRegions(ctx context.Context, orgID int64) ([]Region, error)
// version history — so the editor sends the full desired state each save.
func (s *Store) ReplaceOrgConfig(ctx context.Context, orgID int64, profiles []ProfileInput, regions []RegionInput) error {
return s.inTx(ctx, func(tx pgx.Tx) error {
// Steps cascade from their profile/region, so deleting the parents is enough.
if _, err := tx.Exec(ctx, `DELETE FROM config_profiles WHERE org_id = $1`, orgID); err != nil {
return fmt.Errorf("clear profiles: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM config_regions WHERE org_id = $1`, orgID); err != nil {
return fmt.Errorf("clear regions: %w", err)
}
for pos, p := range profiles {
var pid int64
if err := tx.QueryRow(ctx,
`INSERT INTO config_profiles (org_id, name, position) VALUES ($1, $2, $3) RETURNING id`,
orgID, p.Name, pos).Scan(&pid); err != nil {
return fmt.Errorf("insert profile %q: %w", p.Name, err)
}
if err := insertConfigSteps(ctx, tx, "config_profile_steps", "profile_id", pid, p.Steps); err != nil {
return fmt.Errorf("insert profile %q steps: %w", p.Name, err)
if _, err := insertProfile(ctx, tx, orgID, p.Name, pos, p.Steps); err != nil {
return err
}
}
for _, z := range regions {
var geofence []byte // nil → SQL NULL → everywhere
if len(z.GeofenceJSON) > 0 {
geofence = z.GeofenceJSON
}
if _, err := tx.Exec(ctx,
`INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, geofence) VALUES ($1, $2, $3, $4, $5, $6)`,
orgID, z.Token, z.DisplayName, z.Layer, z.Primary, geofence); err != nil {
return fmt.Errorf("insert region %q: %w", z.Token, err)
}
}
return nil
return replaceRegionsTx(ctx, tx, orgID, regions)
})
}
// ReplaceRegions replaces just an org's regions (profiles untouched) and sets the
// org's root (*) flood policy, in one transaction. Regions are spatially
// interdependent — parenting is derived from overlap — so the region editor sends
// the full desired set each save.
func (s *Store) ReplaceRegions(ctx context.Context, orgID int64, regions []RegionInput, rootAllowFlood bool) error {
return s.inTx(ctx, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`UPDATE organizations SET root_allow_flood = $2 WHERE id = $1`, orgID, rootAllowFlood); err != nil {
return fmt.Errorf("set root flood: %w", err)
}
return replaceRegionsTx(ctx, tx, orgID, regions)
})
}
// RootAllowFlood reports whether flooding is allowed at the org's root region (*).
func (s *Store) RootAllowFlood(ctx context.Context, orgID int64) (bool, error) {
var allow bool
if err := s.pool.QueryRow(ctx,
`SELECT root_allow_flood FROM organizations WHERE id = $1`, orgID).Scan(&allow); err != nil {
return false, fmt.Errorf("root allow flood: %w", err)
}
return allow, nil
}
// CreateProfile inserts a new named profile for an org, appended after existing
// ones. Returns ErrDuplicate if the org already has a profile with that name.
func (s *Store) CreateProfile(ctx context.Context, orgID int64, name string, steps []ConfigStep) (int64, error) {
var id int64
err := s.inTx(ctx, func(tx pgx.Tx) error {
var pos int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM config_profiles WHERE org_id = $1`, orgID).Scan(&pos); err != nil {
return fmt.Errorf("count profiles: %w", err)
}
var err error
id, err = insertProfile(ctx, tx, orgID, name, pos, steps)
return err
})
if isUniqueViolation(err) {
return 0, ErrDuplicate
}
return id, err
}
// GetProfile returns a single profile (with steps) scoped to its org, or
// ErrNotFound if no such profile belongs to the org.
func (s *Store) GetProfile(ctx context.Context, orgID, profileID int64) (*Profile, error) {
var p Profile
err := s.pool.QueryRow(ctx,
`SELECT id, name, position FROM config_profiles WHERE id = $1 AND org_id = $2`, profileID, orgID).
Scan(&p.ID, &p.Name, &p.Position)
if err != nil {
return nil, notFoundOr(err, "get profile")
}
rows, err := s.pool.Query(ctx,
`SELECT position, command_line, command_id, comment FROM config_profile_steps
WHERE profile_id = $1 ORDER BY position, id`, profileID)
if err != nil {
return nil, fmt.Errorf("get profile steps: %w", err)
}
p.Steps, err = collectRows(rows, func(r pgx.Row) (ConfigStep, error) {
var st ConfigStep
return st, r.Scan(&st.Position, &st.CommandLine, &st.CommandID, &st.Comment)
})
if err != nil {
return nil, err
}
return &p, nil
}
// UpdateProfile renames a profile and replaces its steps, scoped to its org. Its
// position is preserved. Returns ErrNotFound if the profile isn't the org's, or
// ErrDuplicate if the new name collides with another profile.
func (s *Store) UpdateProfile(ctx context.Context, orgID, profileID int64, name string, steps []ConfigStep) error {
err := s.inTx(ctx, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx,
`UPDATE config_profiles SET name = $3 WHERE id = $1 AND org_id = $2`, profileID, orgID, name)
if err != nil {
return fmt.Errorf("rename profile: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM config_profile_steps WHERE profile_id = $1`, profileID); err != nil {
return fmt.Errorf("clear profile steps: %w", err)
}
return insertConfigSteps(ctx, tx, "config_profile_steps", "profile_id", profileID, steps)
})
if isUniqueViolation(err) {
return ErrDuplicate
}
return err
}
// DeleteProfile removes a profile (its steps cascade), scoped to its org.
// Returns ErrNotFound if no such profile belongs to the org.
func (s *Store) DeleteProfile(ctx context.Context, orgID, profileID int64) error {
tag, err := s.pool.Exec(ctx,
`DELETE FROM config_profiles WHERE id = $1 AND org_id = $2`, profileID, orgID)
if err != nil {
return fmt.Errorf("delete profile: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// insertProfile inserts one profile row plus its steps, returning the new id.
func insertProfile(ctx context.Context, tx pgx.Tx, orgID int64, name string, pos int, steps []ConfigStep) (int64, error) {
var pid int64
if err := tx.QueryRow(ctx,
`INSERT INTO config_profiles (org_id, name, position) VALUES ($1, $2, $3) RETURNING id`,
orgID, name, pos).Scan(&pid); err != nil {
return 0, fmt.Errorf("insert profile %q: %w", name, err)
}
if err := insertConfigSteps(ctx, tx, "config_profile_steps", "profile_id", pid, steps); err != nil {
return 0, fmt.Errorf("insert profile %q steps: %w", name, err)
}
return pid, nil
}
// replaceRegionsTx clears an org's regions and inserts the given set within tx.
func replaceRegionsTx(ctx context.Context, tx pgx.Tx, orgID int64, regions []RegionInput) error {
if _, err := tx.Exec(ctx, `DELETE FROM config_regions WHERE org_id = $1`, orgID); err != nil {
return fmt.Errorf("clear regions: %w", err)
}
for _, z := range regions {
var geofence []byte // nil → SQL NULL → everywhere
if len(z.GeofenceJSON) > 0 {
geofence = z.GeofenceJSON
}
if _, err := tx.Exec(ctx,
`INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, allow_flood, geofence) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
orgID, z.Token, z.DisplayName, z.Layer, z.Primary, z.AllowFlood, geofence); err != nil {
return fmt.Errorf("insert region %q: %w", z.Token, err)
}
}
return nil
}
// insertConfigSteps writes an ordered run of steps into a step table keyed by a
// parent id column. table/parentCol are fixed internal constants — never user
// input — so the interpolation is safe.
@@ -240,12 +362,18 @@ func betterParent(a, b Region) bool {
// RegionDefCommands renders the regions that apply at (lat, lon) into the MeshCore
// commands to run on a repeater: a single `region def …` line describing the
// region tree for the location, followed by `region save`. The tree is the subset
// of regions whose geofence contains the point (match-all regions always apply),
// re-parented onto their nearest matching ancestor and serialized depth-first with
// the `child|ancestor` pop-back syntax so sibling branches (overlapping same-layer
// regions) come out correctly. Returns nil when no region covers the location.
func RegionDefCommands(regions []Region, lat, lon *float64) []string {
// region tree for the location, then explicit flood policy for the root (*) and
// each applied region, then `region save`. The tree is the subset of regions whose
// geofence contains the point (match-all regions always apply), re-parented onto
// their nearest matching ancestor and serialized depth-first with the
// `child|ancestor` pop-back syntax so sibling branches (overlapping same-layer
// regions) come out correctly. Flood lines are always explicit (allowf/denyf) so
// applying the config normalizes the node over any prior manual settings.
//
// Returns nil when no region covers the location. This is the safety guard: a lone
// `region denyf *` with nothing allowed would kill all flooding, so the root deny
// only ever ships alongside a real def + at least one applied region.
func RegionDefCommands(regions []Region, rootAllowFlood bool, lat, lon *float64) []string {
parents := regionParents(regions)
matched := make([]bool, len(regions))
any := false
@@ -322,7 +450,21 @@ func RegionDefCommands(regions []Region, lat, lon *float64) []string {
}
tokens = append(tokens, regions[node].Token)
}
return []string{"region def " + strings.Join(tokens, " "), "region save"}
// Def line, then explicit flood policy (root first, then each applied region in
// def order), then persist.
out := []string{"region def " + strings.Join(tokens, " "), floodCommand("*", rootAllowFlood)}
for _, node := range seq {
out = append(out, floodCommand(regions[node].Token, regions[node].AllowFlood))
}
return append(out, "region save")
}
// floodCommand returns the MeshCore command setting a region's flood policy.
func floodCommand(name string, allow bool) string {
if allow {
return "region allowf " + name
}
return "region denyf " + name
}
// RegionMatches reports whether a region applies at (lat, lon): a match-all region
+163 -7
View File
@@ -78,6 +78,129 @@ func TestOrgConfigReplaceAndRead(t *testing.T) {
}
}
func TestProfileCRUD(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, err := st.CreateUser(ctx, "profowner", "")
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "ProfOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
id, err := st.CreateProfile(ctx, org.ID, "ESP32", []ConfigStep{{CommandLine: "set tx 22"}, {Comment: "note"}})
if err != nil {
t.Fatalf("create: %v", err)
}
// Duplicate name (case-insensitive per the unique constraint is not guaranteed;
// exact-name collide is).
if _, err := st.CreateProfile(ctx, org.ID, "ESP32", nil); err != ErrDuplicate {
t.Fatalf("duplicate create err = %v, want ErrDuplicate", err)
}
got, err := st.GetProfile(ctx, org.ID, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Name != "ESP32" || len(got.Steps) != 2 || got.Steps[0].CommandLine != "set tx 22" {
t.Fatalf("get profile = %+v", got)
}
// Wrong org can't see it.
other, err := st.CreateOrg(ctx, "OtherOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
if _, err := st.GetProfile(ctx, other.ID, id); err != ErrNotFound {
t.Fatalf("cross-org get err = %v, want ErrNotFound", err)
}
// Update: rename + replace steps, position preserved.
if err := st.UpdateProfile(ctx, org.ID, id, "Heltec", []ConfigStep{{CommandLine: "set tx 20"}}); err != nil {
t.Fatalf("update: %v", err)
}
got, _ = st.GetProfile(ctx, org.ID, id)
if got.Name != "Heltec" || len(got.Steps) != 1 || got.Steps[0].CommandLine != "set tx 20" {
t.Fatalf("after update = %+v", got)
}
// Update non-owned → ErrNotFound.
if err := st.UpdateProfile(ctx, other.ID, id, "X", nil); err != ErrNotFound {
t.Fatalf("cross-org update err = %v, want ErrNotFound", err)
}
// A second profile, then a name clash on update → ErrDuplicate.
id2, err := st.CreateProfile(ctx, org.ID, "Second", nil)
if err != nil {
t.Fatal(err)
}
if err := st.UpdateProfile(ctx, org.ID, id2, "Heltec", nil); err != ErrDuplicate {
t.Fatalf("update to dup name err = %v, want ErrDuplicate", err)
}
// Delete: gone, and idempotency guard returns ErrNotFound on re-delete.
if err := st.DeleteProfile(ctx, org.ID, id); err != nil {
t.Fatalf("delete: %v", err)
}
if err := st.DeleteProfile(ctx, org.ID, id); err != ErrNotFound {
t.Fatalf("re-delete err = %v, want ErrNotFound", err)
}
remaining, _ := st.ListProfiles(ctx, org.ID)
if len(remaining) != 1 || remaining[0].Name != "Second" {
t.Fatalf("after delete, profiles = %+v", remaining)
}
}
func TestReplaceRegionsKeepsProfiles(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, err := st.CreateUser(ctx, "rgnowner", "")
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "RgnOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateProfile(ctx, org.ID, "Keep", nil); err != nil {
t.Fatal(err)
}
if err := st.ReplaceRegions(ctx, org.ID, []RegionInput{
{Token: "country", DisplayName: "Country", Layer: 1, AllowFlood: true},
{Token: "metro", DisplayName: "Metro", Layer: 2, Primary: true, AllowFlood: true, GeofenceJSON: geo.Rectangle(10, 30, 20, 40)},
}, false); err != nil {
t.Fatalf("replace regions: %v", err)
}
// The root (*) flood policy round-trips (we passed deny above).
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || allow {
t.Fatalf("root allow flood = %v (err %v), want false", allow, err)
}
// Regions saved and profiles untouched.
gotR, _ := st.ListRegions(ctx, org.ID)
if len(gotR) != 2 {
t.Fatalf("regions = %d, want 2", len(gotR))
}
gotP, _ := st.ListProfiles(ctx, org.ID)
if len(gotP) != 1 || gotP[0].Name != "Keep" {
t.Fatalf("profiles after region replace = %+v, want [Keep]", gotP)
}
// Replacing with an empty set clears regions but still keeps profiles.
if err := st.ReplaceRegions(ctx, org.ID, nil, true); err != nil {
t.Fatalf("clear regions: %v", err)
}
gotR, _ = st.ListRegions(ctx, org.ID)
if len(gotR) != 0 {
t.Fatalf("regions after clear = %d, want 0", len(gotR))
}
gotP, _ = st.ListProfiles(ctx, org.ID)
if len(gotP) != 1 {
t.Fatalf("profiles after clear = %d, want 1", len(gotP))
}
}
func TestRegionDefCommands(t *testing.T) {
t.Parallel()
@@ -86,7 +209,7 @@ func TestRegionDefCommands(t *testing.T) {
if err != nil {
t.Fatalf("parse %s: %v", token, err)
}
return Region{Token: token, Layer: layer, Geofence: shape}
return Region{Token: token, Layer: layer, AllowFlood: true, Geofence: shape}
}
// us (L1) covers everything; ny and pa (L2) sit inside us and overlap each
@@ -100,9 +223,10 @@ func TestRegionDefCommands(t *testing.T) {
def := func(label string, lat, lon float64, want string) {
t.Helper()
got := RegionDefCommands(regions, ptr(lat), ptr(lon))
if len(got) != 2 || got[0] != want || got[1] != "region save" {
t.Fatalf("%s = %v, want [%q, region save]", label, got, want)
// The def line comes first; flood lines follow; "region save" is always last.
got := RegionDefCommands(regions, true, ptr(lat), ptr(lon))
if len(got) < 2 || got[0] != want || got[len(got)-1] != "region save" {
t.Fatalf("%s = %v, want [%q, …, region save]", label, got, want)
}
}
@@ -113,11 +237,43 @@ func TestRegionDefCommands(t *testing.T) {
// In pa only (lon 40) → linear us → pa.
def("pa only", 15, 40, "region def us pa")
// Unknown / outside location → no commands.
if got := RegionDefCommands(regions, nil, nil); got != nil {
// Unknown / outside location → no commands (the safety guard: never a lone denyf *).
if got := RegionDefCommands(regions, true, nil, nil); got != nil {
t.Fatalf("no location: got %v, want nil", got)
}
if got := RegionDefCommands(regions, ptr(-5.0), ptr(-5.0)); got != nil {
if got := RegionDefCommands(regions, false, ptr(-5.0), ptr(-5.0)); got != nil {
t.Fatalf("outside: got %v, want nil", got)
}
}
func TestRegionDefFloodCommands(t *testing.T) {
t.Parallel()
mk := func(token string, layer int, allow bool, box []byte) Region {
shape, err := geo.Parse(box)
if err != nil {
t.Fatalf("parse %s: %v", token, err)
}
return Region{Token: token, Layer: layer, AllowFlood: allow, Geofence: shape}
}
// us allows flood, ny denies it; root denies flood (scoped-flooding pattern).
regions := []Region{
mk("us", 1, true, geo.Rectangle(0, 0, 100, 100)),
mk("ny", 2, false, geo.Rectangle(10, 10, 30, 30)),
}
got := RegionDefCommands(regions, false, ptr(15.0), ptr(15.0))
want := []string{
"region def us ny",
"region denyf *",
"region allowf us",
"region denyf ny",
"region save",
}
if len(got) != len(want) {
t.Fatalf("commands = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("commands[%d] = %q, want %q (full %v)", i, got[i], want[i], got)
}
}
}
@@ -0,0 +1,12 @@
-- +goose Up
-- Per-region flood policy and the org's root (*) flood policy. MeshCore controls
-- flooding per region via `region allowf <name>` / `region denyf <name>` (name may
-- be the wildcard root *). Defaults are TRUE so existing configs keep flooding
-- allowed everywhere until an admin opts into scoped flooding (deny at root, allow
-- in defined regions).
ALTER TABLE config_regions ADD COLUMN allow_flood BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE organizations ADD COLUMN root_allow_flood BOOLEAN NOT NULL DEFAULT TRUE;
-- +goose Down
ALTER TABLE organizations DROP COLUMN root_allow_flood;
ALTER TABLE config_regions DROP COLUMN allow_flood;
+9 -6
View File
@@ -19,8 +19,11 @@ type Org struct {
Name string
Description string
Region string
CreatedBy *int64
CreatedAt time.Time
// RootAllowFlood is the flood policy for the org's root region (*): whether
// flooding is allowed everywhere by default. See RegionDefCommands.
RootAllowFlood bool
CreatedBy *int64
CreatedAt time.Time
}
// OrgSummary is a public directory entry for an organization.
@@ -115,8 +118,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O
}
if err := tx.QueryRow(ctx,
`INSERT INTO organizations (slug, name, created_by) VALUES ($1, $2, $3)
RETURNING id, slug, name, description, region, created_by, created_at`,
slug, name, creatorID).Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.CreatedBy, &o.CreatedAt); err != nil {
RETURNING id, slug, name, description, region, root_allow_flood, created_by, created_at`,
slug, name, creatorID).Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.RootAllowFlood, &o.CreatedBy, &o.CreatedAt); err != nil {
return fmt.Errorf("insert org: %w", err)
}
if _, err := tx.Exec(ctx,
@@ -138,8 +141,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O
func (s *Store) GetOrg(ctx context.Context, id int64) (*Org, error) {
var o Org
err := s.pool.QueryRow(ctx,
`SELECT id, slug, name, description, region, created_by, created_at FROM organizations WHERE id = $1`, id).
Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.CreatedBy, &o.CreatedAt)
`SELECT id, slug, name, description, region, root_allow_flood, created_by, created_at FROM organizations WHERE id = $1`, id).
Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.RootAllowFlood, &o.CreatedBy, &o.CreatedAt)
if err != nil {
return nil, notFoundOr(err, "get org")
}
+5 -1
View File
@@ -114,7 +114,11 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
cv.MapBounds = []float64{union.minLat, union.minLon, union.maxLat, union.maxLon}
}
if cv.PreviewActive {
cv.RegionDef = store.RegionDefCommands(regions, lat, lon)
rootAllow, err := st.RootAllowFlood(ctx, orgID)
if err != nil {
return ConfigView{}, err
}
cv.RegionDef = store.RegionDefCommands(regions, rootAllow, lat, lon)
}
return cv, nil
}
+15 -3
View File
@@ -98,7 +98,7 @@
map.on("pm:drawend", function () { drawing = false; });
function setStatus(b) {
if (b.status) b.status.textContent = b.layer ? "Custom area" : "Applies everywhere";
if (b.status) b.status.textContent = b.layer ? "Custom area" : "No area yet";
}
function serialize(b) {
b.hidden.value = b.layer ? geomString(b.layer) : "";
@@ -111,7 +111,16 @@
}
function setActive(b) {
active = b;
blocks.forEach(function (x) { x.el.classList.toggle("region-active", x === b); });
blocks.forEach(function (x) {
var on = x === b;
x.el.classList.toggle("region-active", on);
// The Edit button is the visible selection affordance: it lights up and
// reads "Editing" for the active region, "Edit" otherwise.
if (x.edit) {
x.edit.classList.toggle("btn-primary", on);
x.edit.textContent = on ? "Editing" : "Edit";
}
});
if (banner) banner.textContent = b ? regionName(b) : "—";
restyle();
}
@@ -163,12 +172,15 @@
el: el,
hidden: el.querySelector('input[name="region_geojson"]'),
status: el.querySelector(".region-shape-status"),
edit: el.querySelector(".region-edit-btn"),
layer: null,
};
var layer = layerFromGeoJSON(b.hidden && b.hidden.value);
if (layer) bindLayer(b, layer);
setStatus(b);
el.addEventListener("click", function () { if (!drawing) setActive(b); });
// Selection is driven by the explicit Edit button (and clicking the shape on
// the map) — the card itself isn't clickable, which read as unintuitive.
if (b.edit) b.edit.addEventListener("click", function () { if (!drawing) setActive(b); });
var clearBtn = el.querySelector(".region-clear");
if (clearBtn) clearBtn.addEventListener("click", function (e) {
e.stopPropagation();