feat(18-01): write-only settings secrets, richeditor and tabs on settings forms (task 3)

- type: password on a settings form binds a lagoon.Encrypted column as a
  write-only secret (any other column fails boot naming the field): the
  GET omits it from data and reports stored, env or empty per field in
  meta.secrets; on PUT an absent key or "" keeps the value, null clears
  it, a non-string is a 422; the answer never carries a value
- cabana.SecretFallbacks (field to config key) gives the env state;
  cabana.SettingsMeta, SecretStored/SecretEnv/SecretEmpty exported
- settings forms accept richeditor: the Put sanitizes through seaglass
  like a controller save and returns meta.notices; repeater and
  dependsOn stay refused (settingsFormOnly next to controllerFormOnly)
- backend::lang secret.* phrases in en and pl
- admin SPA: PasswordField secret status line (stored with Remove stored
  value, env, empty, pending clear with Undo), SettingsFormView tabs with
  error-tab activation and the secrets/notices meta, settings payload
  that never sends an untouched secret; regenerated OpenAPI and TS
  types; rebuilt dist
- vault fixture, TestSettingsSecretCompile, Docker TestSettingsSecretSmoke
  and TestSettingsRichEditorSmoke, conform settings fixture with a
  password field; settings.md sections, forms.md notes, cabana README
This commit is contained in:
Jakub Zych
2026-10-13 11:36:12 +02:00
parent 0c8605123e
commit 1e841b4281
27 changed files with 1251 additions and 92 deletions

View File

@@ -353,7 +353,17 @@ func conformCases() []conformCase {
return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true)
}, into[cabana.Envelope[cabana.FormView]](), nil},
{"GET /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform", nil, true)
rec := e.send(t, http.MethodGet, "/settings/conform", nil, true)
// Phase 18 D-13: the password field is absent from data and
// reported in meta.secrets.
var body cabana.Envelope[cabana.SettingsResult]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if _, leaked := body.Data.Data["api_key"]; leaked || body.Data.Meta == nil || body.Data.Meta.Secrets["api_key"] != cabana.SecretEmpty {
t.Fatalf("settings secrets = %s", rec.Body.String())
}
return rec
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
{"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
@@ -1060,12 +1070,13 @@ func (conformPart) AttachRelations() []attach.Relation {
}
type conformSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
APIKey lagoon.Encrypted `gorm:"column:api_key;type:text"`
}
func (conformSettings) TableName() string { return "cabana_conform_settings" }
func (conformSettings) Fillable() []string { return []string{"enabled"} }
func (conformSettings) Fillable() []string { return []string{"enabled", "api_key"} }
func (conformSettings) Rules() map[string]string { return map[string]string{} }
type conformPlugin struct{ stamp string }
@@ -1438,6 +1449,9 @@ parts:
label: Enabled
type: switch
default: 0
api_key:
label: API key
type: password
`),
}
}