// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "fmt" "log/slog" "net/http" "strings" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/forms" "git.coopcloud.tech/wiki-cafe/member-console/internal/integration" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" "github.com/spf13/viper" ) // The integration settings surface: one handler/template pair renders every // installed integration's declared configuration (ConfigSpec) and manages // non-secret override rows in core.integration_config_overrides. Overrides // apply on restart (see internal/config.ApplyOverlay); the page derives its // "pending restart" indicator by comparing stored overrides against the // boot-effective snapshot. // SettingRow is one declared configuration key, prepared for rendering. type SettingRow struct { Key string // IntegrationKey is the integration that declared this key: the Source // cell's Clear button builds its route from it, and the route is the // only place the integration is named (finding FA-10). IntegrationKey string Usage string Secret bool Enum []string Kind string // input shape: "enum" | "bool" | "list" | "string" // Placeholder is a text control's format example (design D4, // typed-config-keys): the key's declared type's example // (https://host.example, 1h30m, 10, a, b), or empty for a bare string, // which has no fixed format to show. Placeholder string Required bool // Effective is the boot-effective value (what the running process // uses) in display form; Source is the layer it came from. Effective string Source string // Override is the stored override value ("" when none); a stored // override that has not taken effect yet (or a cleared one that is // still in effect) sets PendingRestart. Override string HasOverride bool PendingRestart bool // SecretSet reports whether a Secret key currently resolves to a // non-blank value (environment or "-file"), without ever exposing that // value — the settings page distinguishes "set (masked)" from "not // set" instead of showing the same "•••" treatment for both (ux- // honest-surfaces integration-settings spec, "Secret settings // distinguish set from unset"). Meaningless when Secret is false. SecretSet bool // Warning is optional consequence copy rendered adjacent to the // control, before submission, for a setting whose value change has a // real-world effect beyond this deployment's own state. No installed // integration declares such a key today (the Stripe mode, which used // to, is derived from the API key and is no longer a setting). Empty // for every routine key. Warning string } // IntegrationSettingsData is the settings page body. type IntegrationSettingsData struct { Key string DisplayName string SurfacePath string // admin page to cross-link ("" when none) // Form is the settings declaration bound to the values in force, or to // a refused submission of them (form-library, spec integration-settings). // It carries every declared key, secret ones included as static rows, // so the page is one table with one Save (design D21). Form forms.FormView } // integrationConfigByKey returns the declared config for one installed // integration, or false when the key is unknown or declares no config keys. func (h *OperatorPartialsHandler) integrationConfigByKey(integrationKey string) (IntegrationConfigInfo, bool) { for _, info := range h.IntegrationConfigs { if info.Key == integrationKey && len(info.Keys) > 0 { return info, true } } return IntegrationConfigInfo{}, false } // configurationReadiness reports whether the named provider's declared required // configuration is fully resolved, and names any unresolved keys. // // Both the Integrations list (GetIntegrationsPage) and the landing // surface's System region (loadOverviewProviders) call this — the SAME // required-key resolution the settings page itself performs // (config.RequiredKeysUnresolved) — so "not configured" means the same // thing everywhere it renders (ux-honest-surfaces design decision 2). A // provider absent from configs, or with no required-together group at all, // is vacuously configured: there is nothing an operator could set. func configurationReadiness(configs []IntegrationConfigInfo, providerKey string) (configured bool, missing []string) { for _, info := range configs { if info.Key == providerKey { missing = config.RequiredKeysUnresolved(info.Keys) return len(missing) == 0, missing } } return true, nil } // settingKind maps a declaration to its input shape. A url, duration or int // key maps to "string" like a bare string does: all four render as a text // control, and settingsPlaceholder is what tells them apart on the page // (typed-config-keys design D1, D4). func settingKind(key config.ConfigKey) string { switch key.TypeOf() { case config.TypeEnum: return "enum" case config.TypeList: return "list" case config.TypeBool: return "bool" default: // string, url, duration, int return "string" } } // settingsPlaceholder is a text control's format example, the type's // example (design D4): shown only once the control is empty, and nothing // else, since the placeholder is the format, not a hint. A bare string has // no fixed format, so it carries none. func settingsPlaceholder(t config.Type) string { switch t { case config.TypeURL: return "https://host.example" case config.TypeDuration: return "1h30m" case config.TypeInt: return "10" case config.TypeList: return "a, b" default: return "" } } // normalizedOverride renders a stored override in the same display form as // the boot snapshot, so pending-restart comparison is encoding-insensitive // for list keys (" a, b " vs "a,b"). func normalizedOverride(key config.ConfigKey, raw string) string { if _, ok := key.Default.([]string); ok { return strings.Join(config.SplitList(raw), ",") } return raw } // loadSettingRows builds one row per declared key (secret included), the // boot-effective value, its winning source, and any stored override. errMsg // is set when the overrides list itself could not be loaded; the declared // rows still render (design D9's siblings: a load failure never wipes the // rest of the surface). func (h *OperatorPartialsHandler) loadSettingRows(r *http.Request, info IntegrationConfigInfo) ([]SettingRow, string) { overrides := map[string]string{} stored, err := integration.New(h.Database).ListConfigOverrides(r.Context()) errMsg := "" if err != nil { h.Logger.Error("failed to list config overrides", slog.Any("error", err)) errMsg = "Failed to load stored overrides; showing declared configuration only." } else { for _, row := range stored { overrides[row.Key] = row.Value } } boot := config.BootEffective() var rows []SettingRow for _, key := range info.Keys { row := SettingRow{ Key: key.Name, IntegrationKey: info.Key, Usage: key.Usage, Secret: key.Secret, Enum: key.Enum, Kind: settingKind(key), Placeholder: settingsPlaceholder(key.TypeOf()), Required: key.RequiredGroup != "", } if key.Secret { // Presence only, never the value: viper still resolves secrets // (cmd/start.go binds their "-file" companion the same as any // other key), they just never enter the boot-effective snapshot // or get echoed back. This is the same resolution // config.RequiredKeysUnresolved reads for the readiness signal // shown on the Integrations list and overview, so "set" here // always agrees with "configured" there. row.SecretSet = strings.TrimSpace(viper.GetString(key.Name)) != "" } else { eff := boot[key.Name] row.Effective = eff.Value row.Source = string(eff.Source) if val, has := overrides[key.Name]; has { row.Override = val row.HasOverride = true row.PendingRestart = eff.Source != config.SourceOverride || eff.Value != normalizedOverride(key, val) } else { row.PendingRestart = eff.Source == config.SourceOverride } } rows = append(rows, row) } return rows, errMsg } // GetIntegrationSettingsPage handles GET /operator/integrations/{integrationKey}/settings. func (h *OperatorPartialsHandler) GetIntegrationSettingsPage(w http.ResponseWriter, r *http.Request) { info, ok := h.integrationConfigByKey(r.PathValue("integrationKey")) if !ok { http.NotFound(w, r) return } rows, errMsg := h.loadSettingRows(r, info) spec := settingsFormFor(rows) // The registered spec's Path still carries the route's own // {integrationKey} placeholder (it is the one declaration every // integration's settings page shares); resolve it to this request's // integration so the rendered hx-post targets a real route. action, err := web.RouteURL(spec.Path, info.Key) if err != nil { h.Logger.Error("failed to resolve settings action", slog.Any("error", err)) } // errMsg is a load failure, not a submission refusal, but the form's // own form-level slot is where it lands either way (design D9's // siblings): the declared rows still render below it, unaffected. errs := forms.NewErrors() if errMsg != "" { errs.Form(errMsg) } bodyData := IntegrationSettingsData{ Key: info.Key, DisplayName: info.DisplayName, SurfacePath: info.SurfacePath, Form: forms.Render(spec, forms.Binding{ Mode: forms.ModeRecord, Action: action, Values: settingsValues(rows), Cells: settingsCells(h.Templates, rows), ControlFooters: settingsControlFooters(h.Templates, rows), Errors: errs, }), } page := h.buildOperatorPageData(r) page.IAPosition = "integration:integrations:" + info.Key page.ActiveCapability = "integrations" switch r.URL.Query().Get("flash") { case "saved": page.FlashSuccess = "Settings saved." case "cleared": page.FlashSuccess = "Override cleared." } page.BodyTemplate = "operator_integration_settings.html" page.BodyData = bodyData h.Templates.Render(w, "operator.html", page) } // renderIntegrationSettingsRefusal re-renders the settings body at 422 with // the declaration in submission mode: every submitted value carried back, // each field's error under its control, and a refusal that belongs to no // field in the form-level slot the part always renders (design D9). The // body alone is the response, because that is what #operator-body swaps in // on the successful path too. func (h *OperatorPartialsHandler) renderIntegrationSettingsRefusal(w http.ResponseWriter, r *http.Request, info IntegrationConfigInfo, rows []SettingRow, spec forms.FormSpec, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) action, err := web.RouteURL(spec.Path, info.Key) if err != nil { h.Logger.Error("failed to resolve settings action", slog.Any("error", err)) } h.Templates.Render(w, "operator_integration_settings.html", IntegrationSettingsData{ Key: info.Key, DisplayName: info.DisplayName, SurfacePath: info.SurfacePath, Form: forms.Render(spec, forms.Binding{ Mode: forms.ModeSubmission, Action: action, Values: values, Cells: settingsCells(h.Templates, rows), ControlFooters: settingsControlFooters(h.Templates, rows), Errors: errs, }), }) } // PostIntegrationSetting handles POST /operator/integrations/{integrationKey}/settings. // It reads the body through the settings declaration and never through // r.FormValue: every non-secret key is its own field, so the declaration is // the write allowlist and no key name travels as a value inside the body // (finding FA-10, "the route names the integration"). // // Every control on this page sets a value and does nothing else (design D21 // round 5). A key that came back with the value its control was rendered // with is no change; a changed one is validated the same way the boot // overlay validates it and, on success, upserted; an emptied text or list // control is refused, because emptying a control is not how an override is // removed (that is the row's own Clear, // DeleteIntegrationSettingOverride). Every change is validated before // anything is written, so a refusal never applies part of the batch // (design D9). func (h *OperatorPartialsHandler) PostIntegrationSetting(w http.ResponseWriter, r *http.Request) { info, ok := h.integrationConfigByKey(r.PathValue("integrationKey")) if !ok { http.NotFound(w, r) return } rows, errMsg := h.loadSettingRows(r, info) spec := settingsFormFor(rows) values, errs := spec.Parse(r) if errMsg != "" { errs.Form(errMsg) } type change struct { key string value string } var changes []change if !errs.Any() { for _, row := range rows { if row.Secret { continue } submitted := settingsSubmittedValue(values, row) switch { case submitted == settingsBoundValue(row): // The control was rendered with the value in force (the // override if one is stored, the effective value // otherwise), and it came back unchanged. Every row // arrives on every save, so this is the common case: // without it, one Save would write an override for every // key on the page (design D21's no-change rule). A key with // nothing in force is bound to an empty control, so leaving // it empty lands here too and writes nothing. case submitted == "": // A text or list control emptied against a value that was // there. Refused rather than read as a request to remove // the override: the control sets a value, and a stored // override is removed by the row's Clear (design D21 round // 5). errs.Field(row.Key, settingsEmptyRefusal(row)) default: keySpec, found := findConfigKey(info.Keys, row.Key) if !found { continue } // Same validation the boot overlay applies: a value that // saves is a value that boots. if _, err := config.CoerceOverride(keySpec, submitted); err != nil { errs.Field(row.Key, err.Error()) continue } changes = append(changes, change{key: row.Key, value: submitted}) } } } if errs.Any() { h.renderIntegrationSettingsRefusal(w, r, info, rows, spec, values, errs) return } svc := integration.NewSettingsService(h.Database) updatedBy := "" if page := h.buildOperatorPageData(r); page.Email != "" { updatedBy = page.Email } writeFailed := false for _, c := range changes { // Every change here already passed the same config.CoerceOverride // check Set repeats (the validation loop above needs its per-field // errors before anything is written, design D9's all-or-nothing // rule), so Set's own refusals are unreachable in practice; it is // still the one seam that writes the row (design D11). if err := svc.Set(r.Context(), info.Keys, c.key, c.value, updatedBy); err != nil { h.Logger.Error("failed to save config override", slog.String("key", c.key), slog.Any("error", err)) writeFailed = true } } if writeFailed { errs.Form("Failed to save the settings. Details are in the server logs.") h.renderIntegrationSettingsRefusal(w, r, info, rows, spec, values, errs) return } // The settings kind's success navigation, through the one helper every // navigating success uses (design D9 as corrected in round 4): an htmx // submit gets HX-Redirect and an empty 200, a native submit gets the // 303. A bare 303 to an htmx submit is followed and swapped whole into // the form's target, which is the panel-inside-panel the Stripe // settings capture showed (maintainer, 2026-09-04). redirectToRecord(w, r, "/operator/integrations/"+info.Key+"/settings?flash=saved") } // DeleteIntegrationSettingOverride handles // DELETE /operator/integrations/{integrationKey}/settings/{key}: the Clear // button an overridden row carries in its Source cell removes that key's // stored override, and the page falls back to the environment or the // declared default on the next restart. // // It is an action trigger, not a form (design D7, D21 round 5): there is // nothing to fill in, so a form around it would be a form with no fields. // It is a Remove and not a Delete, so no confirm modal guards it: the same // override can be set again from the same row. It mirrors // DeleteOrphanOverride's shape, on the route that already names the // integration (finding FA-10), and answers 404 when there is nothing at // that address: an integration this console does not run, a key that // integration does not declare, or a key with no stored override. func (h *OperatorPartialsHandler) DeleteIntegrationSettingOverride(w http.ResponseWriter, r *http.Request) { info, ok := h.integrationConfigByKey(r.PathValue("integrationKey")) if !ok { http.NotFound(w, r) return } keyName := r.PathValue("key") // The declaration is the allowlist here exactly as it is on the save // path: a key this integration does not declare is not this route's to // remove, and a secret key never has an override row to begin with. keySpec, found := findConfigKey(info.Keys, keyName) if !found || keySpec.Secret { http.NotFound(w, r) return } found, err := integration.NewSettingsService(h.Database).Clear(r.Context(), keyName) if err != nil { h.Logger.Error("failed to clear config override", slog.String("key", keyName), slog.Any("error", err)) http.Error(w, "failed to clear the override", http.StatusInternalServerError) return } if !found { http.NotFound(w, r) return } // The same navigating success every settings write answers with (design // D9): HX-Redirect for the htmx trigger, a 303 for a native one. redirectToRecord(w, r, "/operator/integrations/"+info.Key+"/settings?flash=cleared") } // OperatorNotFoundData is the body for the operator-subtree 404 page. type OperatorNotFoundData struct { Path string } // GetOperatorNotFound handles every /operator/* path no more-specific route // claims (the "/operator/" subtree pattern loses to all registered pages). // Without it, unmatched operator paths fall through to the "/" catch-all and // render the MEMBER dashboard with a 200 — a truncated URL like // /operator/integrations/stripe would silently eject the operator from the // operator area (2026-07-22 fresh-eyes audit, finding: soft-404 trap). func (h *OperatorPartialsHandler) GetOperatorNotFound(w http.ResponseWriter, r *http.Request) { page := h.buildOperatorPageData(r) page.IAPosition = "runtime:landing" page.BodyTemplate = "operator_not_found.html" page.BodyData = OperatorNotFoundData{Path: r.URL.Path} // Content-Type must precede WriteHeader; Render's own Set afterwards // would be ignored on this non-200 path. w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusNotFound) h.Templates.Render(w, "operator.html", page) } // DeleteOrphanOverride handles DELETE /operator/integrations/config-orphans/{key} // — remove an override row whose key matches no installed integration's // declaration (listed on the Integrations landing, confirmed via the shared // confirm-action modal). func (h *OperatorPartialsHandler) DeleteOrphanOverride(w http.ResponseWriter, r *http.Request) { keyName := r.PathValue("key") // Refuse to delete a row that is NOT an orphan through this path — the // per-integration settings page owns declared keys. for _, info := range h.IntegrationConfigs { for _, k := range info.Keys { if k.Name == keyName { http.Error(w, fmt.Sprintf("key %q belongs to integration %q; manage it on its settings page", keyName, info.Key), http.StatusBadRequest) return } } } if _, err := integration.NewSettingsService(h.Database).Clear(r.Context(), keyName); err != nil { h.Logger.Error("failed to delete orphan config override", slog.String("key", keyName), slog.Any("error", err)) http.Error(w, "failed to delete override", http.StatusInternalServerError) return } w.Header().Set("HX-Redirect", "/operator/integrations") w.WriteHeader(http.StatusOK) }