diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index f1f0bc98..b84d472c 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -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", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 4478fc4e..8d114b10 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -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. diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index bddefcef..c9cc0be6 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -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'] diff --git a/admin/src/components/form/control.ts b/admin/src/components/form/control.ts index bd92f616..6fee7280 100644 --- a/admin/src/components/form/control.ts +++ b/admin/src/components/form/control.ts @@ -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 { +export function permissionValues( + field: FormField, + value: unknown, + locked?: ReadonlySet, +): Record { const out: Record = {} if (value === null || typeof value !== 'object' || Array.isArray(value)) { return out } const stored = value as Record 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 diff --git a/admin/src/components/form/fields/PermissionEditorField.vue b/admin/src/components/form/fields/PermissionEditorField.vue index 60673661..1b2cac4d 100644 --- a/admin/src/components/form/fields/PermissionEditorField.vue +++ b/admin/src/components/form/fields/PermissionEditorField.vue @@ -1,10 +1,11 @@