feat(12.1-02): permissioneditor field in radio or checkbox mode

- type: permissioneditor with mode radio (1, -1) or checkbox (1); the
  controller serves the options per request through
  cabana.PermissionEditorProvider and reads and stores the values
- a save answers 422 for a non-object, an unknown code or a value outside the
  mode's set and 403 for a changed locked code; stored codes that are not
  offered are kept
- record responses carry the stored permissions as an object
- SPA: PermissionEditorField with sections by tab, locked rows and a read-only
  mode for the preview
- README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
Jakub Zych
2026-10-05 10:44:50 +02:00
parent a1c6bb1ce6
commit f50d9b8f10
38 changed files with 1300 additions and 34 deletions

View File

@@ -765,7 +765,7 @@
"type": "string" "type": "string"
}, },
"mode": { "mode": {
"description": "Mode is the fileupload mode (image or file, default file) or the\ndatepicker mode (date, datetime or time, default datetime).", "description": "Mode is the fileupload mode (image or file, default file), the\ndatepicker mode (date, datetime or time, default datetime) or the\npermissioneditor mode (radio or checkbox).",
"type": "string" "type": "string"
}, },
"multiple": { "multiple": {
@@ -787,6 +787,13 @@
"description": "Path names the controller partial of a `type: partial` field: the\ntemplate {ConfigDir}/_{path}.htm (D-09).", "description": "Path names the controller partial of a `type: partial` field: the\ntemplate {ConfigDir}/_{path}.htm (D-09).",
"type": "string" "type": "string"
}, },
"permissionOptions": {
"description": "PermissionOptions are the permissions a `type: permissioneditor` field\noffers the requesting administrator, in display order. They are filled\nper request by the form schema route.",
"items": {
"$ref": "#/components/schemas/cabana.PermissionOption"
},
"type": "array"
},
"preset": { "preset": {
"allOf": [ "allOf": [
{ {
@@ -1454,6 +1461,30 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.PermissionOption": {
"properties": {
"code": {
"type": "string"
},
"comment": {
"type": "string"
},
"label": {
"type": "string"
},
"locked": {
"type": "boolean"
},
"tab": {
"type": "string"
}
},
"required": [
"code",
"label"
],
"type": "object"
},
"cabana.RecordAction": { "cabana.RecordAction": {
"properties": { "properties": {
"confirm": { "confirm": {
@@ -3314,7 +3345,7 @@
}, },
"/{vendor}/{plugin}/{controller}/schema/form": { "/{vendor}/{plugin}/{controller}/schema/form": {
"get": { "get": {
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it.", "description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).",
"parameters": [ "parameters": [
{ {
"description": "Vendor", "description": "Vendor",

View File

@@ -1216,7 +1216,7 @@ export interface paths {
}; };
/** /**
* Admin form schema * Admin form schema
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. * @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).
*/ */
get: { get: {
parameters: { parameters: {
@@ -4434,8 +4434,9 @@ export interface components {
*/ */
minDate?: string; minDate?: string;
/** /**
* @description Mode is the fileupload mode (image or file, default file) or the * @description Mode is the fileupload mode (image or file, default file), the
* datepicker mode (date, datetime or time, default datetime). * datepicker mode (date, datetime or time, default datetime) or the
* permissioneditor mode (radio or checkbox).
*/ */
mode?: string; mode?: string;
multiple?: boolean; multiple?: boolean;
@@ -4447,6 +4448,12 @@ export interface components {
* template {ConfigDir}/_{path}.htm (D-09). * template {ConfigDir}/_{path}.htm (D-09).
*/ */
path?: string; path?: string;
/**
* @description PermissionOptions are the permissions a `type: permissioneditor` field
* offers the requesting administrator, in display order. They are filled
* per request by the form schema route.
*/
permissionOptions?: components["schemas"]["cabana.PermissionOption"][];
/** /**
* @description Preset makes a text field follow another field of the form while the * @description Preset makes a text field follow another field of the form while the
* administrator has not edited it, on create only (fields.yaml preset). * administrator has not edited it, on create only (fields.yaml preset).
@@ -4652,6 +4659,13 @@ export interface components {
"cabana.PartialView": { "cabana.PartialView": {
nodes: components["schemas"]["cabana.PartialNode"][]; nodes: components["schemas"]["cabana.PartialNode"][];
}; };
"cabana.PermissionOption": {
code: string;
comment?: string;
label: string;
locked?: boolean;
tab?: string;
};
"cabana.RecordAction": { "cabana.RecordAction": {
confirm?: string; confirm?: string;
label: string; label: string;

View File

@@ -27,6 +27,7 @@ export type FormRedirects = Schemas['cabana.FormRedirects']
/** A form's preview screen: present when config_form.yaml declares `preview:`. */ /** A form's preview screen: present when config_form.yaml declares `preview:`. */
export type FormPreview = Schemas['cabana.FormPreview'] export type FormPreview = Schemas['cabana.FormPreview']
export type FieldPreset = Schemas['cabana.FieldPreset'] export type FieldPreset = Schemas['cabana.FieldPreset']
export type PermissionOption = Schemas['cabana.PermissionOption']
export type RelationSchema = Schemas['cabana.RelationSchema'] export type RelationSchema = Schemas['cabana.RelationSchema']
export type RelationMessages = Schemas['cabana.RelationMessages'] export type RelationMessages = Schemas['cabana.RelationMessages']
export type RelationMutationResult = Schemas['cabana.RelationMutationResult'] export type RelationMutationResult = Schemas['cabana.RelationMutationResult']

View File

@@ -67,3 +67,24 @@ export function toggleOn(value: unknown): boolean {
export function toggleValue(current: unknown, on: boolean): boolean | number { export function toggleValue(current: unknown, on: boolean): boolean | number {
return typeof current === 'number' ? (on ? 1 : 0) : on return typeof current === 'number' ? (on ? 1 : 0) : on
} }
/**
* 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.
*/
export function permissionValues(field: FormField, value: unknown): 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 ?? []) {
const n = Number(stored[option.code])
if (n === 1 || (field.mode === 'radio' && n === -1)) {
out[option.code] = n
}
}
return out
}

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckboxIndicator, CheckboxRoot, RadioGroupItem, RadioGroupRoot } from 'reka-ui'
import { Check, Lock } from '@lucide/vue'
import type { PermissionOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import { controlAttributes, permissionValues, type FieldControlProps } from '../control'
// Permission editor (UI-SPEC S5, D-16). The options come with the form
// schema, already filtered and marked for the signed-in administrator. They
// are grouped by `tab` into sections of one list; there is no inner tablist.
// Radio mode edits allow (1), inherit (no value) and deny (-1) per
// 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.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, number>] }>()
interface Section {
key: string
label: string
options: PermissionOption[]
}
const options = computed<PermissionOption[]>(() => props.field.permissionOptions ?? [])
const radio = computed(() => props.field.mode === 'radio')
const readOnly = computed(() => !!props.field.readOnly || !!controlAttributes(props.field).readonly)
const current = computed(() => permissionValues(props.field, props.modelValue))
// Sections keep the order the server sends; options without a tab form a
// last section.
const sections = computed<Section[]>(() => {
const out: Section[] = []
const untabbed: PermissionOption[] = []
for (const option of options.value) {
if (!option.tab) {
untabbed.push(option)
continue
}
let section = out.find((item) => item.label === option.tab)
if (!section) {
section = { key: `tab:${option.tab}`, label: option.tab, options: [] }
out.push(section)
}
section.options.push(option)
}
if (untabbed.length > 0) {
out.push({ key: 'other', label: t('backend::lang.permissioneditor.other'), options: untabbed })
}
return out
})
const segments = [
{
value: '1',
label: 'backend::lang.permissioneditor.allow',
checked: 'data-[state=checked]:bg-ok-bg data-[state=checked]:text-ok-text',
},
{
value: '0',
label: 'backend::lang.permissioneditor.inherit',
checked: 'data-[state=checked]:bg-surface data-[state=checked]:text-text data-[state=checked]:shadow-tab',
},
{
value: '-1',
label: 'backend::lang.permissioneditor.deny',
checked: 'data-[state=checked]:bg-danger-soft data-[state=checked]:text-danger',
},
]
const labelledBy = computed(() => `${props.controlId}-label`)
function rowId(sectionIndex: number, optionIndex: number): string {
return `${props.controlId}-${sectionIndex}-${optionIndex}`
}
function disabled(option: PermissionOption): boolean {
return readOnly.value || !!option.locked
}
/** 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
}
function valueOf(option: PermissionOption): number {
return current.value[option.code] ?? 0
}
function set(option: PermissionOption, value: number): void {
if (disabled(option)) {
return
}
const next = { ...current.value }
if (value === 1 || (radio.value && value === -1)) {
next[option.code] = value
} else {
delete next[option.code]
}
emit('update:modelValue', next)
}
</script>
<template>
<div
v-if="options.length === 0"
:id="controlId"
data-permission-empty
class="flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5 text-muted"
>
{{ t('backend::lang.permissioneditor.empty') }}
</div>
<div
v-else
:id="controlId"
role="group"
:aria-labelledby="field.label ? labelledBy : undefined"
:aria-label="field.label ? undefined : field.name"
:aria-describedby="describedBy || undefined"
:class="invalid ? 'border-danger' : 'border-border'"
class="overflow-hidden rounded-inner border"
data-permission-editor
:data-mode="radio ? 'radio' : 'checkbox'"
>
<div
v-for="(section, sectionIndex) in sections"
:key="section.key"
role="group"
:aria-labelledby="`${controlId}-section-${sectionIndex}`"
data-permission-section
>
<h3
:id="`${controlId}-section-${sectionIndex}`"
:class="sectionIndex > 0 ? 'border-t border-border' : ''"
class="flex h-row-head items-center justify-between gap-4 bg-subtle px-4 font-semibold"
>
<span class="min-w-0 [overflow-wrap:anywhere]">{{ section.label }}</span>
<span v-if="!radio" class="shrink-0 text-[12px] font-semibold text-muted" aria-hidden="true">
{{ t('backend::lang.permissioneditor.allow') }}
</span>
</h3>
<div
v-for="(option, optionIndex) in section.options"
:key="option.code"
:data-permission="option.code"
:data-locked="showsLock(option) ? '' : 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">
<component
:is="radio ? 'span' : 'label'"
:id="`${rowId(sectionIndex, optionIndex)}-label`"
:for="radio ? undefined : rowId(sectionIndex, optionIndex)"
class="[overflow-wrap:anywhere]"
>
{{ option.label || option.code }}
<Lock v-if="showsLock(option)" :size="14" class="ml-1 inline-block align-[-2px] text-muted" aria-hidden="true" />
</component>
<p
v-if="showsLock(option)"
:id="`${rowId(sectionIndex, optionIndex)}-note`"
class="text-[13px] text-muted [overflow-wrap:anywhere]"
data-permission-locked
>
{{ t('backend::lang.permissioneditor.locked') }}
</p>
<p
v-else-if="option.comment"
:id="`${rowId(sectionIndex, optionIndex)}-note`"
class="text-[13px] text-muted [overflow-wrap:anywhere]"
>
{{ option.comment }}
</p>
</div>
<RadioGroupRoot
v-if="radio"
orientation="horizontal"
:model-value="String(valueOf(option))"
:disabled="disabled(option)"
:aria-disabled="disabled(option) ? 'true' : undefined"
:aria-labelledby="`${rowId(sectionIndex, optionIndex)}-label`"
:aria-describedby="showsLock(option) || option.comment ? `${rowId(sectionIndex, optionIndex)}-note` : undefined"
class="inline-flex shrink-0 gap-1 rounded-inner border border-border bg-subtle p-1"
@update:model-value="(value) => set(option, Number(value))"
>
<RadioGroupItem
v-for="segment in segments"
:key="segment.value"
:value="segment.value"
:data-segment="segment.value"
:class="segment.checked"
class="inline-flex h-pager items-center rounded-tab px-4 text-[13px] whitespace-nowrap text-muted transition-colors duration-150 ease-out hover:text-text disabled:cursor-not-allowed disabled:hover:text-muted data-[state=checked]:text-[14px] data-[state=checked]:font-semibold"
>
{{ t(segment.label) }}
</RadioGroupItem>
</RadioGroupRoot>
<CheckboxRoot
v-else
:id="rowId(sectionIndex, optionIndex)"
:model-value="valueOf(option) === 1"
:disabled="disabled(option)"
:aria-disabled="disabled(option) ? 'true' : undefined"
:aria-labelledby="`${rowId(sectionIndex, optionIndex)}-label`"
:aria-describedby="showsLock(option) || option.comment ? `${rowId(sectionIndex, optionIndex)}-note` : undefined"
class="flex size-[18px] shrink-0 items-center justify-center rounded-checkbox border border-border-strong bg-surface disabled:cursor-not-allowed disabled:opacity-60 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-on-primary"
@update:model-value="(value) => set(option, value === true ? 1 : 0)"
>
<CheckboxIndicator>
<Check :size="14" aria-hidden="true" />
</CheckboxIndicator>
</CheckboxRoot>
</div>
</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
// Helpers shared by the record form and the settings form: context // Helpers shared by the record form and the settings form: context
// filtering, the save payload, 422 detail mapping and focus. // filtering, the save payload, 422 detail mapping and focus.
import type { AdminRecord, ErrorBody, FormField } from '../../api/types' import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
import { permissionValues } from './control'
import { isRegistered } from './registry' import { isRegistered } from './registry'
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */ /** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
@@ -56,6 +57,11 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode:
if (field.type === 'password' && mode === 'update' && (value === '' || value === null)) { if (field.type === 'password' && mode === 'update' && (value === '' || value === null)) {
continue continue
} }
if (field.type === 'permissioneditor') {
// Sent whenever it is shown, reduced to offered codes and values.
out[field.name] = permissionValues(field, value)
continue
}
if (value !== undefined) { if (value !== undefined) {
out[field.name] = value out[field.name] = value
} }

View File

@@ -11,7 +11,9 @@
// value either and renders on create and update. The datepicker control is // value either and renders on create and update. The datepicker control is
// a plain value field: its string value is part of the save body (D-18). // a plain value field: its string value is part of the save body (D-18).
// Phase 12.1 adds the password control: a plain value field whose value the // Phase 12.1 adds the password control: a plain value field whose value the
// server never sends back, so it is empty on load and after every save. // server never sends back, so it is empty on load and after every save. The
// permissioneditor control is a value field too: an object of permission code
// to value, labelled as a group.
import type { Component } from 'vue' import type { Component } from 'vue'
import CheckboxField from './fields/CheckboxField.vue' import CheckboxField from './fields/CheckboxField.vue'
import DatepickerField from './fields/DatepickerField.vue' import DatepickerField from './fields/DatepickerField.vue'
@@ -20,6 +22,7 @@ import FileuploadField from './fields/FileuploadField.vue'
import NumberField from './fields/NumberField.vue' import NumberField from './fields/NumberField.vue'
import PartialField from './fields/PartialField.vue' import PartialField from './fields/PartialField.vue'
import PasswordField from './fields/PasswordField.vue' import PasswordField from './fields/PasswordField.vue'
import PermissionEditorField from './fields/PermissionEditorField.vue'
import RelationManager from '../relation/RelationManager.vue' import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue' import RelationField from './fields/RelationField.vue'
import SwitchField from './fields/SwitchField.vue' import SwitchField from './fields/SwitchField.vue'
@@ -56,6 +59,7 @@ const renderers = new Map<string, Component>([
['fileupload', FileuploadField], ['fileupload', FileuploadField],
['datepicker', DatepickerField], ['datepicker', DatepickerField],
['password', PasswordField], ['password', PasswordField],
['permissioneditor', PermissionEditorField],
]) ])
/** Types whose control shows the label itself (toggle cards, relation manager). */ /** Types whose control shows the label itself (toggle cards, relation manager). */
@@ -75,7 +79,7 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
* Types whose control is a group rather than one focusable element: the * Types whose control is a group rather than one focusable element: the
* visible label is a span the group points at, not a label for an input. * visible label is a span the group points at, not a label for an input.
*/ */
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload']) const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor'])
export function rendererFor(type: string): Component { export function rendererFor(type: string): Component {
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField

View File

@@ -9,6 +9,20 @@
{ "name": "password", "type": "password", "label": "Password", "span": "left", "context": ["create", "update"] }, { "name": "password", "type": "password", "label": "Password", "span": "left", "context": ["create", "update"] },
{ "name": "password_confirmation", "type": "password", "label": "Repeat the password", "span": "right", "context": ["create", "update"] }, { "name": "password_confirmation", "type": "password", "label": "Repeat the password", "span": "right", "context": ["create", "update"] },
{ "name": "notify", "type": "checkbox", "label": "Send a welcome message", "default": true, "context": "create" }, { "name": "notify", "type": "checkbox", "label": "Send a welcome message", "default": true, "context": "create" },
{
"name": "permissions",
"type": "permissioneditor",
"label": "Permissions",
"tab": "Permissions",
"context": "update",
"mode": "radio",
"permissionOptions": [
{ "code": "posts.edit", "label": "Edit posts", "tab": "Content", "comment": "Change the text of any post." },
{ "code": "posts.publish", "label": "Publish posts", "tab": "Content" },
{ "code": "reports.export", "label": "Export reports", "tab": "Reports", "locked": true },
{ "code": "misc.beta", "label": "Try beta features" }
]
},
{ "name": "joined_ip", "type": "text", "label": "Joined from IP address", "context": "preview" } { "name": "joined_ip", "type": "text", "label": "Joined from IP address", "context": "preview" }
], ],
"preview": { "headerPartial": "status" }, "preview": { "headerPartial": "status" },

View File

@@ -1,5 +1,5 @@
{ {
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "slug": "ada-lovelace", "joined_ip": "203.0.113.7" }, "data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "slug": "ada-lovelace", "joined_ip": "203.0.113.7", "permissions": { "legacy.code": 1, "posts.edit": 1, "reports.export": 1 } },
"meta": { "meta": {
"labels": {}, "labels": {},
"actions": [ "actions": [

View File

@@ -1,5 +1,6 @@
// Phase 12.1 form seams in the admin SPA: the password field (UI-SPEC S7, // Phase 12.1 form seams in the admin SPA: the password field (UI-SPEC S7,
// D-19) and preset fields (D-27 G7). Fixtures are neutral acme.roster.* data; // D-19), preset fields (D-27 G7) and the permission editor (UI-SPEC S5,
// D-16). Fixtures are neutral acme.roster.* data;
// no application names appear in framework tests. // no application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
@@ -15,6 +16,12 @@ const RECORD = `${LIST}/1`
const strings = { const strings = {
'backend::lang.form.show_password': { other: 'Show password' }, 'backend::lang.form.show_password': { other: 'Show password' },
'backend::lang.form.hide_password': { other: 'Hide password' }, 'backend::lang.form.hide_password': { other: 'Hide password' },
'backend::lang.permissioneditor.allow': { other: 'Allow' },
'backend::lang.permissioneditor.inherit': { other: 'Inherit' },
'backend::lang.permissioneditor.deny': { other: 'Deny' },
'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' },
'backend::lang.permissioneditor.empty': { other: 'No permissions are defined yet.' },
'backend::lang.permissioneditor.other': { other: 'Other' },
} }
function routes(overrides: Record<string, Route> = {}): Record<string, Route> { function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
@@ -202,3 +209,184 @@ describe('preset (D-27 G7)', () => {
expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace') expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace')
}) })
}) })
describe('permission editor (UI-SPEC S5, D-16)', () => {
const editor = (wrapper: VueWrapper) => wrapper.find('[data-permission-editor]')
const row = (wrapper: VueWrapper, code: string) => wrapper.find(`[data-permission="${code}"]`)
const segment = (wrapper: VueWrapper, code: string, value: string) => row(wrapper, code).find(`[data-segment="${value}"]`)
const checked = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[data-state="checked"]')
/** Opens the update form on its Permissions tab. */
async function open(overrides: Record<string, Route> = {}, schema = rosterFormSchemaFixture) {
const mounted = await mountApp(
'/acme/roster/people/1',
routes({ [`GET ${LIST}/schema/form`]: { body: schema }, ...overrides }),
{ attach: true },
)
const tab = mounted.wrapper.findAll('[role="tab"]').find((item) => item.text().includes('Permissions'))
await tab!.trigger('click')
await flushPromises()
return mounted
}
function withField(change: (field: (typeof rosterFormSchemaFixture.data.fields)[number]) => void) {
const schema = clone(rosterFormSchemaFixture)
change(schema.data.fields.find((field) => field.name === 'permissions')!)
return schema
}
it('groups the options by tab into sections of one list, untabbed ones last', async () => {
const { wrapper } = await open()
expect(editor(wrapper).attributes('role')).toBe('group')
expect(editor(wrapper).attributes('aria-labelledby')).toBe('field-permissions-label')
expect(wrapper.find('#field-permissions-label').text()).toBe('Permissions')
expect(editor(wrapper).classes()).toEqual(expect.arrayContaining(['overflow-hidden', 'rounded-inner', 'border', 'border-border']))
const sections = wrapper.findAll('[data-permission-section]')
expect(sections.map((section) => section.find('h3').text())).toEqual(['Content', 'Reports', 'Other'])
expect(sections.map((section) => section.findAll('[data-permission]').map((item) => item.attributes('data-permission')))).toEqual([
['posts.edit', 'posts.publish'],
['reports.export'],
['misc.beta'],
])
// Each section is a group named by its header; there is no inner tablist
// and no inner scroll.
expect(sections[0]!.attributes('aria-labelledby')).toBe(sections[0]!.find('h3').attributes('id'))
expect(editor(wrapper).find('[role="tablist"]').exists()).toBe(false)
expect(editor(wrapper).html()).not.toContain('overflow-y-auto')
expect(editor(wrapper).html()).not.toContain('sticky')
// Label and comment; a radio group of three named segments per row.
expect(row(wrapper, 'posts.edit').text()).toContain('Edit posts')
expect(row(wrapper, 'posts.edit').text()).toContain('Change the text of any post.')
expect(row(wrapper, 'posts.edit').findAll('[role="radio"]').map((item) => item.text())).toEqual(['Allow', 'Inherit', 'Deny'])
expect(row(wrapper, 'posts.edit').find('[role="radiogroup"]').attributes('aria-labelledby')).toBe(
row(wrapper, 'posts.edit').find('span[id$="-label"]').attributes('id'),
)
// Stored allow shows as Allow; a code with no value shows as Inherit.
expect(checked(wrapper, 'posts.edit').text()).toBe('Allow')
expect(checked(wrapper, 'posts.publish').text()).toBe('Inherit')
expect(checked(wrapper, 'posts.edit').classes()).toEqual(
expect.arrayContaining(['data-[state=checked]:bg-ok-bg', 'data-[state=checked]:text-ok-text']),
)
// The row wraps below 640px and the control never shrinks.
expect(row(wrapper, 'posts.edit').classes()).toEqual(expect.arrayContaining(['min-h-[56px]', 'max-sm:flex-col']))
expect(row(wrapper, 'posts.edit').find('[role="radiogroup"]').classes()).toContain('shrink-0')
})
it('sends a chosen Deny as -1, leaves inherited codes out and never sends a code that is not offered', async () => {
const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })
await segment(wrapper, 'posts.publish', '-1').trigger('click')
expect(checked(wrapper, 'posts.publish').text()).toBe('Deny')
expect(checked(wrapper, 'posts.publish').classes()).toContain('data-[state=checked]:text-danger')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.permissions).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': 1 })
})
it('goes back to inherit by leaving the code out', async () => {
const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })
await segment(wrapper, 'posts.edit', '0').trigger('click')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.permissions).toEqual({ 'reports.export': 1 })
})
it('shows a locked row with its stored value, a disabled control, the lock and its text', async () => {
const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })
const locked = row(wrapper, 'reports.export')
expect(locked.attributes('data-locked')).toBeDefined()
expect(locked.find('[data-permission-locked]').text()).toBe('You cannot change this permission.')
expect(locked.find('svg').attributes('aria-hidden')).toBe('true')
expect(checked(wrapper, 'reports.export').text()).toBe('Allow')
for (const item of locked.findAll('[role="radio"]')) {
expect(item.attributes('disabled')).toBeDefined()
}
expect(locked.find('[role="radiogroup"]').attributes('aria-disabled')).toBe('true')
// A click changes nothing, and other rows stay editable.
await segment(wrapper, 'reports.export', '-1').trigger('click')
expect(checked(wrapper, 'reports.export').text()).toBe('Allow')
expect(row(wrapper, 'posts.edit').find('[role="radio"]').attributes('disabled')).toBeUndefined()
expect(row(wrapper, 'posts.edit').find('[data-permission-locked]').exists()).toBe(false)
await segment(wrapper, 'misc.beta', '1').trigger('click')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.permissions).toEqual({ 'posts.edit': 1, 'reports.export': 1, 'misc.beta': 1 })
})
it('edits allow with a labelled checkbox per row in checkbox mode', async () => {
const schema = withField((field) => (field.mode = 'checkbox'))
const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }, schema)
expect(editor(wrapper).attributes('data-mode')).toBe('checkbox')
// The section header carries the column heading.
expect(wrapper.find('[data-permission-section] h3').text()).toContain('Allow')
const box = (code: string) => row(wrapper, code).find('[role="checkbox"]')
expect(box('posts.edit').attributes('aria-checked')).toBe('true')
expect(box('posts.publish').attributes('aria-checked')).toBe('false')
expect(box('posts.edit').classes()).toEqual(expect.arrayContaining(['size-[18px]', 'rounded-checkbox']))
// The row label is a label for the box.
expect(row(wrapper, 'posts.publish').find('label').attributes('for')).toBe(box('posts.publish').attributes('id'))
expect(box('reports.export').attributes('disabled')).toBeDefined()
await box('posts.publish').trigger('click')
await box('posts.edit').trigger('click')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.permissions).toEqual({ 'posts.publish': 1, 'reports.export': 1 })
})
it('shows the empty text when the server supplies no options', async () => {
const { wrapper } = await open({}, withField((field) => delete field.permissionOptions))
expect(editor(wrapper).exists()).toBe(false)
const empty = wrapper.find('[data-permission-empty]')
expect(empty.text()).toBe('No permissions are defined yet.')
expect(empty.classes()).toEqual(expect.arrayContaining(['min-h-input', 'bg-subtle', 'text-muted']))
})
it('renders a 422 on the field error line with a danger border and clears it on a change', async () => {
const refused: Reply = {
status: 422,
body: {
error: {
code: 'validation_failed',
message: 'Validation failed',
details: { permissions: ['The permissions field contains an unknown permission.'] },
},
},
}
const { wrapper } = await open({ [`PUT ${RECORD}`]: refused })
await segment(wrapper, 'posts.publish', '1').trigger('click')
await save(wrapper)
expect(wrapper.find('[data-field="permissions"]').text()).toContain('The permissions field contains an unknown permission.')
expect(editor(wrapper).classes()).toContain('border-danger')
expect(editor(wrapper).attributes('aria-describedby')).toBe('field-permissions-error')
await segment(wrapper, 'posts.publish', '0').trigger('click')
expect(editor(wrapper).classes()).toContain('border-border')
expect(wrapper.find('#field-permissions-error').exists()).toBe(false)
})
it('is read-only on the preview: every control disabled, no lock icon and no locked text', async () => {
const schema = withField((field) => (field.context = ['update', 'preview']))
const { wrapper } = await mountApp('/acme/roster/people/1/preview', routes({ [`GET ${LIST}/schema/form`]: { body: schema } }))
await flushPromises()
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('Permissions'))
await tab!.trigger('click')
await flushPromises()
const shown = wrapper.find('[data-preview-field="permissions"]')
expect(shown.find('[data-permission-editor]').exists()).toBe(true)
const radios = shown.findAll('[role="radio"]')
expect(radios).toHaveLength(12)
for (const item of radios) {
expect(item.attributes('disabled')).toBeDefined()
}
expect(shown.find('[data-permission-locked]').exists()).toBe(false)
expect(shown.find('[data-locked]').exists()).toBe(false)
expect(shown.find('[data-permission="posts.edit"] [data-state="checked"]').text()).toBe('Allow')
})
})

View File

@@ -95,6 +95,7 @@ for _, f := range form.Fields {
| `partial` | A server-rendered template; see [Partials and widgets](partials-and-widgets.md). | | `partial` | A server-rendered template; see [Partials and widgets](partials-and-widgets.md). |
| `fileupload` | Uploads for an attachOne or attachMany relation; see [File uploads](#file-uploads). | | `fileupload` | Uploads for an attachOne or attachMany relation; see [File uploads](#file-uploads). |
| `datepicker` | A date, date and time, or time of day; see [Date pickers](#date-pickers). | | `datepicker` | A date, date and time, or time of day; see [Date pickers](#date-pickers). |
| `permissioneditor` | A list of permissions to allow, deny or leave inherited; see [Permission editor](#permission-editor). |
| `password` | A masked input with a show and hide button. It is a form-only field: the value is sent with a save and never returned; see [Form-only fields](#form-only-fields). | | `password` | A masked input with a show and hide button. It is a form-only field: the value is sent with a save and never returned; see [Form-only fields](#form-only-fields). |
The WinterCMS widgets that are not in this list (the rich editor, the markdown editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up. The WinterCMS widgets that are not in this list (the rich editor, the markdown editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up.
@@ -248,6 +249,74 @@ func (MembersController) FormVirtualFields() []string {
- `type: password` is always a form-only field: a password field the controller does not list stops the start-up. The admin SPA shows it empty on every load, leaves an empty password out of an update (empty means unchanged), and clears it after a save. A confirmation is a second `password` field, compared by the `confirmed` rule on the server. - `type: password` is always a form-only field: a password field the controller does not list stops the start-up. The admin SPA shows it empty on every load, leaves an empty password out of an update (empty means unchanged), and clears it after a save. A confirmation is a second `password` field, compared by the `confirmed` rule on the server.
- Form-only fields are not available on settings forms or relation forms. - Form-only fields are not available on settings forms or relation forms.
## Permission editor
`type: permissioneditor` edits a set of permissions on a record: which codes are allowed, and in radio mode which are denied. The field needs a `mode`:
```yaml
permissions:
label: acme.roster::lang.people.permissions
type: permissioneditor
mode: radio
tab: acme.roster::lang.people.tab_permissions
context: update
```
| Mode | Control per permission | Values |
|------|------------------------|--------|
| `radio` | Allow, Inherit, Deny | `1` allows, `-1` denies; an inherited permission has no value. |
| `checkbox` | One checkbox | `1` allows; an unchecked permission has no value. |
The permissions themselves are not in the YAML. The controller implements `cabana.PermissionEditorProvider` and answers per request, so the list may depend on the signed-in administrator:
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminPermissionOptions
// AdminPermissionOptions lists the permissions the `type: permissioneditor`
// field offers, in display order. The principal on ctx decides what is locked.
func (MembersController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
principal, _ := bouncer.User(ctx)
mayExport := cabana.Allows(principal, []string{"acme.roster.manage"})
return []cabana.PermissionOption{
{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports", Locked: !mayExport},
}, nil
}
```
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminPermissionValues
// AdminPermissionValues reads the permissions stored on the record.
func (MembersController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
values := map[string]int{}
if raw := record.(*Member).Permissions; raw != "" {
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
}
return values, nil
}
```
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminSetPermissionValues
// AdminSetPermissionValues stores the checked set on the model. The save
// writes the row afterwards, in the same transaction.
func (MembersController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
raw, err := json.Marshal(values)
if err != nil {
return err
}
record.(*Member).Permissions = string(raw)
return nil
}
```
- An option (`cabana.PermissionOption`) has a `Code`, a `Label`, and optionally a `Tab` and a `Comment`; the three texts are translation keys or text. Options with the same `Tab` are shown as one section of the list, in the order the controller returns them, and options without a `Tab` form a last section. The form schema carries them on the field as `permissionOptions`.
- The value travels as a JSON object of code to integer, in a record response and in a save body: `{"posts.edit": 1, "posts.publish": -1}`. A record response always carries the object, empty when nothing is stored.
- A save checks the submitted object inside its transaction, after the Form before-hooks and before the row is written. A value that is not an object of integers, a code the controller does not offer, or a value outside the mode's set is a 422 on the field; a `0` means "no value". The checked set is then handed to `AdminSetPermissionValues`, which decides how it is stored.
- A stored code that is not among the options is kept as it is: an offered code that is left out loses its value, a code that is not offered is never touched and can never be submitted.
- An option with `Locked` set is shown with a disabled control. The server enforces it: a save in which a locked code's value differs from the stored one is answered 403 `forbidden` with a message on the field, and nothing is written.
- A save that does not send the field leaves the stored permissions alone, and the field's `context` applies as for any field.
- The keys `options`, `default`, `nameFrom`, `emptyOption`, `relation` and `preset` are refused on the type. A field without `mode`, or on a controller that does not implement the provider, stops the start-up. The type is not available on settings forms or relation forms.
## What a save may write ## What a save may write
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves. The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" /> <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title> <title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-DEO3TzjB.js"></script> <script type="module" crossorigin src="./assets/index-CvMS0tdW.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B2epb8_Z.css"> <link rel="stylesheet" crossorigin href="./assets/index-CXbMmgdh.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -22,6 +22,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id. - Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot. - Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms. - Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field. - Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. - Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation. - Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
@@ -209,6 +210,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.Allows` | Checks a principal against required permission codes. | | `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. | | `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. | | `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
| `cabana.PermissionOption` | One permission a `type: permissioneditor` field offers: code, label, optional tab and comment, and `Locked`. |
| `cabana.PermissionEditorProvider` | Controller capability behind a `type: permissioneditor` field: `AdminPermissionOptions`, `AdminPermissionValues` and `AdminSetPermissionValues`. |
| `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. | | `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. | | `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. | | `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |

View File

@@ -262,7 +262,7 @@ func AdminListSchema() {}
// AdminFormSchema documents the form schema route. // AdminFormSchema documents the form schema route.
// //
// @Summary Admin form schema // @Summary Admin form schema
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. // @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).
// @Tags admin // @Tags admin
// @Produce json // @Produce json
// @Security BackendBearer // @Security BackendBearer

View File

@@ -105,6 +105,9 @@ type CompiledController struct {
// virtual is the set of form field names the controller lists through // virtual is the set of form field names the controller lists through
// pact.FormVirtualFields: never bound, filled or projected. // pact.FormVirtualFields: never bound, filled or projected.
virtual map[string]bool virtual map[string]bool
// permissions are the form's `type: permissioneditor` fields: field name
// to mode (radio or checkbox).
permissions map[string]string
} }
// Registry is the immutable controller map keyed by controller ID. // Registry is the immutable controller map keyed by controller ID.

View File

@@ -544,13 +544,17 @@ type actionConflict struct{}
func (actionConflict) Error() string { return "cabana: action does not apply" } func (actionConflict) Error() string { return "cabana: action does not apply" }
// projectFullRecord is the D-18 record shape: scalar writable fields, relation // projectFullRecord is the D-18 record shape: scalar writable fields, relation
// values keyed by field name, and their labels. // values keyed by field name with their labels, and the stored permissions of
// every permissioneditor field as an object (D-16).
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) { func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
data := projectRecord(cc, model) data := projectRecord(cc, model)
meta, err := projectRelationFields(ctx, tx, cc, model, data) meta, err := projectRelationFields(ctx, tx, cc, model, data)
if err != nil { if err != nil {
return RecordResult{}, err return RecordResult{}, err
} }
if err := projectPermissionFields(withTx(ctx, tx), cc, model, data); err != nil {
return RecordResult{}, err
}
return RecordResult{Data: data, Meta: meta}, nil return RecordResult{Data: data, Meta: meta}, nil
} }
@@ -580,6 +584,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil { if err != nil {
return RecordResult{}, err return RecordResult{}, err
} }
permissions, err := liftPermissionValues(cc, in.Body, op)
if err != nil {
return RecordResult{}, err
}
var result RecordResult var result RecordResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withVirtualFields(withTx(ctx, tx), virtual) ctx = withVirtualFields(withTx(ctx, tx), virtual)
@@ -633,6 +641,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil { if err != nil {
return err return err
} }
// D-16: permission values are checked against the controller's
// options and stored by the controller before the row write.
if err := applyPermissionValues(ctx, cc, target, permissions, update); err != nil {
return err
}
// D-18: submitted ids pass the same scoped query as the options // D-18: submitted ids pass the same scoped query as the options
// endpoint; belongsTo keys land before the row write, pivot rows after. // endpoint; belongsTo keys land before the row write, pivot rows after.
if err := checkRelationScope(ctx, tx, cc, relations); err != nil { if err := checkRelationScope(ctx, tx, cc, relations); err != nil {

View File

@@ -93,7 +93,7 @@ func TestDatepickerSmokeCompile(t *testing.T) {
"minDate on time": {" day:\n type: datepicker\n mode: time\n minDate: 2026-01-01\n", "minDate is not valid with mode: time"}, "minDate on time": {" day:\n type: datepicker\n mode: time\n minDate: 2026-01-01\n", "minDate is not valid with mode: time"},
"min after max": {" starts_at:\n type: datepicker\n minDate: 2026-02-01\n maxDate: 2026-01-01\n", "is after maxDate"}, "min after max": {" starts_at:\n type: datepicker\n minDate: 2026-02-01\n maxDate: 2026-01-01\n", "is after maxDate"},
"key on another type": {" title:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"}, "key on another type": {" title:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
"mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"}, "mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker or permissioneditor"},
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
err := activateFields(t, datepickerFields(tc.field)) err := activateFields(t, datepickerFields(tc.field))

View File

@@ -65,7 +65,7 @@ func TestDatepickerCompile(t *testing.T) {
"ignoreTimezone not bool": {field("starts_at", " ignoreTimezone: maybe\n"), "ignoreTimezone:"}, "ignoreTimezone not bool": {field("starts_at", " ignoreTimezone: maybe\n"), "ignoreTimezone:"},
"ignoreTimezone on date": {field("released_on", " mode: date\n ignoreTimezone: true\n"), "ignoreTimezone is only valid with mode: datetime"}, "ignoreTimezone on date": {field("released_on", " mode: date\n ignoreTimezone: true\n"), "ignoreTimezone is only valid with mode: datetime"},
"key on another type": {" name2:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"}, "key on another type": {" name2:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
"mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"}, "mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker or permissioneditor"},
"date on a datetime": {field("starts_at", " mode: date\n"), "field starts_at: datepicker mode date needs a lagoon.Date or *lagoon.Date column, found *time.Time"}, "date on a datetime": {field("starts_at", " mode: date\n"), "field starts_at: datepicker mode date needs a lagoon.Date or *lagoon.Date column, found *time.Time"},
"datetime on a date": {field("released_on", " mode: datetime\n"), "datepicker mode datetime needs a time.Time or *time.Time column, found lagoon.Date"}, "datetime on a date": {field("released_on", " mode: datetime\n"), "datepicker mode datetime needs a time.Time or *time.Time column, found lagoon.Date"},
"time on a date": {field("released_on", " mode: time\n"), "datepicker mode time needs a lagoon.TimeOfDay or *lagoon.TimeOfDay column, found lagoon.Date"}, "time on a date": {field("released_on", " mode: time\n"), "datepicker mode time needs a lagoon.TimeOfDay or *lagoon.TimeOfDay column, found lagoon.Date"},

View File

@@ -4,8 +4,10 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/pact"
) )
@@ -17,6 +19,8 @@ type Member struct {
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
Slug string `gorm:"column:slug"` Slug string `gorm:"column:slug"`
Password string `gorm:"column:password" json:"-"` Password string `gorm:"column:password" json:"-"`
// Permissions is a JSON object of permission code to value.
Permissions string `gorm:"column:permissions"`
} }
func (Member) TableName() string { return "acme_roster_members" } func (Member) TableName() string { return "acme_roster_members" }
@@ -40,6 +44,8 @@ var (
_ pact.FormRules = MembersController{} _ pact.FormRules = MembersController{}
_ pact.FormBeforeCreate = MembersController{} _ pact.FormBeforeCreate = MembersController{}
_ pact.FormBeforeUpdate = MembersController{} _ pact.FormBeforeUpdate = MembersController{}
_ cabana.PermissionEditorProvider = MembersController{}
) )
func (MembersController) ID() string { return "acme.roster.members" } func (MembersController) ID() string { return "acme.roster.members" }
@@ -88,6 +94,40 @@ func (MembersController) FormBeforeUpdate(ctx context.Context, model any) error
return nil return nil
} }
// AdminPermissionOptions lists the permissions the `type: permissioneditor`
// field offers, in display order. The principal on ctx decides what is locked.
func (MembersController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
principal, _ := bouncer.User(ctx)
mayExport := cabana.Allows(principal, []string{"acme.roster.manage"})
return []cabana.PermissionOption{
{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports", Locked: !mayExport},
}, nil
}
// AdminPermissionValues reads the permissions stored on the record.
func (MembersController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
values := map[string]int{}
if raw := record.(*Member).Permissions; raw != "" {
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
}
return values, nil
}
// AdminSetPermissionValues stores the checked set on the model. The save
// writes the row afterwards, in the same transaction.
func (MembersController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
raw, err := json.Marshal(values)
if err != nil {
return err
}
record.(*Member).Permissions = string(raw)
return nil
}
// hashPassword stands in for the application's password hasher. // hashPassword stands in for the application's password hasher.
func hashPassword(plain string) string { func hashPassword(plain string) string {
sum := sha256.Sum256([]byte(plain)) sum := sha256.Sum256([]byte(plain))
@@ -107,9 +147,23 @@ func Example_formSeams() {
_, inSave := cabana.VirtualFieldsFromContext(ctx) _, inSave := cabana.VirtualFieldsFromContext(ctx)
err := ctl.FormBeforeCreate(ctx, member) err := ctl.FormBeforeCreate(ctx, member)
fmt.Println(inSave, err, member.Password == "") fmt.Println(inSave, err, member.Password == "")
// The permission editor: three options, the last one locked for an
// administrator without acme.roster.manage (here: nobody is signed in).
options, _ := ctl.AdminPermissionOptions(ctx, "permissions")
for _, option := range options {
fmt.Println(option.Code, option.Locked)
}
_ = ctl.AdminSetPermissionValues(ctx, "permissions", member, map[string]int{"posts.edit": 1, "posts.publish": -1})
values, _ := ctl.AdminPermissionValues(ctx, "permissions", member)
fmt.Println(member.Permissions, len(values))
// Output: // Output:
// [password password_confirmation notify] // [password password_confirmation notify]
// create: required|between:8,255|confirmed // create: required|between:8,255|confirmed
// update: nullable|between:8,255|confirmed // update: nullable|between:8,255|confirmed
// false <nil> true // false <nil> true
// posts.edit false
// posts.publish false
// reports.export true
// {"posts.edit":1,"posts.publish":-1} 2
} }

View File

@@ -70,8 +70,8 @@ func compileFileuploadKeys(typ string, values map[string]ast.Node, field *FormFi
return fmt.Errorf("%s is only valid on type: fileupload", key) return fmt.Errorf("%s is only valid on type: fileupload", key)
} }
} }
if _, ok := values["mode"]; ok && typ != "datepicker" { if _, ok := values["mode"]; ok && typ != "datepicker" && typ != permissionFieldType {
return fmt.Errorf("mode is only valid on type: fileupload or datepicker") return fmt.Errorf("mode is only valid on type: fileupload, datepicker or permissioneditor")
} }
return nil return nil
} }

View File

@@ -0,0 +1,340 @@
package cabana
import (
"context"
"encoding/json"
"fmt"
"math"
"sort"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml/ast"
)
// permissionFieldType is the fields.yaml type of the permission editor (D-16).
const permissionFieldType = "permissioneditor"
// permissionRefusedKeys are generic field keys with no meaning on a
// permission editor: its choices come from the controller per request.
var permissionRefusedKeys = []string{"options", "default", "nameFrom", "emptyOption", "relation", "preset"}
// permissionLockedKey is the message a changed locked permission is refused
// with, on the field.
const permissionLockedKey = "backend::lang.permissioneditor.locked"
// PermissionOption is one permission a `type: permissioneditor` field offers.
// Code is the permission code stored with the record. Label, Tab and Comment
// are phrase keys or text, localized per request; options with the same Tab
// are shown as one section, and an option without a Tab goes to a last
// section. Locked marks a permission the requesting administrator may see but
// not change: the admin disables its control and a save that changes its
// value is answered 403.
type PermissionOption struct {
Code string `json:"code"`
Label string `json:"label"`
Tab string `json:"tab,omitempty"`
Comment string `json:"comment,omitempty"`
Locked bool `json:"locked,omitempty"`
}
// PermissionEditorProvider is implemented by an admin controller whose form
// has a `type: permissioneditor` field. The framework validates what an
// administrator submits against the offered options; reading and writing the
// record's stored permissions stays with the controller, so the storage shape
// is the plugin's decision.
//
// AdminPermissionOptions returns the permissions field offers the
// administrator on ctx (read with bouncer.User), in display order. It is
// called for the form schema and again inside every save.
//
// AdminPermissionValues returns the permissions stored on record for field as
// code to value. It is called to show a record and, inside a save, before the
// change is applied.
//
// AdminSetPermissionValues stores values on record for field. It runs inside
// the save's transaction (cabana.TxFromContext), before the record's row is
// written, so setting the model's column is enough. values holds the
// submitted codes with their checked values plus every stored code that is
// not offered, unchanged; an offered code that is absent has no value
// (inherit, or not allowed). An error fails the save like a Form hook's.
type PermissionEditorProvider interface {
AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error)
AdminPermissionValues(ctx context.Context, field string, record any) (map[string]int, error)
AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error
}
// compilePermissionKeys checks the keys of a `type: permissioneditor` field:
// mode is required and must be radio (allow, inherit, deny) or checkbox
// (allow), and the keys that describe choices or a column value are refused.
// On every other type mode is checked by compileFileuploadKeys.
func compilePermissionKeys(typ string, values map[string]ast.Node, field *FormField) error {
if typ != permissionFieldType {
return nil
}
for _, key := range permissionRefusedKeys {
if _, ok := values[key]; ok {
return fmt.Errorf("%s is not valid on type: permissioneditor", key)
}
}
mode, err := nodeString(values["mode"])
if err != nil || (mode != "radio" && mode != "checkbox") {
return fmt.Errorf("mode must be radio or checkbox on type: permissioneditor")
}
field.Mode = mode
return nil
}
// compilePermissionFields stops boot when a form has a permissioneditor field
// and its controller does not implement PermissionEditorProvider, and records
// the fields' modes for the save path.
func compilePermissionFields(pluginID string, cc *CompiledController) error {
if cc == nil || cc.Form == nil {
return nil
}
for _, field := range cc.Form.Fields {
if field.Type != permissionFieldType {
continue
}
if provider, ok := cc.Controller.(PermissionEditorProvider); !ok || provider == nil {
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath,
fmt.Errorf("field %s: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider", field.Name))
}
if cc.permissions == nil {
cc.permissions = map[string]string{}
}
cc.permissions[field.Name] = field.Mode
}
return nil
}
// permissionFieldNames are the controller's permissioneditor fields in a
// stable order.
func permissionFieldNames(cc *CompiledController) []string {
if cc == nil || len(cc.permissions) == 0 {
return nil
}
names := make([]string, 0, len(cc.permissions))
for name := range cc.permissions {
names = append(names, name)
}
sort.Strings(names)
return names
}
// localizePermissionOptions fills the permission options of every
// permissioneditor field of a localized form view for one request. The
// options are asked from the controller with the request context, so they may
// depend on the administrator. fields is the view's own slice; the cached
// schema is not touched.
func localizePermissionOptions(ctx context.Context, tr *phrasebook.Translator, cc *CompiledController, fields []FormField) error {
if cc == nil || len(cc.permissions) == 0 {
return nil
}
provider, ok := cc.Controller.(PermissionEditorProvider)
if !ok || provider == nil {
return fmt.Errorf("cabana: controller %s: no PermissionEditorProvider", controllerID(cc))
}
if ctx == nil {
ctx = context.Background()
}
local := towel.WithLocale(ctx, schemaLocale(ctx, tr))
for i := range fields {
if fields[i].Type != permissionFieldType {
continue
}
options, err := provider.AdminPermissionOptions(ctx, fields[i].Name)
if err != nil {
return err
}
out := make([]PermissionOption, len(options))
for j, option := range options {
out[j] = PermissionOption{
Code: option.Code,
Label: translateKey(local, tr, option.Label),
Tab: translateKey(local, tr, option.Tab),
Comment: translateKey(local, tr, option.Comment),
Locked: option.Locked,
}
}
fields[i].PermissionOptions = out
}
return nil
}
// permissionValue is one permissioneditor value lifted from a save body.
type permissionValue struct {
field string
mode string
values map[string]int
}
// liftPermissionValues takes the permissioneditor values out of a save body
// before scalar projection, which drops every nested value. Only fields
// present in the body whose context allows op are lifted. A value must be a
// JSON object of permission code to integer; anything else is
// validation_failed on the field.
func liftPermissionValues(cc *CompiledController, body map[string]any, op string) ([]permissionValue, error) {
if cc == nil || len(cc.permissions) == 0 || body == nil {
return nil, nil
}
details := map[string]any{}
var out []permissionValue
for _, name := range permissionFieldNames(cc) {
if !contextAllows(cc, name, op) {
continue
}
raw, present := body[name]
if !present {
continue
}
object, ok := raw.(map[string]any)
values := make(map[string]int, len(object))
for code, item := range object {
n, isInt := permissionInt(item)
if !isInt {
ok = false
break
}
values[code] = n
}
if !ok {
details[name] = []string{"The " + name + " field must be an object of permission codes."}
continue
}
out = append(out, permissionValue{field: name, mode: cc.permissions[name], values: values})
}
if len(details) > 0 {
return nil, &ValidationError{Details: details}
}
return out, nil
}
// permissionInt accepts a JSON integer only: no strings, booleans, fractions
// or nested values.
func permissionInt(value any) (int, bool) {
switch n := value.(type) {
case json.Number:
i, err := n.Int64()
if err != nil || i < math.MinInt32 || i > math.MaxInt32 {
return 0, false
}
return int(i), true
case float64:
if n != math.Trunc(n) || n < math.MinInt32 || n > math.MaxInt32 {
return 0, false
}
return int(n), true
case int:
return n, true
case int64:
if n < math.MinInt32 || n > math.MaxInt32 {
return 0, false
}
return int(n), true
default:
return 0, false
}
}
// permissionAllowed reports whether value is in mode's set: 1 or -1 for
// radio, 1 for checkbox.
func permissionAllowed(mode string, value int) bool {
if mode == "radio" {
return value == 1 || value == -1
}
return value == 1
}
// applyPermissionValues checks the lifted permission values against the
// controller's options and hands the next set to the controller, inside the
// save's transaction and before the row write (D-16; T-12.1-13). Every
// submitted code must be offered and every value in the mode's set (a 0 means
// "no value" and is dropped): otherwise 422 on the field. A locked option's
// stored and submitted value must be equal: otherwise a ForbiddenError (403)
// naming the field. The next set is the stored codes that are not offered,
// unchanged, plus the submitted codes.
func applyPermissionValues(ctx context.Context, cc *CompiledController, record any, lifted []permissionValue, update bool) error {
if len(lifted) == 0 {
return nil
}
provider, ok := cc.Controller.(PermissionEditorProvider)
if !ok || provider == nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
for _, value := range lifted {
options, err := provider.AdminPermissionOptions(ctx, value.field)
if err != nil {
return lifecycleFailure(cc, err)
}
offered := make(map[string]PermissionOption, len(options))
for _, option := range options {
offered[option.Code] = option
}
submitted := make(map[string]int, len(value.values))
for code, n := range value.values {
if _, known := offered[code]; !known {
return &ValidationError{Details: map[string]any{value.field: []string{"The " + value.field + " field contains an unknown permission."}}}
}
if n == 0 {
continue
}
if !permissionAllowed(value.mode, n) {
return &ValidationError{Details: map[string]any{value.field: []string{"The " + value.field + " field contains an invalid value."}}}
}
submitted[code] = n
}
stored := map[string]int{}
if update {
current, err := provider.AdminPermissionValues(ctx, value.field, record)
if err != nil {
return lifecycleFailure(cc, err)
}
for code, n := range current {
stored[code] = n
}
}
next := make(map[string]int, len(stored)+len(submitted))
for code, n := range stored {
if _, known := offered[code]; !known {
next[code] = n
}
}
for _, option := range options {
if option.Locked && stored[option.Code] != submitted[option.Code] {
return &ForbiddenError{Details: map[string]any{value.field: []string{permissionLockedKey}}}
}
}
for code, n := range submitted {
next[code] = n
}
if err := provider.AdminSetPermissionValues(ctx, value.field, record, next); err != nil {
return lifecycleFailure(cc, err)
}
}
return nil
}
// projectPermissionFields sets data[field] to the record's stored permissions
// for every permissioneditor field, as an object that is never null.
func projectPermissionFields(ctx context.Context, cc *CompiledController, record any, data map[string]any) error {
if cc == nil || len(cc.permissions) == 0 {
return nil
}
provider, ok := cc.Controller.(PermissionEditorProvider)
if !ok || provider == nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
for _, name := range permissionFieldNames(cc) {
values, err := provider.AdminPermissionValues(ctx, name, record)
if err != nil {
return lifecycleFailure(cc, err)
}
out := make(map[string]int, len(values))
for code, n := range values {
out[code] = n
}
data[name] = out
}
return nil
}

View File

@@ -25,7 +25,7 @@ var (
"text": {}, "textarea": {}, "number": {}, "checkbox": {}, "text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {}, "widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
"password": {}, "password": {}, "permissioneditor": {},
} }
formSpans = map[string]struct{}{ formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {}, "left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
@@ -548,6 +548,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
if err := compilePartialPath(typ, values, &field); err != nil { if err := compilePartialPath(typ, values, &field); err != nil {
return FormField{}, err return FormField{}, err
} }
if err := compilePermissionKeys(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compileFileuploadKeys(typ, values, &field); err != nil { if err := compileFileuploadKeys(typ, values, &field); err != nil {
return FormField{}, err return FormField{}, err
} }

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"reflect" "reflect"
"slices" "slices"
@@ -699,6 +700,13 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
} }
kept = append(kept, field) kept = append(kept, field)
} }
// A permission editor's options are the controller's answer for this
// administrator (D-16); kept is this request's own slice.
if err := localizePermissionOptions(r.Context(), s.translator(), cc, kept); err != nil {
slog.Error("cabana: permission options failed", "controller", controllerID(cc), "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
view.Fields = kept view.Fields = kept
view.Assets = s.controllerAssets(cc) view.Assets = s.controllerAssets(cc)
meta := map[string]any{} meta := map[string]any{}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"net/http" "net/http"
@@ -15,6 +16,7 @@ import (
"time" "time"
"git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon"
@@ -45,6 +47,9 @@ type rosterPerson struct {
// controller's hooks derive this column from the submitted value. // controller's hooks derive this column from the submitted value.
Password string `gorm:"column:password" json:"-"` Password string `gorm:"column:password" json:"-"`
Slug string `gorm:"column:slug"` Slug string `gorm:"column:slug"`
// Permissions is the permission editor's storage: a JSON object of code
// to value, or NULL.
Permissions *string `gorm:"column:permissions"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
} }
@@ -258,6 +263,60 @@ func (rosterController) PartialData(_ context.Context, name string, record any)
return rosterStatus{}, nil return rosterStatus{}, nil
} }
// rosterPermissionCodes are the permissions the people form offers: two tabs
// and one permission without a tab. reports.export is locked for an
// administrator without acme.roster.manage.
var rosterPermissionCodes = []cabana.PermissionOption{
{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content", Comment: "acme.roster::lang.permissions.posts_edit_comment"},
{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports"},
{Code: "misc.beta", Label: "acme.roster::lang.permissions.misc_beta"},
}
// AdminPermissionOptions serves the permission editor's options per
// administrator.
func (rosterController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
if field != "permissions" {
return nil, fmt.Errorf("unknown permission field %s", field)
}
principal, _ := bouncer.User(ctx)
out := append([]cabana.PermissionOption(nil), rosterPermissionCodes...)
for i := range out {
if out[i].Code == "reports.export" {
out[i].Locked = !cabana.Allows(principal, []string{"acme.roster.manage"})
}
}
return out, nil
}
// AdminPermissionValues reads the stored JSON object.
func (rosterController) AdminPermissionValues(_ context.Context, _ string, record any) (map[string]int, error) {
person := record.(*rosterPerson)
out := map[string]int{}
if person.Permissions == nil || *person.Permissions == "" {
return out, nil
}
if err := json.Unmarshal([]byte(*person.Permissions), &out); err != nil {
return nil, err
}
return out, nil
}
// AdminSetPermissionValues writes the JSON object onto the model; the save
// writes the row.
func (rosterController) AdminSetPermissionValues(ctx context.Context, _ string, record any, values map[string]int) error {
if _, ok := cabana.TxFromContext(ctx); !ok {
return fmt.Errorf("no transaction on the context")
}
raw, err := json.Marshal(values)
if err != nil {
return err
}
text := string(raw)
record.(*rosterPerson).Permissions = &text
return nil
}
// rosterLocked is the sentinel name of a person the roster's actions refuse. // rosterLocked is the sentinel name of a person the roster's actions refuse.
const rosterLocked = "Locked" const rosterLocked = "Locked"

View File

@@ -386,3 +386,170 @@ func TestPresetSchema(t *testing.T) {
}) })
} }
} }
// TestPermissionEditorSmoke drives `type: permissioneditor` through the
// assembled router on PostgreSQL (D-16; T-12.1-13): options per administrator,
// the code and value checks, the locked guard, kept unknown codes and the
// boot rules.
func TestPermissionEditorSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
legacy := `{"legacy.code":1,"reports.export":1}`
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &legacy})
record := fmt.Sprintf("%s/%d", rosterPeople, id)
stored := func(t *testing.T) map[string]int {
t.Helper()
person := rosterLoad(t, gdb, id)
out := map[string]int{}
if person.Permissions != nil {
if err := json.Unmarshal([]byte(*person.Permissions), &out); err != nil {
t.Fatalf("stored permissions %q: %v", *person.Permissions, err)
}
}
return out
}
same := func(t *testing.T, got, want map[string]int) {
t.Helper()
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("permissions = %v, want %v", got, want)
}
}
const unknown = "The permissions field contains an unknown permission."
const invalid = "The permissions field contains an invalid value."
const shape = "The permissions field must be an object of permission codes."
t.Run("the schema carries localized options, locked only for the limited admin", func(t *testing.T) {
view, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"permissions","type":"permissioneditor","label":"Permissions","tab":"Permissions","context":"update","mode":"radio","permissionOptions":[{"code":"posts.edit","label":"Edit posts","tab":"Content","comment":"Change the text of any post."},{"code":"posts.publish","label":"Publish posts","tab":"Content"},{"code":"reports.export","label":"Export reports","tab":"Reports"},{"code":"misc.beta","label":"Try beta features"}]`) {
t.Fatalf("permission field is not in the schema: %s", raw)
}
if strings.Contains(raw, `"locked"`) {
t.Fatalf("an option is locked for the full admin: %s", raw)
}
_ = view
limited, raw := rosterFormSchema(t, env, "limited")
if !strings.Contains(raw, `{"code":"reports.export","label":"Export reports","tab":"Reports","locked":true}`) || strings.Count(raw, `"locked":true`) != 1 {
t.Fatalf("limited admin's options: %s", raw)
}
// The cached schema was not mutated by either request.
for _, field := range limited.Fields {
if field.Type == "permissioneditor" && len(field.PermissionOptions) != 4 {
t.Fatalf("options = %+v", field.PermissionOptions)
}
}
})
t.Run("show returns the stored codes as an object", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, record, "", "bearer")
if !strings.Contains(rec.Body.String(), `"permissions":{"legacy.code":1,"reports.export":1}`) {
t.Fatalf("show: %s", rec.Body.String())
}
blank := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Blank", Active: true})
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, blank), "", "bearer")
if !strings.Contains(rec.Body.String(), `"permissions":{}`) {
t.Fatalf("show without stored permissions: %s", rec.Body.String())
}
})
t.Run("an update stores offered codes, drops inherit and keeps a stored code that is not offered", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"posts.publish":-1,"misc.beta":0,"reports.export":1}}`, "bearer")
want := map[string]int{"legacy.code": 1, "posts.edit": 1, "posts.publish": -1, "reports.export": 1}
same(t, stored(t), want)
got, _ := rosterRecord(t, rec.Body.Bytes()).Data["permissions"].(map[string]any)
if len(got) != 4 || got["posts.publish"] != float64(-1) || got["legacy.code"] != float64(1) {
t.Fatalf("update answered %v", got)
}
// An offered code that is left out goes back to inherit.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"reports.export":1}}`, "bearer")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
// A save without the field leaves the column alone.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Perm B"}`, "bearer")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
})
t.Run("an unknown code, a value outside the set and a non-object are 422", func(t *testing.T) {
before := stored(t)
for body, message := range map[string]string{
`{"permissions":{"posts.edit":1,"admin.root":1}}`: unknown,
// A stored code that is not offered cannot be submitted either.
`{"permissions":{"legacy.code":1}}`: unknown,
`{"permissions":{"posts.edit":2}}`: invalid,
`{"permissions":{"posts.edit":-2}}`: invalid,
`{"permissions":{"posts.edit":"1"}}`: shape,
`{"permissions":{"posts.edit":1.5}}`: shape,
`{"permissions":{"posts.edit":true}}`: shape,
`{"permissions":{"posts.edit":{"a":1}}}`: shape,
`{"permissions":["posts.edit"]}`: shape,
`{"permissions":"posts.edit"}`: shape,
`{"permissions":null}`: shape,
} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, body, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", message)
}
same(t, stored(t), before)
})
t.Run("a changed locked code is 403 for the limited admin and nothing is written", func(t *testing.T) {
before := stored(t)
const locked = "You cannot change this permission."
// Removing it (leaving it out), denying it and renaming at the same time.
for _, body := range []string{
`{"name":"Sneaky","permissions":{"posts.edit":1}}`,
`{"name":"Sneaky","permissions":{"posts.edit":1,"reports.export":-1}}`,
`{"name":"Sneaky","permissions":{"reports.export":0}}`,
} {
rec := env.expect(t, http.StatusForbidden, http.MethodPut, record, body, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
}
same(t, stored(t), before)
if person := rosterLoad(t, gdb, id); person.Name != "Perm B" {
t.Fatalf("a refused save renamed the person: %q", person.Name)
}
// Granting it where it is not stored is refused too.
bare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true})
rec := env.expect(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, bare), `{"permissions":{"reports.export":1}}`, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
if person := rosterLoad(t, gdb, bare); person.Permissions != nil {
t.Fatalf("a refused save wrote %q", *person.Permissions)
}
// The limited admin may change the other codes while the locked one
// keeps its stored value.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1,"reports.export":1}}`, "limited")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1, "reports.export": 1})
// The full admin may change it.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1}}`, "bearer")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1})
})
t.Run("a field hidden on create is not written by a create", func(t *testing.T) {
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1","permissions":{"posts.edit":1}}`, "bearer")
created := rosterRecord(t, rec.Body.Bytes())
newID, _ := created.Data["id"].(float64)
if person := rosterLoad(t, gdb, uint(newID)); person.Permissions != nil {
t.Fatalf("create wrote permissions %q", *person.Permissions)
}
if got, ok := created.Data["permissions"].(map[string]any); !ok || len(got) != 0 {
t.Fatalf("create answered permissions %v", created.Data["permissions"])
}
})
t.Run("boot rules", func(t *testing.T) {
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", "")},
"field permissions: mode must be radio or checkbox on type: permissioneditor", rosterFieldsFile)
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: tabs\n")},
"mode must be radio or checkbox on type: permissioneditor")
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: radio\n default: 1\n")},
"default is not valid on type: permissioneditor")
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n mode: radio\n")},
"mode is only valid on type: fileupload, datepicker or permissioneditor")
if err := rosterBoot(t, rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: checkbox\n")})); err != nil {
t.Fatalf("mode: checkbox did not boot: %v", err)
}
// A controller without the provider cannot have the field.
err := activateFields(t, datepickerFields(" rights:\n type: permissioneditor\n mode: checkbox\n"))
const want = "field rights: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want %q", err, want)
}
})
}

