Files
member-console/internal/server/operator_integration_settings.go
T
cgalo5758 782ca8f326 Derive Stripe mode from API key and refine disabled controls
Derive Stripe test/live mode from the API key prefix at boot, failing on
unrecognized prefixes, and drop the separate `stripe-mode` config key.

Refine disabled controls to render through the shared `disabledControl`
part with the not-allowed cursor, and add a lint rule refusing
hand-rolled disabled buttons.

Adjust plan cards to offer no purchase control on free rungs, fix bound
checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted"
with enhanced readiness verdicts.
2026-09-13 16:59:12 -05:00

497 lines
20 KiB
Go

// 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)
}