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:
@@ -2611,6 +2611,25 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.SettingsMeta": {
|
||||
"properties": {
|
||||
"notices": {
|
||||
"description": "Notices are what the seaglass sanitizer removed from richeditor\nvalues on this save.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.FieldNotice"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"secrets": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Secrets maps each password field to stored, env or empty.",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.SettingsResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -2619,6 +2638,9 @@
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SettingsMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -3647,6 +3669,7 @@
|
||||
},
|
||||
"/settings/{code}": {
|
||||
"get": {
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Settings code",
|
||||
@@ -3711,6 +3734,7 @@
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Settings code",
|
||||
|
||||
22
admin/src/api/schema.d.ts
vendored
22
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// application names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import PasswordField from '../../src/components/form/fields/PasswordField.vue'
|
||||
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
|
||||
import { FORM_SESSION, SETTINGS_SECRETS, type FormSession } from '../../src/components/form/formContext'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
@@ -114,3 +114,92 @@ describe('password field component (UI-SPEC S7, D-19)', () => {
|
||||
expect(input(plain.wrapper).attributes('required')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings secret states (UI-SPEC F2, D-13)', () => {
|
||||
const secretStrings = {
|
||||
'backend::lang.secret.stored': { other: 'A value is stored. Leave the field empty to keep it.' },
|
||||
'backend::lang.secret.env': { other: 'Nothing is stored here, so the value from the server configuration is used.' },
|
||||
'backend::lang.secret.empty': { other: 'Not set.' },
|
||||
'backend::lang.secret.clear': { other: 'Remove stored value' },
|
||||
'backend::lang.secret.clear_pending': { other: 'The stored value will be removed when you save.' },
|
||||
'backend::lang.secret.undo': { other: 'Keep stored value' },
|
||||
'backend::lang.secret.placeholder_stored': { other: 'Enter a new value to replace it' },
|
||||
}
|
||||
|
||||
function mountSecret(state: string, modelValue: unknown = '') {
|
||||
const secrets = ref<Record<string, string>>({ api_key: state })
|
||||
const wrapper = mount(PasswordField, {
|
||||
props: { modelValue, controlId: 'f-api_key', field: { name: 'api_key', type: 'password', label: 'API key' } as FormField },
|
||||
global: { provide: { [SETTINGS_SECRETS as symbol]: computed(() => secrets.value) } },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, secrets }
|
||||
}
|
||||
|
||||
const line = (wrapper: VueWrapper) => wrapper.find('[data-secret-state]')
|
||||
|
||||
beforeEach(() => {
|
||||
setBundle({ ...langFixture.data, ...strings, ...secretStrings }, 'en')
|
||||
})
|
||||
|
||||
it('renders no status line on a record form (no state provided)', () => {
|
||||
const { wrapper } = mountField()
|
||||
expect(line(wrapper).exists()).toBe(false)
|
||||
expect(input(wrapper).attributes('aria-describedby')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders the stored, env and empty lines with the input always empty', () => {
|
||||
const stored = mountSecret('stored')
|
||||
expect(input(stored.wrapper).element.value).toBe('')
|
||||
expect(line(stored.wrapper).attributes('data-secret-state')).toBe('stored')
|
||||
expect(line(stored.wrapper).text()).toContain('A value is stored.')
|
||||
expect(line(stored.wrapper).find('[data-action="secret-clear"]').text()).toBe('Remove stored value')
|
||||
expect(input(stored.wrapper).attributes('placeholder')).toBe('Enter a new value to replace it')
|
||||
expect(input(stored.wrapper).attributes('aria-describedby')).toContain('f-api_key-secret')
|
||||
stored.wrapper.unmount()
|
||||
|
||||
const env = mountSecret('env')
|
||||
expect(line(env.wrapper).attributes('data-secret-state')).toBe('env')
|
||||
expect(line(env.wrapper).text()).toContain('server configuration')
|
||||
expect(line(env.wrapper).find('[data-action="secret-clear"]').exists()).toBe(false)
|
||||
env.wrapper.unmount()
|
||||
|
||||
const empty = mountSecret('empty')
|
||||
expect(line(empty.wrapper).attributes('data-secret-state')).toBe('empty')
|
||||
expect(line(empty.wrapper).text()).toBe('Not set.')
|
||||
expect(line(empty.wrapper).find('button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not emit for an untouched field, emits null on Remove stored value and "" on Undo', async () => {
|
||||
const { wrapper } = mountSecret('stored')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await line(wrapper).find('[data-action="secret-clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
|
||||
await wrapper.setProps({ modelValue: null })
|
||||
const pending = line(wrapper)
|
||||
expect(pending.attributes('data-secret-state')).toBe('clearing')
|
||||
expect(pending.attributes('role')).toBe('status')
|
||||
expect(pending.classes()).toContain('text-danger')
|
||||
expect(pending.text()).toContain('will be removed when you save')
|
||||
expect(input(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(input(wrapper).element.value).toBe('')
|
||||
await pending.find('[data-action="secret-undo"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[null], ['']])
|
||||
await wrapper.setProps({ modelValue: '' })
|
||||
expect(line(wrapper).attributes('data-secret-state')).toBe('stored')
|
||||
expect(input(wrapper).attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never shows the typed value after the save response replaces it', async () => {
|
||||
const { wrapper, secrets } = mountSecret('empty')
|
||||
await input(wrapper).setValue('sk_live_123')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['sk_live_123']])
|
||||
// The save answers: the value is gone from the model, the state is stored.
|
||||
await wrapper.setProps({ modelValue: undefined })
|
||||
secrets.value = { api_key: 'stored' }
|
||||
await nextTick()
|
||||
expect(input(wrapper).element.value).toBe('')
|
||||
expect(wrapper.html()).not.toContain('sk_live_123')
|
||||
expect(line(wrapper).attributes('data-secret-state')).toBe('stored')
|
||||
})
|
||||
})
|
||||
|
||||
152
admin/tests/views/SettingsFormView.test.ts
Normal file
152
admin/tests/views/SettingsFormView.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// The settings page with tabs and write-only secrets (Phase 18, UI-SPEC F2
|
||||
// to F4). Fixtures are neutral acme.demo.* data; no application names
|
||||
// appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { clone, langFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const PAGE = `${API}/settings/search`
|
||||
|
||||
const strings = {
|
||||
'backend::lang.form.tab_default': { other: 'General' },
|
||||
'backend::lang.form.tab_errors': { one: ':count error', other: ':count errors' },
|
||||
'backend::lang.secret.stored': { other: 'A value is stored.' },
|
||||
'backend::lang.secret.env': { other: 'From the server configuration.' },
|
||||
'backend::lang.secret.empty': { other: 'Not set.' },
|
||||
'backend::lang.secret.clear': { other: 'Remove stored value' },
|
||||
'backend::lang.secret.clear_pending': { other: 'Removed when you save.' },
|
||||
'backend::lang.secret.undo': { other: 'Keep stored value' },
|
||||
'backend::lang.settings.saved': { other: 'Settings saved' },
|
||||
}
|
||||
|
||||
function tabbedSchema() {
|
||||
const envelope = clone(settingsFixture.schema)
|
||||
envelope.data.fields = [
|
||||
{ name: 'use_engine', label: 'Use the search engine', type: 'switch' },
|
||||
{ name: 'engine_host', label: 'Engine host', type: 'text', tab: 'Connection' },
|
||||
{ name: 'api_key', label: 'API key', type: 'password', tab: 'Keys' },
|
||||
{ name: 'test_api_key', label: 'Test API key', type: 'password', tab: 'Keys' },
|
||||
] as typeof envelope.data.fields
|
||||
return envelope
|
||||
}
|
||||
|
||||
function valuesWith(secrets: Record<string, string>): Reply {
|
||||
const envelope = clone(settingsFixture.values)
|
||||
envelope.data.meta = { secrets }
|
||||
return { body: envelope }
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${API}/settings`]: { body: settingsFixture.list },
|
||||
[`GET ${PAGE}/schema`]: { body: tabbedSchema() },
|
||||
[`GET ${PAGE}`]: valuesWith({ api_key: 'stored', test_api_key: 'empty' }),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openPage(overrides: Record<string, Route> = {}) {
|
||||
setSettings(settingsFixture.list.data)
|
||||
return mountApp('/settings/search', routes(overrides))
|
||||
}
|
||||
|
||||
const tabLabels = (wrapper: VueWrapper) => wrapper.findAll('[role="tab"]').map((tab) => tab.text().replace(/\d+ errors?$/, '').trim())
|
||||
const activeTab = (wrapper: VueWrapper) => wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true')
|
||||
const fieldNames = (wrapper: VueWrapper) => wrapper.findAll('[data-field]').map((field) => field.attributes('data-field'))
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('settings tabs (UI-SPEC F4)', () => {
|
||||
it('renders no tab strip for an untabbed schema', async () => {
|
||||
const { wrapper } = await openPage({ [`GET ${PAGE}/schema`]: { body: settingsFixture.schema } })
|
||||
expect(wrapper.find('[role="tablist"]').exists()).toBe(false)
|
||||
expect(wrapper.find('form').attributes('role')).toBeUndefined()
|
||||
expect(fieldNames(wrapper)).toEqual(['use_engine', 'engine_host'])
|
||||
})
|
||||
|
||||
it('renders the tabs with the default tab first and the card inside the panel', async () => {
|
||||
const { wrapper } = await openPage()
|
||||
expect(tabLabels(wrapper)).toEqual(['General', 'Connection', 'Keys'])
|
||||
expect(activeTab(wrapper)!.text()).toBe('General')
|
||||
expect(fieldNames(wrapper)).toEqual(['use_engine'])
|
||||
expect(wrapper.find('form').attributes('role')).toBe('tabpanel')
|
||||
expect(wrapper.find('form').attributes('aria-labelledby')).toBe(activeTab(wrapper)!.attributes('id'))
|
||||
|
||||
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
|
||||
expect(fieldNames(wrapper)).toEqual(['api_key', 'test_api_key'])
|
||||
})
|
||||
|
||||
it('activates the first tab with an error after a 422 and shows its error chip', async () => {
|
||||
const { wrapper } = await openPage({
|
||||
[`PUT ${PAGE}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { engine_host: ['Bad host'] } } } },
|
||||
})
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(activeTab(wrapper)!.text()).toContain('Connection')
|
||||
expect(activeTab(wrapper)!.find('[data-tab-errors]').text()).toBe('1')
|
||||
expect(fieldNames(wrapper)).toEqual(['engine_host'])
|
||||
expect(wrapper.find('[data-field="engine_host"]').text()).toContain('Bad host')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings secrets (UI-SPEC F2, D-13)', () => {
|
||||
it('shows each secret state and sends neither an untouched secret nor its empty input', async () => {
|
||||
const { wrapper, calls } = await openPage({ [`PUT ${PAGE}`]: valuesWith({ api_key: 'stored', test_api_key: 'empty' }) })
|
||||
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
|
||||
expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('stored')
|
||||
expect(wrapper.find('[data-field="test_api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('empty')
|
||||
await wrapper.find('[data-field="test_api_key"] input').setValue('')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
const sent = requestsTo(calls, 'PUT', '/admin-test/api/v1/settings/search')
|
||||
expect(sent).toHaveLength(1)
|
||||
const body = (await sent[0]!.clone().json()) as Record<string, unknown>
|
||||
expect(body).not.toHaveProperty('api_key')
|
||||
expect(body).not.toHaveProperty('test_api_key')
|
||||
expect(body).toHaveProperty('engine_host')
|
||||
})
|
||||
|
||||
it('sends a typed secret once, then clears the input and shows the stored line from the response', async () => {
|
||||
const { wrapper, calls } = await openPage({ [`PUT ${PAGE}`]: valuesWith({ api_key: 'stored', test_api_key: 'stored' }) })
|
||||
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
|
||||
await wrapper.find('[data-field="test_api_key"] input').setValue('sk_test_9')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
const body = (await requestsTo(calls, 'PUT', '/admin-test/api/v1/settings/search')[0]!.clone().json()) as Record<string, unknown>
|
||||
expect(body.test_api_key).toBe('sk_test_9')
|
||||
expect(wrapper.find<HTMLInputElement>('[data-field="test_api_key"] input').element.value).toBe('')
|
||||
expect(wrapper.find('[data-field="test_api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('stored')
|
||||
expect(wrapper.html()).not.toContain('sk_test_9')
|
||||
expect(document.body.textContent).not.toContain('sk_test_9')
|
||||
})
|
||||
|
||||
it('sends null for a removed stored value and restores the line on Undo', async () => {
|
||||
const { wrapper, calls } = await openPage({ [`PUT ${PAGE}`]: valuesWith({ api_key: 'empty', test_api_key: 'empty' }) })
|
||||
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
|
||||
await wrapper.find('[data-field="api_key"] [data-action="secret-clear"]').trigger('click')
|
||||
const line = wrapper.find('[data-field="api_key"] [data-secret-state]')
|
||||
expect(line.attributes('data-secret-state')).toBe('clearing')
|
||||
expect(wrapper.find('[data-field="api_key"] input').attributes('disabled')).toBeDefined()
|
||||
await line.find('[data-action="secret-undo"]').trigger('click')
|
||||
expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('stored')
|
||||
|
||||
await wrapper.find('[data-field="api_key"] [data-action="secret-clear"]').trigger('click')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
const body = (await requestsTo(calls, 'PUT', '/admin-test/api/v1/settings/search')[0]!.clone().json()) as Record<string, unknown>
|
||||
expect(body.api_key).toBeNull()
|
||||
expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('empty')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user