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