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

@@ -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)) {