Files
member-console/internal/server/operator_integration_settings_forms.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

278 lines
11 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"html/template"
"strconv"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
)
// The integration settings form (spec integration-settings "Operator
// settings surface is generic and declaration-driven"; spec form-library;
// design D9, D21; findings FA-10, FA-24). One form covers every key an
// installed integration declares, so a refusal for any key answers 422
// with that field's reason under its control, exactly like every other
// form on either surface; a refusal that belongs to no field (the key is
// unknown, or the write itself fails) lands in the form-level slot.
//
// The Table family is the only one a Settings form may declare (design
// D21): one row per key, the key and its usage in the first column, the
// effective value and the winning source in the page's own cells, the
// control last, and one Save below the table. Round 3 forced this into
// Stacked, and the page lost the effective value, the source and the
// status it had carried, gained "(optional)" on every row, showed "None"
// in the control with the real value in a hint, and kept a second table
// below the form for the secret keys (maintainer, 2026-09-04).
//
// This file is the form's home; the registry is only the index (design
// D1). It sits beside operator_integration_settings.go, which parses
// through the declaration and never through r.FormValue.
// integrationSettingsFormName is the declaration's name and its data-form
// id, the same on every integration's settings page: one form, rendered
// once per installed integration with a different field list each time.
const integrationSettingsFormName = "operator.integration.settings"
// integrationSettingsForm is the registered declaration. It carries the
// contract every integration's settings page shares (kind, family, method,
// path, columns, commit) for the registry, the invariants test, the route
// test and the design system's registered-forms table; it is never
// rendered as-is. The real field list cannot be fixed here: each installed
// integration declares its own ConfigSpec, assembled by the composition
// root when the server starts, after this package's init has already run
// and registered this declaration, and a settings page with no
// per-integration template or handler edits must show whichever keys that
// integration happens to declare (integration-settings, "New integration
// needs no surface work"). settingsFormFor builds the per-request
// declaration those pages actually render, from the same name, kind,
// family, method, path, columns and commit.
var integrationSettingsForm = forms.Register(forms.FormSpec{
Name: integrationSettingsFormName,
Kind: forms.KindSettings,
Family: forms.Table,
Method: "POST",
Path: "/operator/integrations/{integrationKey}/settings",
Target: "#operator-body",
Swap: "innerHTML",
LabelColumn: "Key",
ControlColumn: "Override",
Columns: []string{"Effective value", "Source"},
Fields: []forms.Field{
{
Name: "override",
Label: "override",
Control: forms.Text,
Optional: true,
Autocomplete: "off",
Hint: "One row per configuration key the installed integration declares.",
},
},
Commit: "Save",
})
// settingsFormFor is the settings page's real declaration for one request:
// the registered spec's name, kind, family, route, columns and commit,
// with one row per declared key, in the order the integration declared
// them. Every control sets a value and does nothing else (design D21 round
// 5): an enum key's select offers its declared members and no empty
// choice, a bool key is a checkbox, and a text or list key is a text
// control. Removing a stored override is the row's own Clear action, not a
// value the control can carry. A secret key is a static row: it states
// where the value is set and offers no control, because there is nothing
// an operator can set here (design D21, which is why the separate secrets
// table below the form is gone).
func settingsFormFor(rows []SettingRow) forms.FormSpec {
spec := integrationSettingsForm
spec.Fields = nil
for _, row := range rows {
spec.Fields = append(spec.Fields, settingsField(row))
}
return spec
}
// settingsField declares one key's row. The control is bound to the value
// in force (settingsValues): the stored override when one exists, the
// boot-effective value otherwise, so the control says what is actually
// running (design D21).
//
// A bool key is a checkbox, not a three-way select: a boolean has two
// states, and the select's third choice offered a way to submit nothing at
// all (maintainer, 2026-09-05: "fedwiki-sync-enabled is also odd"). The
// table places the label itself, in the label column, so the control cell
// holds the input alone and no second label is rendered.
//
// A text control's Placeholder is its type's format example
// (row.Placeholder, set by settingsPlaceholder from the key's declared or
// inferred type; design D4, typed-config-keys), shown only once the
// control is empty; a bare string key carries none.
func settingsField(row SettingRow) forms.Field {
if row.Secret {
return forms.Field{
Name: row.Key,
Label: row.Key,
Control: forms.Static,
Value: "Set " + row.Key + " (or " + row.Key + "-file) in the environment.",
Hint: row.Usage,
}
}
f := forms.Field{
Name: row.Key,
Label: row.Key,
Control: forms.Text,
Optional: true,
Autocomplete: "off",
Hint: settingsHint(row),
Placeholder: row.Placeholder,
}
switch row.Kind {
case "enum":
f.Control = forms.Select
for _, v := range row.Enum {
f.Options = append(f.Options, forms.Option{Value: v, Label: v})
}
case "bool":
f.Control = forms.Checkbox
// A checkbox submits "true" when ticked and nothing when it is not;
// autocomplete has no meaning on one and the part never emits it
// there.
f.Autocomplete = ""
case "list":
f.Help = forms.Help("List format", "Comma-separated list.")
}
return f
}
// settingsHint is the key's declared usage, and, for the rare key whose
// value has a real-world consequence beyond this deployment's own state,
// that consequence too. The effective value is a column now,
// and the pending-restart state is a badge in it, so neither is repeated
// here (design D21: "the Effective column carries the fact").
func settingsHint(row SettingRow) string {
return appendSentence(row.Usage, row.Warning)
}
// appendSentence joins two sentence fragments with a space, skipping either
// side that is empty.
func appendSentence(hint, addition string) string {
if hint == "" {
return addition
}
if addition == "" {
return hint
}
return hint + " " + addition
}
// settingsBoundValue is the value a key's control renders with, and the
// value a submission is compared against to decide whether anything
// changed: the stored override when one exists, the boot-effective value
// otherwise (design D21's control-shows-the-value-in-force rule, and its
// handler no-change rule, which are the same fact read twice).
func settingsBoundValue(row SettingRow) string {
value := row.Effective
if row.HasOverride {
value = row.Override
}
if row.Kind == "bool" {
// A checkbox is ticked or it is not, so a bool row's value in force
// is one of two strings: anything that is not "true" is false. This
// is the same reduction the control renders with, so the state on
// the page and the state a submission is compared against cannot
// disagree.
return strconv.FormatBool(value == "true")
}
return value
}
// settingsEmptyRefusal is what a text or list control emptied against a
// value that was there is told. Both messages carry the fact the person
// needs and nothing else: where a stored override is what they emptied,
// the way to remove it is the row's Clear, and that is not something the
// page states anywhere else (design D21 round 5).
func settingsEmptyRefusal(row SettingRow) string {
if row.HasOverride {
return "Enter a value, or clear the override."
}
return "Enter a value."
}
// settingsSubmittedValue is what one row's control sent, in the display
// form settingsBoundValue produces: a checkbox missing from the body is
// "false", every other control is its raw text.
func settingsSubmittedValue(values forms.Values, row SettingRow) string {
if row.Kind == "bool" {
return strconv.FormatBool(values.Bool(row.Key))
}
return values.String(row.Key)
}
// settingsValues binds each row's control to the value in force, so a
// select shows the running value, a checkbox stands in its running state,
// and a text control shows the running text rather than an empty box
// (design D5, "bound to a record").
func settingsValues(rows []SettingRow) forms.Values {
values := forms.NewValues()
for _, row := range rows {
if row.Secret {
continue
}
values.Set(row.Key, settingsBoundValue(row))
}
return values
}
// settingsCells renders each row's Effective value and Source cells, in the
// declaration's Columns order. Each cell holds its own subject alone
// (design D9): the Effective cell the value in force, the Source cell its
// badge, and nothing that describes the stored override; that renders in
// the Override cell instead (settingsControlFooters). The badges are the
// page's, not the library's, which is why they arrive pre-rendered (design
// D21); they go through the same statusBadge part every other surface
// uses, from the settings template's own cell defines, so no badge markup
// is written in Go.
func settingsCells(t *SafeTemplates, rows []SettingRow) map[string][]template.HTML {
if t == nil {
return nil
}
cells := make(map[string][]template.HTML, len(rows))
for _, row := range rows {
cells[row.Key] = []template.HTML{
t.Fragment("settingsEffectiveCell", row),
t.Fragment("settingsSourceCell", row),
}
}
return cells
}
// settingsControlFooters renders each row's Override cell content under
// its control: Clear when a stored override exists, and the Pending
// restart badge when the stored state differs from the running one, both
// on one line (design D9, typed-config-keys): everything that describes
// the stored override rather than the running value, kept out of the
// Effective and Source cells settingsCells renders. Empty (renders
// nothing) for a row with neither.
func settingsControlFooters(t *SafeTemplates, rows []SettingRow) map[string]template.HTML {
if t == nil {
return nil
}
footers := make(map[string]template.HTML, len(rows))
for _, row := range rows {
footers[row.Key] = t.Fragment("settingsOverrideCell", row)
}
return footers
}
// findConfigKey returns the declared key named name, and whether it exists.
func findConfigKey(keys []config.ConfigKey, name string) (config.ConfigKey, bool) {
for _, k := range keys {
if k.Name == name {
return k, true
}
}
return config.ConfigKey{}, false
}