hub: merge the customer edit page into the Edit tab (v0.48.0 edit-a, part 2)
- 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 </form> — 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZc5w5jDhFLv6qDC32KN5v
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 </form>, never nested
|
||||
// inside the config <form> (invalid HTML; breaks the formaction sub-buttons). Between the
|
||||
// config form's opening tag and its first </form> no other <form may open.
|
||||
formStart := strings.Index(panel, `action="/configs/c1/edit"`)
|
||||
formEnd := strings.Index(panel[formStart:], "</form>")
|
||||
if formEnd < 0 {
|
||||
t.Fatal("config form never closes")
|
||||
}
|
||||
if inner := panel[formStart : formStart+formEnd]; strings.Contains(inner, "<form") {
|
||||
t.Error("a form is NESTED inside the config <form> — 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<a href="#tab=overview" data-tab="overview" class="active">Overview</a>
|
||||
<a href="#tab=applications" data-tab="applications">Applications</a>
|
||||
<a href="#tab=setup" data-tab="setup">Setup</a>
|
||||
<a href="#tab=settings" data-tab="settings">Settings</a>
|
||||
<a href="#tab=edit" data-tab="edit">Edit</a>
|
||||
<a href="#tab=backup" data-tab="backup">Backup & DR</a>
|
||||
<a href="#tab=events" data-tab="events">Events{{with mapGet .EventCounts "error"}}<span class="tab-badge">{{.}}</span>{{end}}</a>
|
||||
<a href="#tab=notifications" data-tab="notifications">Notifications</a>
|
||||
@@ -104,26 +104,9 @@
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<h2>Customer Info</h2>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
{{if .HasConfig}}
|
||||
<a href="/configs/{{.CustomerID}}/edit" class="btn btn-outline btn-sm">Edit</a>
|
||||
{{if .IsBlocked}}
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-sm">Unblock</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
|
||||
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-outline btn-sm">Block</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
{{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}}
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/create-config" style="display:inline">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-sm">Create Config</button>
|
||||
@@ -539,8 +522,17 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══ Settings ═══ -->
|
||||
<div class="tab-panel" data-tab="settings">
|
||||
<!-- ═══ Edit ═══ (v0.48.0 edit-a: the standalone config form embedded via the shared
|
||||
config_form_body sub-template, plus the relocated Block/Delete danger zone. The
|
||||
Controller Update / Geo / Danger zone cards are SIBLINGS after </form> — nesting
|
||||
them inside the config <form> is invalid HTML and breaks the formaction sub-buttons. -->
|
||||
<div class="tab-panel" data-tab="edit">
|
||||
|
||||
{{if .HasConfig}}
|
||||
{{template "config_form_body" .ConfigForm}}
|
||||
{{else}}
|
||||
<section class="card"><p class="text-muted">No managed config yet — create one with the Create Config button above.</p></section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasReports}}
|
||||
<!-- Controller Update -->
|
||||
@@ -596,9 +588,8 @@
|
||||
</form>
|
||||
<p class="text-muted" style="margin-top: 0.75em; font-size: 0.8em;">
|
||||
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 <strong>Edit</strong> 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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -673,6 +664,34 @@
|
||||
<section class="card"><p class="text-muted">Controller update and geo-restriction settings appear once the first report arrives.</p></section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasConfig}}
|
||||
<!-- Danger zone (v0.48.0 edit-a): the Block/Delete forms relocated verbatim from the
|
||||
Customer Info header — endpoints and confirm() handlers unchanged. -->
|
||||
<section class="card">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="text-muted">Blocking hides the customer from the Dashboard (reports are still accepted); deleting removes the managed configuration permanently.</p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
|
||||
{{if .IsBlocked}}
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-sm">Unblock</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
|
||||
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-outline btn-sm">Block</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══ Backup & DR ═══ -->
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user