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

@@ -1017,7 +1017,10 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Read admin settings */
/**
* Read admin settings
* @description The singleton's values keyed by field name. A write-only password field (a lagoon.Encrypted column) is never in data; meta.secrets reports its state instead: stored, env (empty column, the model's SecretFallbacks config key resolves) or empty. meta is omitted when the form has no password field.
*/
get: {
parameters: {
query?: never;
@@ -1068,7 +1071,10 @@ export interface paths {
};
};
};
/** Update admin settings */
/**
* Update admin settings
* @description Saves the singleton. For a password field an absent key or an empty string keeps the stored value and JSON null clears it; any other non-string value is a 422 on the field. A richeditor value is sanitized like a controller save and what was removed comes back in meta.notices. The answer carries meta.secrets with the new states and never the values.
*/
put: {
parameters: {
query?: never;
@@ -6077,11 +6083,23 @@ export interface components {
model: string;
order: number;
};
"cabana.SettingsMeta": {
/**
* @description Notices are what the seaglass sanitizer removed from richeditor
* values on this save.
*/
notices?: components["schemas"]["cabana.FieldNotice"][];
/** @description Secrets maps each password field to stored, env or empty. */
secrets?: {
[key: string]: string;
};
};
"cabana.SettingsResult": {
data: {
[key: string]: unknown;
};
exists: boolean;
meta?: components["schemas"]["cabana.SettingsMeta"];
};
"cabana.SuccessMeta": {
last_page?: number;

View File

@@ -1,16 +1,25 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { Eye, EyeOff } from '@lucide/vue'
import { CircleAlert, Eye, EyeOff, Info, Lock, Trash2, Undo2 } from '@lucide/vue'
import { t } from '../../../app/i18n'
import Button from '../../ui/Button.vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
import { FORM_SESSION } from '../formContext'
import { FORM_SESSION, SETTINGS_SECRETS } from '../formContext'
// Password control (UI-SPEC S7, D-19). The server never sends a value, so the
// field is empty on load; the value leaves only in the save body. The toggle
// switches the input between hidden and visible text. After a successful save
// the form clears the value and the control returns to hidden.
//
// On a settings page (UI-SPEC F2, D-13) the field is a write-only secret and
// carries a state from meta.secrets: a status line under the input says
// whether a value is stored, comes from the server configuration or is not
// set. "Remove stored value" makes the clear pending (the input empties and
// is disabled, Save sends null) until Undo; typing a value replaces the
// stored one on Save; an empty, untouched field is not sent at all. A record
// form provides no state, so this block never renders there.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
const shown = ref(false)
const text = computed(() => (typeof props.modelValue === 'string' ? props.modelValue : ''))
@@ -22,35 +31,92 @@ if (session) {
shown.value = false
})
}
const secrets = inject(SETTINGS_SECRETS, null)
const state = computed(() => secrets?.value[props.field.name] ?? '')
const secret = computed(() => state.value !== '')
// A pending clear: the stored value is removed on Save (the model holds null).
const pendingClear = computed(() => state.value === 'stored' && props.modelValue === null)
const statusId = computed(() => `${props.controlId}-secret`)
const describedBy = computed(() => [props.describedBy ?? '', secret.value ? statusId.value : ''].filter(Boolean).join(' '))
const placeholder = computed(() => {
if (state.value === 'stored' && !pendingClear.value) {
return t('backend::lang.secret.placeholder_stored')
}
const own = attrs.value.placeholder
return typeof own === 'string' ? own : undefined
})
function clearStored(): void {
shown.value = false
emit('update:modelValue', null)
}
function undoClear(): void {
emit('update:modelValue', '')
}
</script>
<template>
<div class="relative">
<input
:id="controlId"
autocomplete="new-password"
spellcheck="false"
v-bind="attrs"
:name="field.name"
:type="shown ? 'text' : 'password'"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input pr-12"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button
type="button"
data-password-toggle
:aria-pressed="shown ? 'true' : 'false'"
:aria-label="t(shown ? 'backend::lang.form.hide_password' : 'backend::lang.form.show_password')"
class="absolute top-1/2 right-1.5 flex size-8 -translate-y-1/2 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
@click="shown = !shown"
<div class="flex min-w-0 flex-col gap-1.5">
<div class="relative">
<input
:id="controlId"
autocomplete="new-password"
spellcheck="false"
v-bind="attrs"
:name="field.name"
:type="shown ? 'text' : 'password'"
:value="pendingClear ? '' : text"
:placeholder="placeholder"
:disabled="pendingClear || undefined"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input pr-12"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button
type="button"
data-password-toggle
:aria-pressed="shown ? 'true' : 'false'"
:aria-label="t(shown ? 'backend::lang.form.hide_password' : 'backend::lang.form.show_password')"
:disabled="pendingClear || undefined"
class="absolute top-1/2 right-1.5 flex size-8 -translate-y-1/2 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text disabled:opacity-60"
@click="shown = !shown"
>
<component :is="shown ? EyeOff : Eye" :size="16" aria-hidden="true" />
</button>
</div>
<p
v-if="secret"
:id="statusId"
:data-secret-state="pendingClear ? 'clearing' : state"
:role="pendingClear ? 'status' : undefined"
:class="pendingClear ? 'text-danger' : 'text-muted'"
class="flex flex-wrap items-center gap-2 text-[13px]"
>
<component :is="shown ? EyeOff : Eye" :size="16" aria-hidden="true" />
</button>
<template v-if="pendingClear">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.secret.clear_pending') }}</span>
<Button variant="ghost" size="sm" :icon="Undo2" data-action="secret-undo" @click="undoClear">
{{ t('backend::lang.secret.undo') }}
</Button>
</template>
<template v-else-if="state === 'stored'">
<Lock :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.secret.stored') }}</span>
<Button variant="ghost" size="sm" :icon="Trash2" data-action="secret-clear" @click="clearStored">
{{ t('backend::lang.secret.clear') }}
</Button>
</template>
<template v-else-if="state === 'env'">
<Info :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.secret.env') }}</span>
</template>
<span v-else>{{ t('backend::lang.secret.empty') }}</span>
</p>
</div>
</template>

View File

@@ -82,6 +82,14 @@ export function createUploadGate(): { activeUploads: Ref<number>; beginUpload: (
export const FORM_SESSION: InjectionKey<FormSession> = Symbol('summer.form.session')
/**
* The state of each write-only password field of a settings page (Phase 18
* D-13, UI-SPEC F2): field name to `stored`, `env` or `empty`, from the
* settings result's meta.secrets. SettingsFormView provides it; a record
* form provides nothing, so its password fields keep their plain behaviour.
*/
export const SETTINGS_SECRETS: InjectionKey<Readonly<Ref<Record<string, string>>>> = Symbol('summer.settings.secrets')
/** Event a widget element dispatches to run its action (bubbles, composed). */
export const WIDGET_EVENT = 'summer-action'

View File

@@ -1,13 +1,27 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { computed, nextTick, provide, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminRecord, FormView } from '../api/types'
import type { AdminRecord, FormField, FormView } from '../api/types'
import { t } from '../app/i18n'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import { contextAllows, editablePayload, fieldErrors, focusField } from '../components/form/formState'
import FormTabs from '../components/form/FormTabs.vue'
import { SETTINGS_SECRETS } from '../components/form/formContext'
import {
DEFAULT_TAB,
contextAllows,
editablePayload,
fieldErrors,
fieldHasErrors,
focusField,
noticeLines,
panelDomId,
tabDomId,
tabOf,
type TabItem,
} from '../components/form/formState'
import Button from '../components/ui/Button.vue'
import { settingsEntry } from '../state/useSettings'
import { showToast } from '../state/useToasts'
@@ -15,6 +29,11 @@ import { showToast } from '../state/useToasts'
// One settings page (D-21) through the same FormGrid and field registry as
// records: GET the schema and the values, PUT the values keyed by field name,
// map a 422 the same way. Settings forms have no create step.
// Phase 18: fields with a `tab` render the FormTabs strip as on a record form
// (UI-SPEC F4); the write-only password fields carry their stored, env or
// empty state from meta.secrets (F2) and are sent only when the
// administrator typed a value (a string) or removed the stored one (null);
// richeditor save notices from meta.notices render under their field (F3).
const route = useRoute()
const code = String(route.params.code ?? '')
const ID_PREFIX = 'setting'
@@ -22,15 +41,53 @@ const ID_PREFIX = 'setting'
const schema = ref<FormView | null>(null)
const values = ref<AdminRecord>({})
const errors = ref<Record<string, string[]>>({})
const notices = ref<Record<string, string[]>>({})
const secrets = ref<Record<string, string>>({})
const loading = ref(true)
const failed = ref(false)
const busy = ref(false)
const activeTab = ref(DEFAULT_TAB)
provide(SETTINGS_SECRETS, computed(() => secrets.value))
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, 'update')))
const fieldNames = computed(() => fields.value.map((field) => field.name))
const title = computed(() => settingsEntry(code)?.label || schema.value?.name || code)
const description = computed(() => settingsEntry(code)?.description ?? '')
const tabs = computed<TabItem[]>(() => {
if (!fields.value.some((field) => field.tab)) {
return []
}
const out: TabItem[] = []
for (const field of fields.value) {
const key = tabOf(field)
let tab = out.find((item) => item.key === key)
if (!tab) {
tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
if (key === DEFAULT_TAB) {
out.unshift(tab)
} else {
out.push(tab)
}
}
if (fieldHasErrors(errors.value, field.name)) {
tab.errors += 1
}
}
return out
})
const activeIndex = computed(() => Math.max(tabs.value.findIndex((tab) => tab.key === activeTab.value), 0))
const panelFields = computed(() => {
if (tabs.value.length === 0) {
return fields.value
}
const key = tabs.value[activeIndex.value]?.key ?? DEFAULT_TAB
return fields.value.filter((field) => tabOf(field) === key)
})
async function load(): Promise<void> {
const params = { path: { code } }
const [schemaResult, valuesResult] = await Promise.all([
@@ -39,8 +96,10 @@ async function load(): Promise<void> {
])
schema.value = schemaResult.data?.data ?? null
values.value = { ...(valuesResult.data?.data.data ?? {}) }
secrets.value = { ...(valuesResult.data?.data.meta?.secrets ?? {}) }
failed.value = !schemaResult.data || !valuesResult.data
loading.value = false
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
}
function update(name: string, value: unknown): void {
@@ -52,6 +111,27 @@ function update(name: string, value: unknown): void {
}
}
/**
* The PUT body: every editable value, except a password field that was not
* typed into (undefined or an empty string keeps the stored value, so it is
* left out) while a pending clear sends null.
*/
function settingsPayload(editable: FormField[], current: AdminRecord): AdminRecord {
const out = editablePayload(editable, current)
for (const field of editable) {
if (field.type !== 'password') {
continue
}
const value = current[field.name]
if (value === null) {
out[field.name] = null
} else if (typeof value !== 'string' || value === '') {
delete out[field.name]
}
}
return out
}
async function onSave(): Promise<void> {
if (busy.value || !schema.value) {
return
@@ -60,17 +140,23 @@ async function onSave(): Promise<void> {
try {
const result = await api.PUT('/settings/{code}', {
params: { path: { code } },
body: editablePayload(fields.value, values.value),
body: settingsPayload(fields.value, values.value),
})
if (result.data) {
errors.value = {}
values.value = { ...result.data.data.data }
secrets.value = { ...(result.data.data.meta?.secrets ?? {}) }
notices.value = noticeLines(result.data.data.meta?.notices)
showToast(t('backend::lang.settings.saved'))
return
}
if (result.response.status === 422) {
errors.value = fieldErrors(result.error?.error.details)
const first = fields.value.find((field) => errors.value[field.name])
const invalid = fields.value.filter((field) => errors.value[field.name])
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
if (first && tabs.value.length > 0) {
activeTab.value = tabOf(first)
}
await nextTick()
if (first) {
focusField(ID_PREFIX, first.name)
@@ -100,10 +186,11 @@ void load()
>
<ArrowLeft :size="18" aria-hidden="true" />
</Button>
<div class="flex min-w-0 flex-col">
<div class="flex min-w-0 flex-1 flex-col">
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ title }}</h1>
<p v-if="description" class="text-muted">{{ description }}</p>
</div>
<FormTabs v-if="tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="ID_PREFIX" />
</header>
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
@@ -112,11 +199,19 @@ void load()
<template v-else-if="schema">
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
<form class="rounded-card border border-border bg-surface p-7 shadow-card" novalidate @submit.prevent="onSave">
<form
:id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined"
:role="tabs.length > 0 ? 'tabpanel' : undefined"
:aria-labelledby="tabs.length > 0 ? tabDomId(ID_PREFIX, activeIndex) : undefined"
class="rounded-card border border-border bg-surface p-7 shadow-card"
novalidate
@submit.prevent="onSave"
>
<FormGrid
:fields="fields"
:fields="panelFields"
:values="values"
:errors="errors"
:notices="notices"
:source="null"
:id-prefix="ID_PREFIX"
@update="update"