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
no other