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.
278 lines
11 KiB
Go
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
|
|
}
|