diff --git a/internal/core/config_flow_test.go b/internal/core/config_flow_test.go index b5c165f..548fc0b 100644 --- a/internal/core/config_flow_test.go +++ b/internal/core/config_flow_test.go @@ -3,6 +3,7 @@ package core import ( "net/http" "net/url" + "strconv" "strings" "testing" ) @@ -44,64 +45,120 @@ func TestOrgConfigProfilesFlow(t *testing.T) { t.Fatalf("create profile %s redirected to %q, want %q", p.name, got, want) } } - // A geofenced region, saved on the region page. + // A region, created in two steps the way the editor does it: the attribute form + // creates it as a draft, then the area page saves its drawn shape. 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) + t.Fatalf("create region status = %d, want 303", rsave.StatusCode) + } + regions, err := st.ListRegions(ctx, org.ID) + if err != nil || len(regions) != 1 { + t.Fatalf("ListRegions = %v, %v; want one region", regions, err) + } + rid := strconv.FormatInt(regions[0].ID, 10) + + // Before an area is drawn the region is a draft: flagged on the config page and + // contributing nothing to a location's region def. + draftView := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?lat=15&lon=35", sess)) + if !strings.Contains(draftView, `data-testid="config-region-draft"`) { + t.Fatal("a region with no area should be flagged as needing one") + } + if strings.Contains(draftView, "region def") { + t.Fatal("a draft region must not produce region def commands") + } + + asave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/area", url.Values{ + "region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`}, + }, sess) + asave.Body.Close() + if asave.StatusCode != http.StatusSeeOther { + t.Fatalf("save area status = %d, want 303", asave.StatusCode) } body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess)) - // The profile list, with per-row admin actions; the geofenced region surfaces as - // the click-to-preview location map (regions aren't listed individually). + // The profile list and the region list, each with per-row admin actions, plus the + // click-to-preview location map the drawn geofence enables. for _, want := range []string{ "ESP32", "nRF52", `data-testid="config-profile-list"`, `data-testid="config-profile-add"`, `data-testid="config-profile-edit"`, `data-testid="config-profile-delete"`, - `id="config-profile-modal"`, "Preview a location", "region-map", + `id="config-profile-modal"`, `data-testid="config-regions"`, "region-map", + "Mountains", "mtns", `data-testid="config-region-row"`, `data-testid="config-region-add"`, + `data-testid="config-region-area"`, `id="config-region-modal"`, } { if !strings.Contains(body, want) { t.Fatalf("config view missing %q", want) } } - // Default selection shows the first profile's base settings. - if !strings.Contains(body, "ESP32 · base settings") { - t.Fatalf("default config view should show the first profile's base settings") + // With an area drawn, the region is no longer flagged as a draft. + if strings.Contains(body, `data-testid="config-region-draft"`) { + t.Fatal("a region with an area should not be flagged as needing one") + } + // And it now applies at a location inside it. + preview := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?lat=15&lon=35", sess)) + if !strings.Contains(preview, "region def mtns") { + t.Fatal("a drawn region should apply at a location inside it") + } + // Default selection shows the first profile's steps, not the second's. + if !strings.Contains(body, "esp base") || strings.Contains(body, "nrf base") { + t.Fatal("default config view should show the first profile's base settings") } // The profile selector works: ?profile= switches the shown base settings. sel := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?profile=nRF52", sess)) - if !strings.Contains(sel, "nRF52 · base settings") { - t.Fatalf("?profile=nRF52 should show nRF52's base settings") + if !strings.Contains(sel, "nrf base") || strings.Contains(sel, "esp base") { + t.Fatal("?profile=nRF52 should show nRF52's base settings") } - // The admin editor pages render: the hub summarizes the regions (profiles live - // on the config page now), 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{"Edit regions", "1 region"} { - if !strings.Contains(hub, want) { - t.Fatalf("config hub missing %q", want) + // The preview is assembled: with a location picked, the selected profile's steps + // and that location's region commands appear in one block, which is what a + // repeater owner actually runs. + assembled := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?profile=ESP32&lat=15&lon=35", sess)) + block := assembled[strings.Index(assembled, `data-testid="config-commands"`):] + block = block[:strings.Index(block, "")] + for _, want := range []string{"esp base", "region def mtns", "region save"} { + if !strings.Contains(block, want) { + t.Fatalf("assembled preview missing %q; got %q", want, block) } } - if strings.Contains(hub, "Add profile") { - t.Fatalf("config hub should no longer manage profiles") + // Region-contributed lines are marked so the page can show where they came from. + if !strings.Contains(block, `data-testid="config-region-line"`) { + t.Fatal("region commands in the assembled preview should be marked as region lines") } + // And the legend marks the regions that cover the picked location. + if !strings.Contains(assembled, `data-testid="config-region-match"`) { + t.Fatal("the legend should mark which regions apply at the previewed location") + } + + // The standalone editor pages render (the no-JS fallbacks): the new-profile page + // shows the base-settings editor, and the region area page has the map. 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"} { + rgn := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/area", sess)) + for _, want := range []string{"region-map", "Mountains", "mtns", "Save area", "Clear area"} { if !strings.Contains(rgn, want) { - t.Fatalf("regions editor missing %q", want) + t.Fatalf("region area editor missing %q", want) } } + // The old config hub is gone entirely (404), and the old bulk region editor's + // path is now POST-only — the collection you create a region through — so a GET + // there is a 405 rather than a page. + hub := do(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", sess) + hub.Body.Close() + if hub.StatusCode != http.StatusNotFound { + t.Errorf("GET /config/edit = %d, want 404 (route removed)", hub.StatusCode) + } + bulk := do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", sess) + bulk.Body.Close() + if bulk.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET /config/regions = %d, want 405 (POST-only collection)", bulk.StatusCode) + } // A non-admin member of a config-less org does NOT see the Configuration tab. plain, _ := st.CreateUser(ctx, "plainmember", "") diff --git a/internal/core/config_profile.go b/internal/core/config_profile.go index 4c95bad..08e78bf 100644 --- a/internal/core/config_profile.go +++ b/internal/core/config_profile.go @@ -10,27 +10,10 @@ import ( "github.com/go-chi/chi/v5" - "github.com/jleight/meshtender/internal/geo" "github.com/jleight/meshtender/internal/store" "github.com/jleight/meshtender/internal/web" ) -// 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 -// editor reads and writes (empty = applies everywhere). -type configRegionView struct { - DisplayName string - Token string - Layer int - Primary bool - AllowFlood bool - GeofenceJSON string -} - // pageOrgConfig renders an org's configuration read-only: a selected profile's // base settings plus the regions (location steps). Visible to any signed-in user; // admins get an Edit button. ?profile= chooses which profile to show; ?lat=&lon= @@ -76,39 +59,6 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) { s.Render(w, r, "org_config.html", data) } -// pageConfigHub is the admin configuration overview: a summary of the org's -// regions, which are edited on the map page. (Profiles are managed inline on the -// Configuration page itself.) -func (s *Handlers) pageConfigHub(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 { - s.NotFound(w, r) - return - } - regions, err := s.Store.ListRegions(r.Context(), orgID) - if err != nil { - s.ServerError(w, r, "could not load config", err) - 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(), web.OrgNavArgs{OrgID: orgID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}), - "RegionCount": len(regions), - "PrimaryRegion": primary, - }) -} - // 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) { @@ -150,7 +100,7 @@ func (s *Handlers) pageProfileEdit(w http.ResponseWriter, r *http.Request) { func (s *Handlers) renderProfileEdit(w http.ResponseWriter, r *http.Request, org *store.Org, pid int64, name, stepsText string, errs, risky []string) { data := map[string]any{ "Org": org, - "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}), + "Nav": s.orgAdminNav(r, org), "ProfileID": pid, "Action": profileFormAction(org.Slug, pid), "Name": name, @@ -265,178 +215,6 @@ func (s *Handlers) handleDeleteProfile(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", 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 { - s.NotFound(w, r) - return - } - regions, err := s.Store.ListRegions(r.Context(), orgID) - if err != nil { - s.ServerError(w, r, "could not load regions", err) - return - } - s.Render(w, r, "config_regions_edit.html", map[string]any{ - "Org": org, - "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: orgID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: 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 - } - if err := r.ParseForm(); err != nil { - http.Error(w, "bad form", http.StatusBadRequest) - return - } - 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 { - s.NotFound(w, r) - return - } - s.Render(w, r, "config_regions_edit.html", map[string]any{ - "Org": org, - "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: orgID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}), - "EmptyRegion": configRegionView{AllowFlood: true}, - "Errors": errs, - "Regions": regionVs, - "RootAllowFlood": rootAllowFlood, - }) - return - } - if err := s.Store.ReplaceRegions(r.Context(), orgID, regions, rootAllowFlood); err != nil { - s.ServerError(w, r, "could not save", err) - return - } - http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin -} - -// 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 -// letters, digits, hyphens, or underscores (they are space-joined into a single -// region def line, so spaces and the |/, separators are not allowed). -func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.RegionInput, []configRegionView) { - tokens := r.Form["region_token"] - if len(tokens) > maxConfigRegions { - *errs = append(*errs, fmt.Sprintf("Too many regions (max %d).", maxConfigRegions)) - } - seen := map[string]bool{} - primaryTaken := false - var ins []store.RegionInput - var views []configRegionView - for i := range tokens { - token := strings.TrimSpace(formAt(r, "region_token", i)) - display := strings.TrimSpace(formAt(r, "region_display", i)) - 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, AllowFlood: allowFlood, GeofenceJSON: geojson} - if token == "" { - *errs = append(*errs, "A region is missing its short name.") - views = append(views, zv) - continue - } - if !validRegionToken(token) { - *errs = append(*errs, fmt.Sprintf("Region name %q may only contain letters, digits, hyphens, or underscores.", token)) - views = append(views, zv) - continue - } - if seen[strings.ToLower(token)] { - *errs = append(*errs, fmt.Sprintf("Duplicate region name %q.", token)) - views = append(views, zv) - continue - } - seen[strings.ToLower(token)] = true - if display == "" { - 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) - continue - } - // Only one region may be primary; keep the first and clear any later ones. - if primary && primaryTaken { - primary, zv.Primary = false, false - } else if primary { - primaryTaken = true - } - views = append(views, zv) - ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, Primary: primary, AllowFlood: allowFlood, GeofenceJSON: geofence}) - } - return ins, views -} - -// validRegionToken reports whether s is a usable MeshCore region token: non-empty -// and limited to letters, digits, hyphens, and underscores. -func validRegionToken(s string) bool { - if s == "" { - return false - } - for _, c := range s { - switch { - case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': - default: - return false - } - } - return true -} - -// formAt returns the i-th value of a repeated form field, or "". -func formAt(r *http.Request, field string, i int) string { - v := r.Form[field] - if i < len(v) { - return v[i] - } - return "" -} - -// regionGeofence validates the GeoJSON drawn for a region. An empty shape is a -// region that applies everywhere (nil geofence); anything else must parse as a -// GeoJSON Polygon/MultiPolygon. -func regionGeofence(zv configRegionView, name string, errs *[]string) ([]byte, bool) { - gj := strings.TrimSpace(zv.GeofenceJSON) - if gj == "" { - return nil, true // everywhere - } - if _, err := geo.Parse([]byte(gj)); err != nil { - *errs = append(*errs, fmt.Sprintf("Region %q has an invalid map shape — redraw it on the map.", name)) - return nil, false - } - return []byte(gj), true -} - // parseConfigSteps turns textarea lines into config steps, validating each command // against the catalog with the same resolver the console uses. Blank lines are // skipped; "# ..." lines become comment steps; unknown commands are recorded in @@ -517,16 +295,3 @@ func stepsToText(steps []store.ConfigStep) string { } return b.String() } - -// 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 { - out := make([]configRegionView, 0, len(regions)) - for _, z := range regions { - out = append(out, configRegionView{ - DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, Primary: z.Primary, - AllowFlood: z.AllowFlood, GeofenceJSON: string(z.GeofenceJSON), - }) - } - return out -} diff --git a/internal/core/config_profile_test.go b/internal/core/config_profile_test.go index 4662cb9..b5c0b18 100644 --- a/internal/core/config_profile_test.go +++ b/internal/core/config_profile_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - "github.com/jleight/meshtender/internal/geo" "github.com/jleight/meshtender/internal/store" ) @@ -80,33 +79,3 @@ func TestParseConfigStepsRegionMarker(t *testing.T) { } _ = risky } - -func TestRegionGeofence(t *testing.T) { - t.Parallel() - - // Empty shape → everywhere (nil geofence, ok). - if gf, ok := regionGeofence(configRegionView{}, "z", new([]string)); !ok || gf != nil { - t.Fatalf("empty shape: got (%v,%v), want (nil,true)", gf, ok) - } - - // A drawn polygon → carried through verbatim, parseable, contains its center. - drawn := `{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}` - var errs []string - gf, ok := regionGeofence(configRegionView{GeofenceJSON: drawn}, "z", &errs) - if !ok || gf == nil { - t.Fatalf("drawn polygon: got (%v,%v), want non-nil geofence ok", gf, ok) - } - shape, err := geo.Parse(gf) - if err != nil { - t.Fatalf("parse geofence: %v", err) - } - if !shape.Contains(15, 35) { - t.Fatalf("geofence should contain its center") - } - - // Malformed GeoJSON → error. - errs = nil - if _, ok := regionGeofence(configRegionView{GeofenceJSON: "{not geojson"}, "z", &errs); ok || len(errs) == 0 { - t.Fatalf("invalid shape should be rejected with an error") - } -} diff --git a/internal/core/config_region.go b/internal/core/config_region.go new file mode 100644 index 0000000..5ca8d64 --- /dev/null +++ b/internal/core/config_region.go @@ -0,0 +1,404 @@ +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" +) + +// Regions are edited one at a time, split by what each part needs from the screen: +// +// - Attributes (name, token, level, primary, flood) are a small form, edited in a +// modal on the Configuration page — the same pattern as profiles. +// - The area is drawn on a map, which needs the width and the spatial context of +// the neighboring regions, so it gets its own workspace page. +// +// The two save through separate endpoints, so a rejected attribute edit can never +// discard a drawn polygon (and vice versa). + +// configRegionView is a region in the admin editor: its display name, MeshCore +// token, layer (depth / region def order), flags, and the raw GeoJSON geofence the +// map editor reads and writes (empty = a draft that applies nowhere yet). +type configRegionView struct { + ID int64 + DisplayName string + Token string + Layer int + Primary bool + AllowFlood bool + GeofenceJSON string +} + +// regionAreaSibling is one *other* region of the org, drawn as read-only context on +// the area map so an admin can see how the shape they're editing sits against its +// neighbors. +type regionAreaSibling struct { + Name string `json:"name"` + GeoJSON string `json:"geojson"` +} + +// pageRegionEdit renders the region attribute editor: blank for the /new route, or +// pre-filled when a {rid} is present. 404s if the region isn't this org's. +func (s *Handlers) pageRegionEdit(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 { + s.NotFound(w, r) + return + } + // A new region defaults to allowing flood, matching the column default. + zv := configRegionView{AllowFlood: true} + if raw := chi.URLParam(r, "rid"); raw != "" { + rid, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + s.NotFound(w, r) + return + } + z, err := s.Store.GetRegion(r.Context(), orgID, rid) + if errors.Is(err, store.ErrNotFound) { + s.NotFound(w, r) + return + } + if err != nil { + s.ServerError(w, r, "could not load region", err) + return + } + zv = regionView(*z) + } + s.renderRegionEdit(w, r, org, zv, nil) +} + +// renderRegionEdit renders the attribute editor (shared by the initial GET and the +// error re-render). A zero ID means a new region. An htmx request gets the modal +// fragment the Configuration page swaps in place; anything else (no JS, or a direct +// link) gets the standalone page. +func (s *Handlers) renderRegionEdit(w http.ResponseWriter, r *http.Request, org *store.Org, zv configRegionView, errs []string) { + data := map[string]any{ + "Org": org, + "Nav": s.orgAdminNav(r, org), + "Region": zv, + "Action": regionFormAction(org.Slug, zv.ID), + "Errors": errs, + } + if r.Header.Get("HX-Request") != "" { + data["Layout"] = "config-region-modal" + } + s.Render(w, r, "config_region_edit.html", data) +} + +// regionFormAction is where the attribute editor posts: the collection for a new +// region, the region's own URL for an update. +func regionFormAction(slug string, rid int64) string { + if rid == 0 { + return "/orgs/" + slug + "/config/regions" + } + return "/orgs/" + slug + "/config/regions/" + strconv.FormatInt(rid, 10) +} + +// handleCreateRegion validates and inserts a new region. +func (s *Handlers) handleCreateRegion(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + s.saveRegion(w, r, orgID, 0) +} + +// handleUpdateRegion validates and updates an existing region's attributes. +func (s *Handlers) handleUpdateRegion(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + rid, ok := s.regionID(r) + if !ok { + s.NotFound(w, r) + return + } + s.saveRegion(w, r, orgID, rid) +} + +// saveRegion parses the attribute form and creates (rid 0) or updates a region. On +// a validation error it re-renders the editor with the entered values preserved; a +// duplicate token is reported the same way rather than as a server error. +func (s *Handlers) saveRegion(w http.ResponseWriter, r *http.Request, orgID, rid 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 { + s.NotFound(w, r) + return + } + layer, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("region_layer"))) + zv := configRegionView{ + ID: rid, + DisplayName: strings.TrimSpace(r.FormValue("region_display")), + Token: strings.TrimSpace(r.FormValue("region_token")), + Layer: layer, + Primary: r.FormValue("region_primary") == "1", + AllowFlood: r.FormValue("region_allow_flood") == "1", + } + + var errs []string + switch { + case zv.Token == "": + errs = append(errs, "Give the region a short name — it's the name MeshCore uses.") + case !validRegionToken(zv.Token): + errs = append(errs, fmt.Sprintf("Region name %q may only contain letters, digits, hyphens, or underscores.", zv.Token)) + } + // The display name is optional; fall back to the token so a row always has a + // label to show. + display := zv.DisplayName + if display == "" { + display = zv.Token + } + + if len(errs) == 0 { + in := store.RegionInput{ + Token: zv.Token, DisplayName: display, Layer: zv.Layer, + Primary: zv.Primary, AllowFlood: zv.AllowFlood, + } + // Only the area endpoint writes a geofence, so neither branch here can drop a + // shape that's already been drawn. + if rid == 0 { + _, err = s.Store.CreateRegion(r.Context(), orgID, in) + } else { + err = s.Store.UpdateRegion(r.Context(), orgID, rid, in) + } + switch { + case errors.Is(err, store.ErrDuplicate): + errs = append(errs, fmt.Sprintf("A region named %q already exists.", zv.Token)) + case errors.Is(err, store.ErrNotFound): + s.NotFound(w, r) + return + case err != nil: + s.ServerError(w, r, "could not save region", err) + return + default: + // hxRedirect closes the modal and reloads the config page. + s.hxRedirect(w, r, "/orgs/"+orgParam(r)+"/config") + return + } + } + s.renderRegionEdit(w, r, org, zv, errs) +} + +// handleDeleteRegion removes a region and returns to the config page. +func (s *Handlers) handleDeleteRegion(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + rid, ok := s.regionID(r) + if !ok { + s.NotFound(w, r) + return + } + if err := s.Store.DeleteRegion(r.Context(), orgID, rid); err != nil && !errors.Is(err, store.ErrNotFound) { + s.ServerError(w, r, "could not delete region", err) + return + } + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin +} + +// pageRegionArea renders the area workspace for one region: a full-width map with +// this region's shape editable and its siblings drawn as read-only context. +func (s *Handlers) pageRegionArea(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 { + s.NotFound(w, r) + return + } + rid, ok := s.regionID(r) + if !ok { + s.NotFound(w, r) + return + } + regions, err := s.Store.ListRegions(r.Context(), orgID) + if err != nil { + s.ServerError(w, r, "could not load regions", err) + return + } + zv, siblings, found := splitRegionAndSiblings(regions, rid) + if !found { + s.NotFound(w, r) + return + } + s.renderRegionArea(w, r, org, zv, siblings, nil) +} + +// renderRegionArea renders the area workspace (shared by the initial GET and the +// error re-render, which keeps the submitted shape so a bad polygon isn't lost). +func (s *Handlers) renderRegionArea(w http.ResponseWriter, r *http.Request, org *store.Org, zv configRegionView, siblings []regionAreaSibling, errs []string) { + s.Render(w, r, "config_region_area.html", map[string]any{ + "Org": org, + "Nav": s.orgAdminNav(r, org), + "Region": zv, + "Siblings": siblings, + "Errors": errs, + }) +} + +// handleSaveRegionArea saves just this region's drawn area. An empty shape clears it +// back to a draft (the "Clear area" path), which is allowed — a region with no area +// simply applies nowhere until one is drawn. +func (s *Handlers) handleSaveRegionArea(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + rid, ok := s.regionID(r) + if !ok { + s.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + gj := strings.TrimSpace(r.FormValue("region_geojson")) + + var errs []string + geofence, valid := regionGeofence(gj, &errs) + if valid { + err := s.Store.UpdateRegionGeofence(r.Context(), orgID, rid, geofence) + switch { + case errors.Is(err, store.ErrNotFound): + s.NotFound(w, r) + return + case err != nil: + s.ServerError(w, r, "could not save area", err) + return + } + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin + return + } + // Re-render with the rejected shape still in hand so the admin can fix it. + org, err := s.Store.GetOrg(r.Context(), orgID) + if err != nil { + s.NotFound(w, r) + return + } + regions, err := s.Store.ListRegions(r.Context(), orgID) + if err != nil { + s.ServerError(w, r, "could not load regions", err) + return + } + zv, siblings, found := splitRegionAndSiblings(regions, rid) + if !found { + s.NotFound(w, r) + return + } + zv.GeofenceJSON = gj + s.renderRegionArea(w, r, org, zv, siblings, errs) +} + +// handleSetRootFlood toggles the org's root (*) flood policy. The root isn't a +// region row, so it saves on its own from the Configuration page. +func (s *Handlers) handleSetRootFlood(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + if err := s.Store.SetRootAllowFlood(r.Context(), orgID, r.FormValue("root_allow_flood") == "1"); err != nil { + s.ServerError(w, r, "could not save flood policy", err) + return + } + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin +} + +// regionID reads the {rid} URL param. +func (s *Handlers) regionID(r *http.Request) (int64, bool) { + rid, err := strconv.ParseInt(chi.URLParam(r, "rid"), 10, 64) + return rid, err == nil +} + +// orgAdminNav builds the org sub-navigation for the admin config screens, which are +// all admin-only members-area pages on the Configuration tab. +func (s *Handlers) orgAdminNav(r *http.Request, org *store.Org) map[string]any { + return s.OrgNavFor(r.Context(), web.OrgNavArgs{ + OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "config", + IsMember: true, IsAdmin: true, Manage: true, + }) +} + +// splitRegionAndSiblings picks the target region out of an org's regions and turns +// the rest into read-only map context. Only siblings with a drawn shape are +// included — a draft has nothing to render. +func splitRegionAndSiblings(regions []store.Region, rid int64) (configRegionView, []regionAreaSibling, bool) { + var zv configRegionView + found := false + var siblings []regionAreaSibling + for _, z := range regions { + if z.ID == rid { + zv, found = regionView(z), true + continue + } + if len(z.GeofenceJSON) > 0 { + siblings = append(siblings, regionAreaSibling{Name: z.DisplayName, GeoJSON: string(z.GeofenceJSON)}) + } + } + return zv, siblings, found +} + +// regionView converts a stored region into an editor view, carrying its geofence's +// raw GeoJSON through verbatim so the map editor round-trips arbitrary polygons. +func regionView(z store.Region) configRegionView { + return configRegionView{ + ID: z.ID, DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, + Primary: z.Primary, AllowFlood: z.AllowFlood, GeofenceJSON: string(z.GeofenceJSON), + } +} + +// validRegionToken reports whether s is a usable MeshCore region token: non-empty +// and limited to letters, digits, hyphens, and underscores. Tokens are space-joined +// into a single `region def` line, so spaces and the |/, separators aren't allowed. +func validRegionToken(s string) bool { + if s == "" { + return false + } + for _, c := range s { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': + default: + return false + } + } + return true +} + +// regionGeofence validates the GeoJSON drawn for a region. An empty shape is a +// draft (nil geofence, applies nowhere); anything else must parse as a GeoJSON +// Polygon/MultiPolygon. +func regionGeofence(gj string, errs *[]string) ([]byte, bool) { + gj = strings.TrimSpace(gj) + if gj == "" { + return nil, true // draft — no area yet + } + if _, err := geo.Parse([]byte(gj)); err != nil { + *errs = append(*errs, "That map shape isn't valid — redraw the area and try again.") + return nil, false + } + return []byte(gj), true +} diff --git a/internal/core/config_region_modal_test.go b/internal/core/config_region_modal_test.go new file mode 100644 index 0000000..ca81d14 --- /dev/null +++ b/internal/core/config_region_modal_test.go @@ -0,0 +1,364 @@ +package core + +import ( + "net/http" + "net/url" + "strconv" + "strings" + "testing" + + "github.com/jleight/meshtender/internal/geo" + "github.com/jleight/meshtender/internal/store" +) + +// TestConfigRegionEditorSurfaces: the region attribute editor serves the modal +// fragment to htmx and the standalone page to everything else (the no-JS fallback), +// a validation error re-renders the fragment in place with the entered values kept, +// and a save sends the browser back to the config page. +func TestConfigRegionEditorSurfaces(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + admin, sess := appLogin(t, ts, st, ctx, h.app, "rgnmodaladmin") + org, err := st.CreateOrg(ctx, "Region Modal Org", admin.ID) // creator becomes admin + if err != nil { + t.Fatalf("create org: %v", err) + } + + // htmx GET → just the modal fragment (no page chrome), posting to the collection. + frag := readBody(t, doHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/new", sess)) + for _, want := range []string{"modal-header", "Add region", `hx-post="/orgs/` + org.Slug + `/config/regions"`} { + if !strings.Contains(frag, want) { + t.Fatalf("new-region fragment missing %q", want) + } + } + if strings.Contains(frag, "