View File

@@ -103,6 +103,9 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
if err := compileDateFields(item.plugin.ID(), compiled); err != nil { if err := compileDateFields(item.plugin.ID(), compiled); err != nil {
return nil, err return nil, err
} }
if err := compilePermissionFields(item.plugin.ID(), compiled); err != nil {
return nil, err
}
if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil { if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil {
return nil, err return nil, err
} }

View File

@@ -21,6 +21,8 @@ var relationFormRefusedTypes = map[string]bool{
"relation": true, "relation-manager": true, "widget": true, "partial": true, "relation": true, "relation-manager": true, "widget": true, "partial": true,
// A password is a virtual field, and only an admin controller lists those. // A password is a virtual field, and only an admin controller lists those.
"password": true, "password": true,
// A permission editor's options and storage belong to an admin controller.
"permissioneditor": true,
} }
// pivotFieldPattern is WinterCMS's pivot form field name, pivot[column]. // pivotFieldPattern is WinterCMS's pivot form field name, pivot[column].

View File

@@ -257,8 +257,9 @@ type FormField struct {
// Path names the controller partial of a `type: partial` field: the // Path names the controller partial of a `type: partial` field: the
// template {ConfigDir}/_{path}.htm (D-09). // template {ConfigDir}/_{path}.htm (D-09).
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
// Mode is the fileupload mode (image or file, default file) or the // Mode is the fileupload mode (image or file, default file), the
// datepicker mode (date, datetime or time, default datetime). // datepicker mode (date, datetime or time, default datetime) or the
// permissioneditor mode (radio or checkbox).
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
// Format is a datepicker's WinterCMS (PHP date) display format; // Format is a datepicker's WinterCMS (PHP date) display format;
// DisplayFormat is the same format in the SPA's moment-style tokens. // DisplayFormat is the same format in the SPA's moment-style tokens.
@@ -304,6 +305,10 @@ type FormField struct {
// Preset makes a text field follow another field of the form while the // Preset makes a text field follow another field of the form while the
// administrator has not edited it, on create only (fields.yaml preset). // administrator has not edited it, on create only (fields.yaml preset).
Preset *FieldPreset `json:"preset,omitempty"` Preset *FieldPreset `json:"preset,omitempty"`
// PermissionOptions are the permissions a `type: permissioneditor` field
// offers the requesting administrator, in display order. They are filled
// per request by the form schema route.
PermissionOptions []PermissionOption `json:"permissionOptions,omitempty"`
optionsMethod string optionsMethod string
} }

