Files
member-console/internal/server/operator_integration_settings.go
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00

327 lines
12 KiB
Go

package server
import (
"database/sql"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"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
Usage string
Secret bool
Enum []string
Kind string // input shape: "enum" | "bool" | "list" | "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 the operator should see up front (today: only
// stripe-mode, set by name in GetIntegrationSettingsPage). Empty for
// every routine key.
Warning string
}
// IntegrationSettingsData is the settings page body.
type IntegrationSettingsData struct {
Slug string
DisplayName string
SurfacePath string // admin page to cross-link ("" when none)
Rows []SettingRow
// Saved/Cleared/Error/ErrorKey carry post-redirect feedback (?saved= /
// ?cleared= / ?error= / ?key= query params — the write path answers
// with HX-Redirect).
Saved string
Cleared string
Error string
ErrorKey string
}
// integrationConfigBySlug returns the declared config for one installed
// integration, or false when the slug is unknown or declares no keys.
func (h *OperatorPartialsHandler) integrationConfigBySlug(slug string) (IntegrationConfigInfo, bool) {
for _, info := range h.IntegrationConfigs {
if info.Slug == slug && len(info.Keys) > 0 {
return info, true
}
}
return IntegrationConfigInfo{}, false
}
// configurationReadiness reports whether provider slug'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, slug string) (configured bool, missing []string) {
for _, info := range configs {
if info.Slug == slug {
missing = config.RequiredKeysUnresolved(info.Keys)
return len(missing) == 0, missing
}
}
return true, nil
}
// settingKind maps a declaration to its input shape.
func settingKind(key config.ConfigKey) string {
if len(key.Enum) > 0 {
return "enum"
}
switch key.Default.(type) {
case []string:
return "list"
case bool:
return "bool"
default:
return "string"
}
}
// 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
}
// GetIntegrationSettingsPage handles GET /operator/integrations/{slug}/settings.
func (h *OperatorPartialsHandler) GetIntegrationSettingsPage(w http.ResponseWriter, r *http.Request) {
info, ok := h.integrationConfigBySlug(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
overrides := map[string]string{}
rows, err := integration.New(h.Database).ListConfigOverrides(r.Context())
bodyData := IntegrationSettingsData{
Slug: info.Slug,
DisplayName: info.DisplayName,
SurfacePath: info.SurfacePath,
Saved: r.URL.Query().Get("saved"),
Cleared: r.URL.Query().Get("cleared"),
Error: r.URL.Query().Get("error"),
ErrorKey: r.URL.Query().Get("key"),
}
if err != nil {
h.Logger.Error("failed to list config overrides", slog.Any("error", err))
bodyData.Error = "Failed to load stored overrides; showing declared configuration only."
} else {
for _, row := range rows {
overrides[row.Key] = row.Value
}
}
boot := config.BootEffective()
for _, key := range info.Keys {
row := SettingRow{
Key: key.Name,
Usage: key.Usage,
Secret: key.Secret,
Enum: key.Enum,
Kind: settingKind(key),
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 stored, has := overrides[key.Name]; has {
row.Override = stored
row.HasOverride = true
row.PendingRestart = eff.Source != config.SourceOverride ||
eff.Value != normalizedOverride(key, stored)
} else {
row.PendingRestart = eff.Source == config.SourceOverride
}
}
if key.Name == "stripe-mode" {
// The one setting on this generic page whose value has a
// real-world consequence beyond this deployment's own state:
// switching to live processes real charges. Disclosed here,
// adjacent to the control, before the operator ever submits —
// see the integration-settings spec's stripe-mode requirement.
row.Warning = "Selecting live moves this deployment onto real payment processing: charges become real money, not test transactions."
}
bodyData.Rows = append(bodyData.Rows, row)
}
page := h.buildOperatorPageData(r)
page.IAPosition = "integration:integrations:" + info.Slug
page.ActiveCapability = "integrations"
page.BodyTemplate = "operator_integration_settings.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// PostIntegrationSetting handles POST /operator/integrations/{slug}/settings —
// save or clear one key's override. Answers with HX-Redirect back to the
// settings page (the operator shell posts via HTMX with the CSRF header);
// feedback travels as query params.
func (h *OperatorPartialsHandler) PostIntegrationSetting(w http.ResponseWriter, r *http.Request) {
info, ok := h.integrationConfigBySlug(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
settingsPath := "/operator/integrations/" + info.Slug + "/settings"
redirect := func(query string) {
w.Header().Set("HX-Redirect", settingsPath+query)
w.WriteHeader(http.StatusOK)
}
keyName := r.FormValue("key")
var spec config.ConfigKey
found := false
for _, k := range info.Keys {
if k.Name == keyName {
spec, found = k, true
break
}
}
if !found {
redirect("?error=" + url.QueryEscape("Unknown configuration key.") + "&key=" + url.QueryEscape(keyName))
return
}
if spec.Secret {
redirect("?error=" + url.QueryEscape("Secret keys are managed via the environment and cannot be overridden.") + "&key=" + url.QueryEscape(keyName))
return
}
iq := integration.New(h.Database)
switch r.FormValue("action") {
case "clear":
if _, err := iq.DeleteConfigOverride(r.Context(), keyName); err != nil {
h.Logger.Error("failed to clear config override", slog.String("key", keyName), slog.Any("error", err))
redirect("?error=" + url.QueryEscape("Failed to clear the override.") + "&key=" + url.QueryEscape(keyName))
return
}
redirect("?cleared=" + url.QueryEscape(keyName))
return
case "save":
value := strings.TrimSpace(r.FormValue("value"))
if value == "" {
redirect("?error=" + url.QueryEscape("Provide a value; an empty override cannot be stored.") + "&key=" + url.QueryEscape(keyName))
return
}
// Same validation the boot overlay applies: a value that saves is
// a value that boots.
if _, err := config.CoerceOverride(spec, value); err != nil {
redirect("?error=" + url.QueryEscape(err.Error()) + "&key=" + url.QueryEscape(keyName))
return
}
updatedBy := sql.NullString{}
if page := h.buildOperatorPageData(r); page.Email != "" {
updatedBy = sql.NullString{String: page.Email, Valid: true}
}
if err := iq.UpsertConfigOverride(r.Context(), integration.UpsertConfigOverrideParams{
Key: keyName, Value: value, UpdatedBy: updatedBy,
}); err != nil {
h.Logger.Error("failed to save config override", slog.String("key", keyName), slog.Any("error", err))
redirect("?error=" + url.QueryEscape("Failed to save the override.") + "&key=" + url.QueryEscape(keyName))
return
}
default:
redirect("?error=" + url.QueryEscape("Unknown action."))
return
}
redirect("?saved=" + url.QueryEscape(keyName))
}
// 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.Slug), http.StatusBadRequest)
return
}
}
}
if _, err := integration.New(h.Database).DeleteConfigOverride(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)
}