- 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
327 lines
12 KiB
Go
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)
|
|
}
|