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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user