feat(19.6.1-02): per-record locked permission values

- cabana.PermissionRecordLock and the optional PermissionRecordLockProvider
- RecordMeta.PermissionLocks (meta.permissionLocks) on show and save responses, notes localized
- save rule: a record-locked code keeps its stored value, a differing present value is 403
- Roles lock code-declared grants (locked_by_code); Administrators lock every override on your own record (locked_self)
- SPA: FORM_PERMISSION_LOCKS, locked codes never sent, record-locked rows in the permission editor
- regenerated admin OpenAPI and schema.d.ts; docs and README
This commit is contained in:
Jakub Zych
2026-10-22 11:29:25 +02:00
parent e2525d4956
commit 705f94d9e5
22 changed files with 588 additions and 60 deletions

View File

@@ -20,6 +20,7 @@ import {
FORM_FEEDBACK,
FORM_LOCALE,
FORM_PATCH,
FORM_PERMISSION_LOCKS,
FORM_SESSION,
FORM_VALUES,
createUploadGate,
@@ -78,6 +79,9 @@ const recordId = mode === 'update' ? Number(route.params.id) : null
const schema = ref<FormView | null>(null)
const values = ref<AdminRecord>({})
const labels = ref<RecordMeta['labels']>({})
// The record's permission locks (meta.permissionLocks, D-21): codes the
// stored record fixes; they are shown locked and never sent.
const permissionLocks = ref<NonNullable<RecordMeta['permissionLocks']>>({})
/** The controller's record title (meta.title, pact.FormRecordTitle); '' when it names none. */
const recordTitle = ref('')
const errors = ref<Record<string, string[]>>({})
@@ -205,7 +209,7 @@ const dirty = computed(
!loading.value &&
(pendingChanges.value > 0 ||
activeUploads.value > 0 ||
snapshot(editablePayload(fields.value, values.value, mode)) !== saved.value),
snapshot(editablePayload(fields.value, values.value, mode, permissionLocks.value)) !== saved.value),
)
function adopt(record: RecordEnvelope | undefined): void {
@@ -221,9 +225,10 @@ function adopt(record: RecordEnvelope | undefined): void {
}
values.value = next
labels.value = record.meta.labels ?? {}
permissionLocks.value = record.meta.permissionLocks ?? {}
recordTitle.value = record.meta.title ?? ''
}
saved.value = snapshot(editablePayload(fields.value, values.value, mode))
saved.value = snapshot(editablePayload(fields.value, values.value, mode, permissionLocks.value))
}
async function load(): Promise<void> {
@@ -316,6 +321,7 @@ provide(FORM_PATCH, update)
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
provide(FORM_ENABLED_LOCALES, computed(() => schemaEnabledLocales(schema.value?.meta)))
provide(FORM_ASSETS, () => assetsReady)
provide(FORM_PERMISSION_LOCKS, computed(() => permissionLocks.value))
// The form's single polite live region (dependent dropdown clears, repeater
// moves) and the feedback nested controls read: errors and notices by row
// path, the confirm dialog (Phase 17, D-07, D-08).
@@ -395,7 +401,7 @@ async function save(): Promise<RecordEnvelope | null> {
forbidden.value = null
notices.value = {}
try {
const body = editablePayload(fields.value, values.value, mode)
const body = editablePayload(fields.value, values.value, mode, permissionLocks.value)
const result =
recordId === null
? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path, header: sessionHeader }, body })

View File

@@ -9,7 +9,7 @@ import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { message, t } from '../app/i18n'
import { loadControllerAssets } from '../app/pluginAssets'
import { newSessionKey } from '../app/sessionKey'
import { FIELD_SCOPE, FORM_SESSION, createUploadGate } from '../components/form/formContext'
import { FIELD_SCOPE, FORM_PERMISSION_LOCKS, FORM_SESSION, createUploadGate } from '../components/form/formContext'
import FormTabs from '../components/form/FormTabs.vue'
import PreviewField from '../components/form/PreviewField.vue'
import RecordActions from '../components/form/RecordActions.vue'
@@ -54,6 +54,9 @@ const recordPath = `${listPath}/${recordId}`
const schema = ref<FormView | null>(null)
const values = ref<AdminRecord>({})
const labels = ref<RecordMeta['labels']>({})
// The record's permission locks (meta.permissionLocks): the preview shows
// their values, without icon or note (read-only screen).
const permissionLocks = ref<NonNullable<RecordMeta['permissionLocks']>>({})
/** The controller's record title (meta.title, pact.FormRecordTitle); '' when it names none. */
const recordTitle = ref('')
const actions = ref<RecordAction[]>([])
@@ -150,6 +153,7 @@ async function loadRecord(): Promise<boolean> {
}
values.value = { ...result.data.data }
labels.value = result.data.meta.labels ?? {}
permissionLocks.value = result.data.meta.permissionLocks ?? {}
recordTitle.value = result.data.meta.title ?? ''
actions.value = result.data.meta.actions ?? []
return true
@@ -212,6 +216,7 @@ provide(FIELD_SCOPE, {
values: computed(() => values.value),
path: (name: string) => name,
})
provide(FORM_PERMISSION_LOCKS, computed(() => permissionLocks.value))
provide(FORM_SESSION, {
key: sessionKey,
recordId,