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

@@ -2301,6 +2301,25 @@
],
"type": "object"
},
"cabana.PermissionRecordLock": {
"properties": {
"code": {
"type": "string"
},
"note": {
"type": "string"
},
"value": {
"type": "integer"
}
},
"required": [
"code",
"note",
"value"
],
"type": "object"
},
"cabana.RecordAction": {
"properties": {
"confirm": {
@@ -2361,6 +2380,16 @@
},
"type": "array"
},
"permissionLocks": {
"additionalProperties": {
"items": {
"$ref": "#/components/schemas/cabana.PermissionRecordLock"
},
"type": "array"
},
"description": "PermissionLocks are, per permissioneditor field, the permissions this\nrecord fixes (the controller's PermissionRecordLockProvider), with the\nvalue to show and the note, translated in the request locale. The show\nand save responses carry it; it is omitted when no record lock applies\nand is never part of the form schema.",
"type": "object"
},
"title": {
"description": "Title is the controller's record title (pact.FormRecordTitle), omitted\nwhen the controller names none.",
"type": "string"
@@ -5936,7 +5965,7 @@
]
},
"get": {
"description": "meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered.",
"description": "meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered. meta.permissionLocks maps a `type: permissioneditor` field to the permissions this record fixes (the controller's cabana.PermissionRecordLockProvider), each with code, value (1, -1, or 0 for inherit) and note (localized): the admin shows that value and the note on the row and never sends the code; it is absent when no record lock applies.",
"parameters": [
{
"description": "Vendor",
@@ -6028,7 +6057,7 @@
]
},
"put": {
"description": "A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.",
"description": "A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise. A record-locked permission (meta.permissionLocks) keeps its stored value when its code is absent from the body; a present value that differs from the stored one is a 403 on the field. The response carries meta.permissionLocks like the show route.",
"parameters": [
{
"description": "Vendor",

View File

@@ -2666,7 +2666,7 @@ export interface paths {
};
/**
* Show an admin record
* @description meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered.
* @description meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered. meta.permissionLocks maps a `type: permissioneditor` field to the permissions this record fixes (the controller's cabana.PermissionRecordLockProvider), each with code, value (1, -1, or 0 for inherit) and note (localized): the admin shows that value and the note on the row and never sends the code; it is absent when no record lock applies.
*/
get: {
parameters: {
@@ -2726,7 +2726,7 @@ export interface paths {
};
/**
* Update an admin record
* @description A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
* @description A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise. A record-locked permission (meta.permissionLocks) keeps its stored value when its code is absent from the body; a present value that differs from the stored one is a 403 on the field. The response carries meta.permissionLocks like the show route.
*/
put: {
parameters: {
@@ -6138,6 +6138,11 @@ export interface components {
locked?: boolean;
tab?: string;
};
"cabana.PermissionRecordLock": {
code: string;
note: string;
value: number;
};
"cabana.RecordAction": {
confirm?: string;
/**
@@ -6158,6 +6163,16 @@ export interface components {
[key: string]: components["schemas"]["cabana.RelationOption"][];
};
notices?: components["schemas"]["cabana.FieldNotice"][];
/**
* @description PermissionLocks are, per permissioneditor field, the permissions this
* record fixes (the controller's PermissionRecordLockProvider), with the
* value to show and the note, translated in the request locale. The show
* and save responses carry it; it is omitted when no record lock applies
* and is never part of the form schema.
*/
permissionLocks?: {
[key: string]: components["schemas"]["cabana.PermissionRecordLock"][];
};
/**
* @description Title is the controller's record title (pact.FormRecordTitle), omitted
* when the controller names none.

View File

@@ -41,6 +41,8 @@ export type FormRedirects = Schemas['cabana.FormRedirects']
export type FormPreview = Schemas['cabana.FormPreview']
export type FieldPreset = Schemas['cabana.FieldPreset']
export type PermissionOption = Schemas['cabana.PermissionOption']
/** A permission a stored record fixes (meta.permissionLocks): the value and note to show. */
export type PermissionRecordLock = Schemas['cabana.PermissionRecordLock']
export type RelationSchema = Schemas['cabana.RelationSchema']
export type RelationMessages = Schemas['cabana.RelationMessages']
export type RelationMutationResult = Schemas['cabana.RelationMutationResult']

View File

@@ -72,15 +72,24 @@ export function toggleValue(current: unknown, on: boolean): boolean | number {
* A permissioneditor value as it may be sent (UI-SPEC S5): only codes the
* field's options offer, with a value of the mode's set. Radio keeps 1
* (allow) and -1 (deny) and leaves an inherited code out; checkbox keeps 1.
* A stored code that is not offered is never sent: the server keeps it.
* A stored code that is not offered is never sent: the server keeps it. A
* code in `locked` (the record's permission locks, D-21) is never sent
* either: the server keeps its stored value.
*/
export function permissionValues(field: FormField, value: unknown): Record<string, number> {
export function permissionValues(
field: FormField,
value: unknown,
locked?: ReadonlySet<string>,
): Record<string, number> {
const out: Record<string, number> = {}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return out
}
const stored = value as Record<string, unknown>
for (const option of field.permissionOptions ?? []) {
if (locked?.has(option.code)) {
continue
}
const n = Number(stored[option.code])
if (n === 1 || (field.mode === 'radio' && n === -1)) {
out[option.code] = n

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { CheckboxIndicator, CheckboxRoot, RadioGroupItem, RadioGroupRoot } from 'reka-ui'
import { Check, Lock } from '@lucide/vue'
import type { PermissionOption } from '../../../api/types'
import type { PermissionOption, PermissionRecordLock } from '../../../api/types'
import { t } from '../../../app/i18n'
import { controlAttributes, permissionValues, type FieldControlProps } from '../control'
import { FORM_PERMISSION_LOCKS } from '../formContext'
// Permission editor (UI-SPEC S5, D-16). The options come with the form
// schema, already filtered and marked for the signed-in administrator. They
@@ -13,10 +14,21 @@ import { controlAttributes, permissionValues, type FieldControlProps } from '../
// permission; checkbox mode edits allow (1). The value is an object of code
// to integer holding only offered codes: a stored code that is not offered is
// kept by the server and never sent. A locked row shows its stored value and
// cannot change; the server refuses a changed locked code anyway.
// cannot change; the server refuses a changed locked code anyway. A row the
// record fixes (meta.permissionLocks, D-21) shows the lock's value and note
// instead, wins over a request lock, and is never sent.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, number>] }>()
const allLocks = inject(FORM_PERMISSION_LOCKS, null)
const recordLocks = computed(() => {
const out = new Map<string, PermissionRecordLock>()
for (const lock of allLocks?.value[props.field.name] ?? []) {
out.set(lock.code, lock)
}
return out
})
interface Section {
key: string
label: string
@@ -75,16 +87,29 @@ function rowId(sectionIndex: number, optionIndex: number): string {
return `${props.controlId}-${sectionIndex}-${optionIndex}`
}
function recordLock(option: PermissionOption): PermissionRecordLock | undefined {
return recordLocks.value.get(option.code)
}
function disabled(option: PermissionOption): boolean {
return readOnly.value || !!option.locked
return readOnly.value || !!option.locked || recordLock(option) !== undefined
}
/** Whether the row shows the lock icon and text: never on a read-only screen. */
function showsLock(option: PermissionOption): boolean {
return !!option.locked && !readOnly.value
return (!!option.locked || recordLock(option) !== undefined) && !readOnly.value
}
/** The lock note of a row: the record lock's note wins over the request lock's. */
function lockNote(option: PermissionOption): string {
return recordLock(option)?.note || t('backend::lang.permissioneditor.locked')
}
function valueOf(option: PermissionOption): number {
const lock = recordLock(option)
if (lock) {
return lock.value === 1 || (radio.value && lock.value === -1) ? lock.value : 0
}
return current.value[option.code] ?? 0
}
@@ -145,6 +170,7 @@ function set(option: PermissionOption, value: number): void {
:key="option.code"
:data-permission="option.code"
:data-locked="showsLock(option) ? '' : undefined"
:data-locked-by="showsLock(option) && recordLock(option) ? 'record' : undefined"
class="flex min-h-[56px] items-center justify-between gap-x-4 gap-y-2 border-t border-border px-4 py-3 max-sm:flex-col max-sm:items-start"
>
<div class="flex min-w-0 flex-col">
@@ -163,7 +189,7 @@ function set(option: PermissionOption, value: number): void {
class="text-[13px] text-muted [overflow-wrap:anywhere]"
data-permission-locked
>
{{ t('backend::lang.permissioneditor.locked') }}
{{ lockNote(option) }}
</p>
<p
v-else-if="option.comment"

View File

@@ -5,7 +5,7 @@
// provider (settings pages) gets the defaults of the injecting control.
import { ref, type InjectionKey, type Ref } from 'vue'
import type { FileRoutes } from '../../api/files'
import type { AdminDependentOptionsResult, AdminRecord, FormField } from '../../api/types'
import type { AdminDependentOptionsResult, AdminRecord, FormField, PermissionRecordLock } from '../../api/types'
/** Read-only view of the form's current values. */
export const FORM_VALUES: InjectionKey<Readonly<Ref<AdminRecord>>> = Symbol('summer.form.values')
@@ -90,6 +90,15 @@ export const FORM_SESSION: InjectionKey<FormSession> = Symbol('summer.form.sessi
*/
export const SETTINGS_SECRETS: InjectionKey<Readonly<Ref<Record<string, string>>>> = Symbol('summer.settings.secrets')
/**
* The record's permission locks (meta.permissionLocks, D-21): per
* permissioneditor field name, the codes the stored record fixes with the
* value and note to show. FormView and PreviewView provide it from the record
* response; a control without a provider sees no record lock.
*/
export const FORM_PERMISSION_LOCKS: InjectionKey<Readonly<Ref<Record<string, PermissionRecordLock[]>>>> =
Symbol('summer.form.permissionLocks')
/** Event a widget element dispatches to run its action (bubbles, composed). */
export const WIDGET_EVENT = 'summer-action'

View File

@@ -1,6 +1,6 @@
// Helpers shared by the record form and the settings form: context
// filtering, the save payload, 422 detail mapping and focus.
import type { AdminRecord, ErrorBody, FieldNotice, FormField } from '../../api/types'
import type { AdminRecord, ErrorBody, FieldNotice, FormField, PermissionRecordLock } from '../../api/types'
import { permissionValues } from './control'
import { isMLField, localeRecord } from './mlLocale'
import { isRegisteredField } from './registry'
@@ -48,9 +48,16 @@ export function contextAllows(field: FormField, mode: FormMode): boolean {
* The save body: values keyed by field name for every editable field that
* has a value. Read-only fields and types without a renderer are never sent.
* An empty password on update means "unchanged" and is left out; on create it
* is sent as entered and the server's rules decide (UI-SPEC S7).
* is sent as entered and the server's rules decide (UI-SPEC S7). `locks` are
* the record's permission locks (meta.permissionLocks): their codes are left
* out of each permissioneditor value.
*/
export function editablePayload(fields: FormField[], values: AdminRecord, mode: FormMode = 'create'): AdminRecord {
export function editablePayload(
fields: FormField[],
values: AdminRecord,
mode: FormMode = 'create',
locks?: Readonly<Record<string, readonly PermissionRecordLock[]>>,
): AdminRecord {
const out: AdminRecord = {}
for (const field of fields) {
if (field.readOnly || !isRegisteredField(field)) {
@@ -62,7 +69,8 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode:
}
if (field.type === 'permissioneditor') {
// Sent whenever it is shown, reduced to offered codes and values.
out[field.name] = permissionValues(field, value)
const locked = locks?.[field.name]
out[field.name] = permissionValues(field, value, locked ? new Set(locked.map((lock) => lock.code)) : undefined)
continue
}
if (isMLField(field)) {

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,