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:
@@ -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",
|
||||
|
||||
19
admin/src/api/schema.d.ts
vendored
19
admin/src/api/schema.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user