View File

@@ -63,7 +63,7 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
} }
for _, field := range fields { for _, field := range fields {
// A settings screen has no admin controller to own actions or view models. // A settings screen has no admin controller to own actions or view models.
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" { if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" || field.Type == permissionFieldType {
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type) return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
} }
if field.Preset != nil { if field.Preset != nil {

View File

@@ -28,3 +28,13 @@ people:
password: Password password: Password
password_confirmation: Repeat the password password_confirmation: Repeat the password
notify: Send a welcome message notify: Send a welcome message
permissions: Permissions
tab_permissions: Permissions
permissions:
tab_content: Content
tab_reports: Reports
posts_edit: Edit posts
posts_edit_comment: Change the text of any post.
posts_publish: Publish posts
reports_export: Export reports
misc_beta: Try beta features

View File

@@ -28,3 +28,13 @@ people:
password: Hasło password: Hasło
password_confirmation: Powtórz hasło password_confirmation: Powtórz hasło
notify: Wyślij wiadomość powitalną notify: Wyślij wiadomość powitalną
permissions: Uprawnienia
tab_permissions: Uprawnienia
permissions:
tab_content: Treści
tab_reports: Raporty
posts_edit: Edycja wpisów
posts_edit_comment: Zmiana treści dowolnego wpisu.
posts_publish: Publikowanie wpisów
reports_export: Eksport raportów
misc_beta: Funkcje beta

View File

@@ -26,6 +26,12 @@ fields:
type: checkbox type: checkbox
default: true default: true
context: create context: create
permissions:
label: acme.roster::lang.people.permissions
type: permissioneditor
mode: radio
tab: acme.roster::lang.people.tab_permissions
context: update
joined_ip: joined_ip:
label: acme.roster::lang.people.joined_ip label: acme.roster::lang.people.joined_ip
type: text type: text

View File

@@ -220,3 +220,10 @@ messages:
update_submit: Save record update_submit: Save record
pivot_submit: Save link details pivot_submit: Save link details
link_submit: Add link link_submit: Add link
permissioneditor:
allow: Allow
inherit: Inherit
deny: Deny
locked: You cannot change this permission.
empty: No permissions are defined yet.
other: Other

View File

@@ -242,3 +242,10 @@ messages:
update_submit: Zapisz rekord update_submit: Zapisz rekord
pivot_submit: Zapisz szczegóły powiązania pivot_submit: Zapisz szczegóły powiązania
link_submit: Dodaj powiązanie link_submit: Dodaj powiązanie
permissioneditor:
allow: Zezwól
inherit: Dziedzicz
deny: Odmów
locked: Nie możesz zmienić tego uprawnienia.
empty: Nie zdefiniowano jeszcze żadnych uprawnień.
other: Inne