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:
@@ -298,6 +298,7 @@ func (MembersController) FormVirtualFields() []string {
|
||||
- Every listed name must be a field of the form's `fields.yaml` with the type `password`, `text`, `textarea`, `number`, `checkbox`, `switch` or `dropdown`. Anything else stops the start-up.
|
||||
- `type: password` is always a form-only field: a password field the controller does not list stops the start-up. The admin SPA shows it empty on every load, leaves an empty password out of an update (empty means unchanged), and clears it after a save. A confirmation is a second `password` field, compared by the `confirmed` rule on the server.
|
||||
- Form-only fields are not available on settings forms or relation forms.
|
||||
- On a settings form `type: password` is not a form-only field but a write-only secret backed by a `lagoon.Encrypted` column; see [Write-only secrets](settings.md#write-only-secrets).
|
||||
|
||||
## Permission editor
|
||||
|
||||
@@ -449,7 +450,7 @@ The value travels as a string of HTML, or null. Anything else in a save body is
|
||||
- HTML the policy keeps whole is stored byte for byte, so a value that only differs in escaping is never rewritten.
|
||||
- Otherwise the clean HTML is stored and the save still succeeds. The create or update response then carries `meta.notices` for the field: how many elements and attributes were removed with their names, how many comments, or that some markup changed. The admin shows them under the field.
|
||||
|
||||
The editor's own toolbar keeps to the same allowlist, but the server is the gate: a value posted by any client goes through the policy. The type is supported in controller forms only; settings forms and relation forms refuse it at start-up, so no save path stores an unsanitized value.
|
||||
The editor's own toolbar keeps to the same allowlist, but the server is the gate: a value posted by any client goes through the policy. The type is supported in controller forms and on [settings forms](settings.md#rich-text-on-a-settings-page), whose save runs the same sanitizer; relation forms refuse it at start-up, so no save path stores an unsanitized value.
|
||||
|
||||
In the admin SPA the field is a TipTap editor with a toolbar for bold, italic, underline, a level 3 heading, bullet and numbered lists, quotes, links, undo and redo. The editor code loads on first use, so forms without a rich text field never download it.
|
||||
|
||||
|
||||
@@ -95,6 +95,44 @@ The settings row is the one with ID 1. Before it exists, the page shows the fiel
|
||||
|
||||
The admin API serves the page at `<prefix>/api/v1/settings/{code}` (values) and `.../settings/{code}/schema` (the form), and `GET .../settings` lists the pages the administrator may open.
|
||||
|
||||
## Write-only secrets
|
||||
|
||||
A `type: password` field on a settings form is a write-only secret: an API key, a webhook signing secret. Its model column must be a `lagoon.Encrypted` (or a pointer to one), so the value is encrypted at rest under `app.key`; any other column type stops the start-up naming the field.
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
secret_key:
|
||||
label: acme.pay::lang.settings.secret_key
|
||||
type: password
|
||||
tab: acme.pay::lang.settings.tab_live
|
||||
```
|
||||
|
||||
- `GET /settings/{code}` never includes the field in `data`. It reports the field's state in `meta.secrets` instead: `stored` when the column holds a value, `env` when the column is empty and a fallback applies (below), or `empty`. The `meta` key is omitted when the form has no password field.
|
||||
- `PUT /settings/{code}` keeps the stored value when the key is absent or an empty string, clears it when the value is JSON `null`, and stores any other string through `lagoon.NewEncrypted`. A value that is neither a string nor `null` is a 422 on the field. The answer carries the new states in `meta.secrets` and never the values; cabana never logs a secret and never echoes it in an error.
|
||||
- A `required` password field is satisfied by a stored value, so an absent key does not fail the required check.
|
||||
|
||||
The admin shows the field empty on every load with a status line under it: a stored value with a "Remove stored value" button (the clear is deferred until Save and can be undone), the configuration fallback, or "Not set". Typing a value replaces the stored one on Save; an untouched field is not sent.
|
||||
|
||||
A settings model may implement `cabana.SecretFallbacks` to name, per field, the configuration key the application falls back to when the column is empty, so the page can say that a value from the server configuration is in use:
|
||||
|
||||
```go src=modules/cabana/settings_secret_test.go#secretSettings.SecretFallbacks
|
||||
// SecretFallbacks maps each secret field to the config key the application
|
||||
// reads when the column is empty; SUMMER_* variables map onto those keys.
|
||||
func (secretSettings) SecretFallbacks() map[string]string {
|
||||
return map[string]string{"api_key": "acme.secret.api_key"}
|
||||
}
|
||||
```
|
||||
|
||||
cabana only checks whether the key resolves to a non-empty value (`env` state); reading and using the fallback value stays the plugin's job. The states are the constants `cabana.SecretStored`, `cabana.SecretEnv` and `cabana.SecretEmpty`, and the meta shape is `cabana.SettingsMeta`.
|
||||
|
||||
## Rich text on a settings page
|
||||
|
||||
`type: richeditor` is accepted on a settings form. The settings save runs every submitted value through the same seaglass sanitizer as a controller save (see [Rich text](forms.md#rich-text)): clean HTML is stored byte for byte, otherwise the clean value is stored and the answer lists what was removed in `meta.notices`, which the admin shows under the field. A value that is not a string or `null` is a 422 on the field. `repeater` and `dependsOn` are still refused on settings forms, as they are on relation forms.
|
||||
|
||||
## Tabs
|
||||
|
||||
Settings fields take `tab` like record form fields. When any field declares one, the admin renders the tab strip in the page header, with the fields that have no `tab` on the default tab first; the form card sits inside the active tab's panel. After a 422 the first tab with an error is activated and shows its error count. Without any `tab` the page is unchanged.
|
||||
|
||||
## Reading settings in plugin code
|
||||
|
||||
Settings are an ordinary table, so plugin code reads them with GORM:
|
||||
|
||||
Reference in New Issue
Block a user