- 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
123 lines
5.1 KiB
Vue
123 lines
5.1 KiB
Vue
<script setup lang="ts">
|
|
import { computed, inject, ref, watch } from '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, 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 | null] }>()
|
|
|
|
const shown = ref(false)
|
|
const text = computed(() => (typeof props.modelValue === 'string' ? props.modelValue : ''))
|
|
const attrs = computed(() => controlAttributes(props.field))
|
|
|
|
const session = inject(FORM_SESSION, null)
|
|
if (session) {
|
|
watch(session.revision, () => {
|
|
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="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]"
|
|
>
|
|
<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>
|