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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user