From 2e03de1e0cedfabec2eaaaabf85d05c6bf587f63 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 12 Jul 2026 17:33:07 +0200 Subject: [PATCH] hub: merge the customer edit page into the Edit tab (v0.48.0 edit-a, part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings tab renamed Edit; embeds config_form_body (.ConfigForm via the builder) + Controller Update + Geo + a new Danger zone card holding the relocated Block/Unblock/Delete forms (endpoints + confirm() unchanged). All cards are SIBLINGS after — never nested in the config form. - Customer Info header loses the Edit link and Block/Delete forms; only the config-less Create Config action stays. - GET /configs/{id}/edit is a 302 to /customers/{id}#tab=edit; tabs JS gains the settings→edit legacy-hash alias. - Post-action redirects land back on their tab: update/block/unblock/ offsite-reissue/offsite-freeze/pbsdr-reissue → #tab=edit, regen-password → #tab=setup; delete unchanged (/configs). - handleConfigUpdate gains the server-side twin of the form's required fields; the error path re-renders the STANDALONE page with the SUBMITTED overrides (B3 red-proof: nil overrides → typed values reset → test FAILS; header red-proof: restored header buttons → count=2 → test FAILS; both run). - Tests: Group A (panel surface, sibling forms, header cleaned by COUNT), Group B (B1 302, B2 create unchanged, B3 typed-values, B4/B5 anchor table). Amended pins: customer_tabs_test settings→edit; pbsdr_test postUpdate now supplies the required fields + FormRendersState asserts the embedded render. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TZc5w5jDhFLv6qDC32KN5v --- hub/internal/web/configs.go | 42 ++- hub/internal/web/customer_edit_tab_test.go | 288 ++++++++++++++++++ hub/internal/web/customer_tabs_test.go | 3 +- hub/internal/web/pbsdr.go | 2 +- hub/internal/web/pbsdr_test.go | 14 +- .../web/templates/customer_unified.html | 75 +++-- 6 files changed, 384 insertions(+), 40 deletions(-) create mode 100644 hub/internal/web/customer_edit_tab_test.go diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 07022f0..ec823f1 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -333,6 +333,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c // (1 today, N for a later HA cluster). Each entry is the hostDetailData view-model map // the shared host_detail_body sub-template renders. Hosts []map[string]interface{} + + // ConfigForm (v0.48.0 edit-a): the embedded config form's view model for the Edit tab — + // the same configFormData the standalone chrome renders. Zero-valued (and never rendered) + // when the customer has no config. + ConfigForm configFormView } pendingSet := make(map[string]bool, len(pendingTails)) @@ -415,6 +420,13 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c Hosts: hostViews, } + // Edit tab (v0.48.0 edit-a): embed the config form. nil overrides → the builder parses the + // STORED ConfigJSON (the read path; submitted-value preservation is the standalone error + // re-render's job). + if cfg != nil { + data.ConfigForm = s.configFormData(r, false, cfg, nil, "") + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil { s.logger.Printf("[ERROR] Template render: %v", err) @@ -538,15 +550,16 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/customers/"+customerID+"?flash=created", http.StatusSeeOther) } -// handleConfigEditForm shows the edit form for a customer config. +// handleConfigEditForm — the standalone edit page merged into the customer page's Edit tab +// (v0.48.0 edit-a); old links and bookmarks land on the tab. POST /configs/{id}/edit stays the +// real mutation endpoint (the embedded form posts to it). func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } - - s.renderConfigForm(w, r, false, cfg, nil, "") + http.Redirect(w, r, "/customers/"+customerID+"#tab=edit", http.StatusFound) } // handleConfigUpdate processes the edit form submission. @@ -565,6 +578,17 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name")) cfg.Domain = strings.TrimSpace(r.FormValue("domain")) cfg.Email = strings.TrimSpace(r.FormValue("email")) + + // Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is + // the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is + // lost; runs BEFORE provisioning so an invalid submit never touches Hetzner/ep0. + if cfg.CustomerName == "" || cfg.Domain == "" { + var submitted map[string]interface{} + _ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted) + s.renderConfigForm(w, r, false, cfg, submitted, "Display Name and Domain are required.") + return + } + cfg.ConfigJSON = buildConfigJSON(r) if err := s.applyOffsite(r.Context(), r, cfg); err != nil { @@ -588,7 +612,7 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust } s.logger.Printf("[INFO] Customer config updated: %s", customerID) - http.Redirect(w, r, "/customers/"+customerID+"?flash=updated", http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+customerID+"?flash=updated#tab=edit", http.StatusSeeOther) } // handleOffsiteReissue (F4) resets the customer's offsite credential and stores a fresh one-time password — @@ -632,7 +656,7 @@ func (s *Server) handleOffsiteReissue(w http.ResponseWriter, r *http.Request, cu return } s.logger.Printf("[INFO] offsite credentials re-issued for %s (fresh one-time password stored; ConfigVersion bumped)", customerID) - http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued", http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued#tab=edit", http.StatusSeeOther) } // handleOffsiteFreeze (SLICE 4) freezes/unfreezes the customer's shared sub-account (readonly) — an @@ -672,7 +696,7 @@ func (s *Server) handleOffsiteFreeze(w http.ResponseWriter, r *http.Request, cus if !frozen { flash = "offsite_unfrozen" } - http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash, http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash+"#tab=edit", http.StatusSeeOther) } // handleConfigDelete deletes a customer config. @@ -726,7 +750,7 @@ func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Reques } s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID) - http.Redirect(w, r, "/customers/"+customerID+"?flash=password_regenerated", http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+customerID+"?flash=password_regenerated#tab=setup", http.StatusSeeOther) } // handleBlockCustomer sets a customer's status to "blocked". @@ -742,7 +766,7 @@ func (s *Server) handleBlockCustomer(w http.ResponseWriter, r *http.Request, cus return } s.logger.Printf("[INFO] Customer blocked: %s", customerID) - http.Redirect(w, r, "/customers/"+customerID+"?flash=blocked", http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+customerID+"?flash=blocked#tab=edit", http.StatusSeeOther) } // handleUnblockCustomer sets a customer's status back to "active". @@ -758,7 +782,7 @@ func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, c return } s.logger.Printf("[INFO] Customer unblocked: %s", customerID) - http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked", http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked#tab=edit", http.StatusSeeOther) } // countBoxesBelowFloor counts reporting boxes whose EFFECTIVE floor (per-customer override else the diff --git a/hub/internal/web/customer_edit_tab_test.go b/hub/internal/web/customer_edit_tab_test.go new file mode 100644 index 0000000..e3416ce --- /dev/null +++ b/hub/internal/web/customer_edit_tab_test.go @@ -0,0 +1,288 @@ +package web + +// v0.48.0 edit-a — the standalone customer edit page merged into the customer page's Edit tab +// (config_form_body embedded; Block/Delete relocated to a danger zone) plus the redirect-anchor +// contract of the surrounding POST actions. Groups A + B of the task's test plan. + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi" + "gitea.dooplex.hu/admin/felhom-hub/internal/offsite" + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// editPanel slices the rendered customer page down to the Edit tab's panel (from its opening div +// to the next panel) so containment assertions are panel-scoped, not page-scoped. +func editPanel(t *testing.T, html string) string { + t.Helper() + start := strings.Index(html, `tab-panel" data-tab="edit"`) + if start < 0 { + t.Fatal("Edit tab panel missing") + } + end := strings.Index(html[start:], `tab-panel" data-tab="backup"`) + if end < 0 { + t.Fatal("Backup panel (the Edit panel's terminator) missing") + } + return html[start : start+end] +} + +// Group A — the Edit tab renders the full mutation surface and the header is cleaned. +func TestCustomerEditTab_RendersMutationSurface(t *testing.T) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu", Email: "a@acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", + ConfigJSON: `{"infrastructure":{"cf_tunnel_token":"stored-tunnel-token"}}`, + }); err != nil { + t.Fatal(err) + } + if err := st.SaveReport("c1", []byte(tabsTestReportJSON)); err != nil { + t.Fatal(err) + } + + html := renderCustomerPage(t, s, "c1") + panel := editPanel(t, html) + + // The panel carries the config form + the three sibling cards. + for _, want := range []string{ + `action="/configs/c1/edit"`, // the embedded config form + "Controller Update", + "Geo-korlátozás", + "Danger zone", + `action="/customers/c1/block"`, + `action="/configs/c1/delete"`, + `name="_csrf"`, // CSRF field inside the form + } { + if !strings.Contains(panel, want) { + t.Errorf("Edit panel missing %q", want) + } + } + // Stored override values populate the embedded form (values come from Config/ConfigJSON). + if !strings.Contains(panel, `value="stored-tunnel-token"`) { + t.Error("embedded form does not render the stored cf_tunnel_token override") + } + // The named trap: the floor/geo/danger forms must be SIBLINGS after , never nested + // inside the config
(invalid HTML; breaks the formaction sub-buttons). Between the + // config form's opening tag and its first
no other
") + if formEnd < 0 { + t.Fatal("config form never closes") + } + if inner := panel[formStart : formStart+formEnd]; strings.Contains(inner, " — floor/geo/danger must be siblings") + } + + // Header cleaned: the Edit link is gone, and Block/Delete exist EXACTLY once on the whole + // page (in the Edit panel). RED-PROOF: restoring the old header buttons makes the counts 2 + // and this FAILS — a naive Contains would not catch the duplicate danger zone. + if strings.Contains(html, `href="/configs/c1/edit"`) { + t.Error("header still links to the retired standalone edit page") + } + if got := strings.Count(html, `action="/customers/c1/block"`); got != 1 { + t.Errorf("Block form count = %d, want exactly 1 (in the danger zone)", got) + } + if got := strings.Count(html, `action="/configs/c1/delete"`); got != 1 { + t.Errorf("Delete form count = %d, want exactly 1 (in the danger zone)", got) + } +} + +// A blocked customer's danger zone offers Unblock instead of Block (the old header conditional). +func TestCustomerEditTab_BlockedShowsUnblock(t *testing.T) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "cb", CustomerName: "Blocked", Domain: "b.hu", + RetrievalPassword: "pw", APIKey: "k", + }); err != nil { + t.Fatal(err) + } + // SaveCustomerConfig does not persist Status — blocking is its own state transition. + if err := st.SetCustomerConfigStatus("cb", "blocked"); err != nil { + t.Fatal(err) + } + html := renderCustomerPage(t, s, "cb") + panel := editPanel(t, html) + if !strings.Contains(panel, `action="/customers/cb/unblock"`) { + t.Error("blocked customer's danger zone missing the Unblock form") + } + if strings.Contains(html, `action="/customers/cb/block"`) { + t.Error("blocked customer must not offer Block") + } +} + +// Group B1 — the standalone GET edit page 302s to the Edit tab. +func TestConfigEditForm_RedirectsToEditTab(t *testing.T) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", + }); err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + s.handleConfigEditForm(rr, httptest.NewRequest("GET", "/configs/c1/edit", nil), "c1") + if rr.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", rr.Code) + } + if loc := rr.Header().Get("Location"); loc != "/customers/c1#tab=edit" { + t.Errorf("Location = %q, want /customers/c1#tab=edit", loc) + } + // Unknown customer still 404s (no open redirect for garbage ids). + rr = httptest.NewRecorder() + s.handleConfigEditForm(rr, httptest.NewRequest("GET", "/configs/nope/edit", nil), "nope") + if rr.Code != http.StatusNotFound { + t.Errorf("unknown customer edit = %d, want 404", rr.Code) + } +} + +// Group B2 — the create flow (IsNew) still renders standalone chrome around the SAME shared +// body sub-template, and the create round-trip is unchanged. +func TestConfigNewForm_StandaloneUnchanged(t *testing.T) { + s, st := newTestServer(t) + rr := httptest.NewRecorder() + s.handleConfigNewForm(rr, httptest.NewRequest("GET", "/configs/new", nil)) + if rr.Code != 200 { + t.Fatalf("new form status = %d", rr.Code) + } + html := rr.Body.String() + for _, want := range []string{"Add Customer", `action="/configs/new"`, `id="customer_id"`, "Create Configuration"} { + if !strings.Contains(html, want) { + t.Errorf("new form missing %q", want) + } + } + + form := url.Values{"customer_id": {"newcust"}, "customer_name": {"New"}, "domain": {"new.hu"}} + req := httptest.NewRequest("POST", "/configs/new", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = httptest.NewRecorder() + s.handleConfigCreate(rr, req) + if rr.Code != http.StatusSeeOther { + t.Fatalf("create = %d, want 303: %s", rr.Code, rr.Body.String()) + } + if loc := rr.Header().Get("Location"); loc != "/customers/newcust?flash=created" { + t.Errorf("create Location = %q", loc) + } + if cfg, _ := st.GetCustomerConfig("newcust"); cfg == nil { + t.Error("created config not stored") + } +} + +// Group B3 — THE load-bearing correctness test: a validation error re-renders the STANDALONE +// form with the SUBMITTED values preserved (overrides pass-through), never the stored ones. +// RED-PROOF (run once during v0.48.0): passing nil overrides in handleConfigUpdate's error path +// makes the builder fall back to the STORED ConfigJSON → "typed-tunnel-token" disappears and +// "stored-tunnel-token" renders → this test FAILS. +func TestConfigUpdate_ValidationErrorPreservesTypedValues(t *testing.T) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", + ConfigJSON: `{"infrastructure":{"cf_tunnel_token":"stored-tunnel-token"}}`, + }); err != nil { + t.Fatal(err) + } + + // Invalid: customer_name cleared; the operator also typed a NEW tunnel token. + rr := httptest.NewRecorder() + s.handleConfigUpdate(rr, postForm("/configs/c1/edit", + "customer_name=&domain=acme.hu&cf_tunnel_token=typed-tunnel-token"), "c1") + if rr.Code != 200 { + t.Fatalf("validation error = %d, want 200 (standalone re-render)", rr.Code) + } + html := rr.Body.String() + if !strings.Contains(html, "Display Name and Domain are required.") { + t.Error("error message missing from the re-render") + } + if !strings.Contains(html, `value="typed-tunnel-token"`) { + t.Error("SUBMITTED value lost on the error re-render (values reset to stored)") + } + if strings.Contains(html, "stored-tunnel-token") { + t.Error("stored value rendered instead of the submitted one") + } + // Nothing was saved. + cfg, _ := st.GetCustomerConfig("c1") + if cfg.CustomerName != "Acme" || !strings.Contains(cfg.ConfigJSON, "stored-tunnel-token") { + t.Error("invalid submit mutated the stored config") + } +} + +// Group B4/B5 — redirect anchors, table-driven over the handlers whose buttons live on the +// customer page: post-action lands back on the tab the action came from. +func TestCustomerActions_RedirectAnchors(t *testing.T) { + newProvisioned := func(t *testing.T) (*Server, *store.Store) { + s, st := newTestServer(t) + s.SetOffsiteProvisioner(&offsite.Provisioner{ + API: hetznerapi.NewFake(), Store: st, Scanner: webTestScanner{}, + PoolBoxID: 611714, Location: "fsn1", Logger: log.New(io.Discard, "", 0), + }) + cfg := &store.CustomerConfig{ + CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", ConfigJSON: "{}", + } + // Provision the shared offsite tier through the real applyOffsite leg so reissue/freeze + // find exactly one labelled sub-account. + r := postForm("/configs/c1/edit", "offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50") + if err := s.applyOffsite(r.Context(), r, cfg); err != nil { + t.Fatalf("provision: %v", err) + } + if err := st.SaveCustomerConfig(cfg); err != nil { + t.Fatal(err) + } + return s, st + } + + cases := []struct { + name string + invoke func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) + blocked bool // seed Status=blocked (for unblock) + wantLoc string + }{ + {"update", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) { + s.handleConfigUpdate(rr, postForm("/configs/c1/edit", "customer_name=Acme&domain=acme.hu"), "c1") + }, false, "/customers/c1?flash=updated#tab=edit"}, + {"block", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) { + s.handleBlockCustomer(rr, postForm("/customers/c1/block", ""), "c1") + }, false, "/customers/c1?flash=blocked#tab=edit"}, + {"unblock", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) { + s.handleUnblockCustomer(rr, postForm("/customers/c1/unblock", ""), "c1") + }, true, "/customers/c1?flash=unblocked#tab=edit"}, + {"offsite-reissue", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) { + s.handleOffsiteReissue(rr, postForm("/configs/c1/offsite-reissue", ""), "c1") + }, false, "/customers/c1?flash=offsite_reissued#tab=edit"}, + {"offsite-freeze", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) { + s.handleOffsiteFreeze(rr, postForm("/configs/c1/offsite-freeze", ""), "c1", true) + }, false, "/customers/c1?flash=offsite_frozen#tab=edit"}, + {"regen-password lands on Setup (its card lives there)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) { + s.handleConfigRegenPassword(rr, postForm("/configs/c1/regen-password", ""), "c1") + }, false, "/customers/c1?flash=password_regenerated#tab=setup"}, + {"delete stays anchor-free (leaves the page)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) { + s.handleConfigDelete(rr, postForm("/configs/c1/delete", ""), "c1") + }, false, "/configs?flash=deleted"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, st := newProvisioned(t) + if tc.blocked { + if err := st.SetCustomerConfigStatus("c1", "blocked"); err != nil { + t.Fatal(err) + } + } + rr := httptest.NewRecorder() + tc.invoke(t, s, rr) + if rr.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303: %s", rr.Code, rr.Body.String()) + } + if loc := rr.Header().Get("Location"); loc != tc.wantLoc { + t.Errorf("Location = %q, want %q", loc, tc.wantLoc) + } + }) + } +} diff --git a/hub/internal/web/customer_tabs_test.go b/hub/internal/web/customer_tabs_test.go index a742c43..5ab32fe 100644 --- a/hub/internal/web/customer_tabs_test.go +++ b/hub/internal/web/customer_tabs_test.go @@ -51,8 +51,9 @@ func TestTemplates_CustomerTabs(t *testing.T) { html := renderCustomerPage(t, s, "acme") // The tab nav renders all 8 tabs (hash links — plain anchors without JS). + // v0.48.0 edit-a: "settings" became "edit" (the standalone config form merged in). for _, tab := range []string{ - "overview", "applications", "setup", "settings", "backup", "events", "notifications", "host", + "overview", "applications", "setup", "edit", "backup", "events", "notifications", "host", } { if !strings.Contains(html, `href="#tab=`+tab+`"`) { t.Errorf("tab nav missing tab %q", tab) diff --git a/hub/internal/web/pbsdr.go b/hub/internal/web/pbsdr.go index 4a8a3c7..075c411 100644 --- a/hub/internal/web/pbsdr.go +++ b/hub/internal/web/pbsdr.go @@ -266,7 +266,7 @@ func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, cust return } s.logger.Printf("[INFO] pbsdr credentials re-issued for %s (host %s; fresh consume-once secret stored)", customerID, host.HostID) - http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued", http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued#tab=edit", http.StatusSeeOther) } // pbsDRView is the config form's render model for the PBS DR section. diff --git a/hub/internal/web/pbsdr_test.go b/hub/internal/web/pbsdr_test.go index 2b4b0ba..546a012 100644 --- a/hub/internal/web/pbsdr_test.go +++ b/hub/internal/web/pbsdr_test.go @@ -90,6 +90,14 @@ func newPBSDRServer(t *testing.T, fake *fakeTenancy) (*Server, *store.Store, *by // postUpdate drives the REAL handler pipeline (handleConfigUpdate → applyPBSDR → save). func postUpdate(t *testing.T, s *Server, form url.Values) *httptest.ResponseRecorder { t.Helper() + // v0.48.0 edit-a: the update handler now enforces the form's required fields server-side — + // supply them like every real submission does (the browser form marks both `required`). + if form.Get("customer_name") == "" { + form.Set("customer_name", "Peti") + } + if form.Get("domain") == "" { + form.Set("domain", "peti.example") + } req := httptest.NewRequest("POST", "/configs/peti", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() @@ -347,9 +355,11 @@ func TestPBSDR_FormRendersState(t *testing.T) { s, _, _ := newPBSDRServer(t, fake) postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}}) - req := httptest.NewRequest("GET", "/configs/peti/edit", nil) + // v0.48.0 edit-a: the standalone GET edit page is a redirect now — the form renders embedded + // in the customer page's Edit tab (the same config_form_body sub-template), so assert there. + req := httptest.NewRequest("GET", "/customers/peti", nil) rr := httptest.NewRecorder() - s.handleConfigEditForm(rr, req, "peti") + s.handleCustomerUnified(rr, req, "peti") out := rr.Body.String() if !strings.Contains(out, `name="pbsdr_enabled" checked`) { t.Error("enabled checkbox not checked after provisioning") diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index eb77fcc..16924b3 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -89,7 +89,7 @@ Overview Applications Setup - Settings + Edit Backup & DR Events{{with mapGet .EventCounts "error"}}{{.}}{{end}} Notifications @@ -104,26 +104,9 @@

Customer Info

- {{if .HasConfig}} - Edit - {{if .IsBlocked}} - - {{.CSRFField}} - - - {{else}} -
- {{.CSRFField}} - -
- {{end}} -
- {{.CSRFField}} - -
- {{else}} + {{/* v0.48.0 edit-a: Edit/Block/Delete moved to the Edit tab (form + danger + zone); only the config-less bootstrap action stays in the header. */}} + {{if not .HasConfig}}
{{.CSRFField}} @@ -539,8 +522,17 @@
- -
+ +
+ + {{if .HasConfig}} + {{template "config_form_body" .ConfigForm}} + {{else}} +

No managed config yet — create one with the Create Config button above.

+ {{end}} {{if .HasReports}} @@ -596,9 +588,8 @@

Controller updates are agent-driven (the version floor above) and config is delivered by the - box pulling it on a config change — the hub never connects into the box. Edit the config via - the Edit button (Overview tab); the controller re-pulls and restarts on its - next report. + box pulling it on a config change — the hub never connects into the box. Edit the config in + the form above; the controller re-pulls and restarts on its next report.

@@ -673,6 +664,34 @@

Controller update and geo-restriction settings appear once the first report arrives.

{{end}} + {{if .HasConfig}} + +
+

Danger zone

+

Blocking hides the customer from the Dashboard (reports are still accepted); deleting removes the managed configuration permanently.

+
+ {{if .IsBlocked}} +
+ {{.CSRFField}} + +
+ {{else}} +
+ {{.CSRFField}} + +
+ {{end}} +
+ {{.CSRFField}} + +
+
+
+ {{end}} +
@@ -1049,7 +1068,9 @@ panels.forEach(function(p) { known[p.getAttribute('data-tab')] = true; }); function currentTab() { var m = (location.hash || '').match(/^#tab=([a-z-]+)$/); - return (m && known[m[1]]) ? m[1] : 'overview'; + var t = m ? m[1] : ''; + if (t === 'settings') t = 'edit'; // legacy alias: the Settings tab became Edit (v0.48.0) + return known[t] ? t : 'overview'; } function activate() { var tab = currentTab();