feat: SPA Roles screens

This commit is contained in:
Florian Sylvain committed 2026-10-27 23:05:04 +01:00
1 parent 907949aa12
commit 3db490a615
47 files changed
+6947 -9

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { effectiveScope, formatGrantKey, parseGrantKey } from '@/lib/permissions'
import { setScope, scopeAt, isCustomRow, resetRow } from '@/lib/roleForm'
import { canCover } from '@/lib/grantLocks'
import { baseKey } from '@/lib/permissionNames'
describe('parseGrantKey and formatGrantKey', () => {
it('parses a bare key without a type', () => {
expect(parseGrantKey('content.read')).toEqual({ base: 'content.read' })
})
it('parses a typed key', () => {
expect(parseGrantKey('content.read:events')).toEqual({
base: 'content.read',
typeKey: 'events',
})
})
it('splits on the first colon only so a type key may contain a colon', () => {
expect(parseGrantKey('content.update:ns:case-studies')).toEqual({
base: 'content.update',
typeKey: 'ns:case-studies',
})
})
it('formats and parses back to the same parts', () => {
const key = formatGrantKey('content.update', 'ns:case-studies')
expect(key).toBe('content.update:ns:case-studies')
expect(parseGrantKey(key)).toEqual({ base: 'content.update', typeKey: 'ns:case-studies' })
expect(formatGrantKey('content.update')).toBe('content.update')
})
it('keeps a colon type key working through the form, locks and names', () => {
const grants = setScope({}, 'content.update', 'own', 'ns:a')
expect(grants).toEqual({ 'content.update:ns:a': 'own' })
expect(scopeAt(grants, 'content.update', 'ns:a')).toBe('own')
expect(
effectiveScope([{ key: 'content.update:ns:a', scope: 'own' }], 'content.update', {
typeKey: 'ns:a',
}),
).toBe('own')
expect(isCustomRow(grants, 'ns:a')).toBe(true)
expect(resetRow(grants, 'ns:a')).toEqual({})
expect(baseKey('content.update:ns:a')).toBe('content.update')
const held: string[] = []
canCover({ grants: [{ key: 'content.update:ns:a', scope: 'own' }] }, (key, options) => {
held.push(`${key}|${options?.typeKey}`)
return 'all'
})
expect(held).toEqual(['content.update|ns:a'])
})
})
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { canCover, lockReason, lockedScopes } from '@/lib/grantLocks'
import { effectiveScope, type Grant } from '@/lib/permissions'
import { grantsFrom } from '@/test/access'
import type { RoleItem } from '@/lib/roles'
function scopeFor(spec: string) {
const grants: Grant[] = grantsFrom(spec)
return (key: string, options?: { typeKey?: string }) => effectiveScope(grants, key, options)
}
function role(spec: string): Pick<RoleItem, 'grants'> {
return { grants: grantsFrom(spec) }
}
describe('lockedScopes', () => {
it('locks nothing when the caller holds All', () => {
expect(lockedScopes('all')).toEqual([])
})
it('locks All when the caller holds only Own', () => {
expect(lockedScopes('own')).toEqual(['all'])
})
it('keeps None available and locks Own and All when the caller holds nothing', () => {
expect(lockedScopes('none')).toEqual(['own', 'all'])
})
})
describe('lockReason', () => {
it('names the action when the caller holds Own', () => {
expect(lockReason('content.publish', 'own')).toBe(
"You can only grant what your role has. Your role can publish only entries you created, so All isn't available.",
)
})
it('falls back to the generic reason for non-content keys', () => {
expect(lockReason('media.delete', 'own')).toBe('You can only grant what your role has.')
})
it('has no reason when the caller holds All', () => {
expect(lockReason('content.publish', 'all')).toBeUndefined()
})
it('a typed none in the session locks the matching cell through the scope lookup', () => {
const scopeOf = scopeFor('content.update=all, content.update:article=none')
expect(lockedScopes(scopeOf('content.update', { typeKey: 'article' }))).toEqual([
'own',
'all',
])
expect(lockedScopes(scopeOf('content.update', { typeKey: 'events' }))).toEqual([])
})
})
describe('canCover', () => {
it('covers a role whose every grant is within the caller scope', () => {
expect(
canCover(
role('content.read=all, content.update=own'),
scopeFor('content.read, content.update=all'),
),
).toBe(true)
})
it('refuses a role that grants more than the caller holds', () => {
expect(canCover(role('content.publish=all'), scopeFor('content.publish=own'))).toBe(false)
expect(canCover(role('content.read'), scopeFor('content.update'))).toBe(false)
})
it('checks typed grants against the caller typed scope', () => {
const scopeOf = scopeFor('content.update=all, content.update:article=own')
expect(canCover(role('content.update:article=all'), scopeOf)).toBe(false)
expect(canCover(role('content.update:events=all'), scopeOf)).toBe(true)
})
})
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest'
import {
activePreset,
applyPreset,
differences,
emptyForm,
formFromRole,
grantsToPayload,
isCustomRow,
resetRow,
scopeAt,
setScope,
unsavedCount,
} from '@/lib/roleForm'
const CONTRIBUTOR = {
name: 'Contributor',
description: 'Creates content and edits only their own.',
grants: [
{ key: 'content.read', scope: 'all' as const },
{ key: 'content.create', scope: 'all' as const },
{ key: 'content.update', scope: 'own' as const },
{ key: 'content.update:case-studies', scope: 'all' as const },
],
}
const NO_OWNER_ONLY = new Set<string>()
describe('formFromRole', () => {
it('fills name, description and the grants by key', () => {
const form = formFromRole(CONTRIBUTOR)
expect(form.name).toBe('Contributor')
expect(form.description).toBe('Creates content and edits only their own.')
expect(form.grants['content.update:case-studies']).toBe('all')
})
})
describe('scopeAt', () => {
it('reads the typed grant first, then the wildcard, then none', () => {
const { grants } = formFromRole(CONTRIBUTOR)
expect(scopeAt(grants, 'content.update', 'case-studies')).toBe('all')
expect(scopeAt(grants, 'content.update', 'events')).toBe('own')
expect(scopeAt(grants, 'content.delete', 'events')).toBe('none')
})
})
describe('grantsToPayload', () => {
it('sends wildcard grants and typed grants, omits none wildcards, keeps typed none', () => {
const grants = setScope(
setScope(
{ 'content.read': 'all', 'content.delete': 'none' },
'content.update',
'none',
'events',
),
'content.publish',
'own',
)
expect(grantsToPayload(grants, NO_OWNER_ONLY)).toEqual([
{ key: 'content.read', scope: 'all' },
{ key: 'content.update:events', scope: 'none' },
{ key: 'content.publish', scope: 'own' },
])
})
it('never sends an owner-only key', () => {
const payload = grantsToPayload(
{ 'content.read': 'all', 'system.apply_updates': 'all' },
new Set(['system.apply_updates']),
)
expect(payload).toEqual([{ key: 'content.read', scope: 'all' }])
})
it('never puts an owner-only key in a custom-role payload', () => {
const ownerOnly = [
'settings.delete_site',
'roles.edit_owner_only',
'system.apply_updates',
'system.edit_retention',
'system.transfer_ownership',
]
const grants = Object.fromEntries([
['content.read', 'all'],
...ownerOnly.map((key) => [key, 'all']),
])
const payload = grantsToPayload(grants, new Set(ownerOnly))
expect(payload).toEqual([{ key: 'content.read', scope: 'all' }])
})
it('sends nothing for a new role with nothing granted', () => {
expect(grantsToPayload(emptyForm().grants, NO_OWNER_ONLY)).toEqual([])
})
})
describe('custom rows', () => {
it('marks a type with a typed grant as custom and Reset removes its typed grants', () => {
const { grants } = formFromRole(CONTRIBUTOR)
expect(isCustomRow(grants, 'case-studies')).toBe(true)
expect(isCustomRow(grants, 'events')).toBe(false)
const reset = resetRow(grants, 'case-studies')
expect(isCustomRow(reset, 'case-studies')).toBe(false)
expect(scopeAt(reset, 'content.update', 'case-studies')).toBe('own')
})
it('treats a typed none as a custom row', () => {
expect(isCustomRow({ 'content.read:events': 'none' }, 'events')).toBe(true)
})
it('setting the All content types row clears the typed grants of that action', () => {
const next = setScope({ 'content.update:events': 'all' }, 'content.update', 'own')
expect(next).toEqual({ 'content.update': 'own' })
})
})
describe('presets', () => {
it.each([
['read_only', ['all', 'none', 'none', 'none', 'none']],
['write_own', ['all', 'all', 'own', 'none', 'none']],
['full_content', ['all', 'all', 'all', 'all', 'all']],
] as const)('%s sets every content row and is then the active preset', (preset, expected) => {
const grants = applyPreset({ 'content.update:events': 'none', 'media.view': 'all' }, preset)
expect(
['read', 'create', 'update', 'delete', 'publish'].map((action) =>
scopeAt(grants, `content.${action}`, 'events'),
),
).toEqual(expected)
expect(grants['media.view']).toBe('all')
expect(activePreset(grants)).toBe(preset)
})
it('has no active preset once a type row is customised', () => {
const grants = setScope(applyPreset({}, 'write_own'), 'content.update', 'all', 'events')
expect(activePreset(grants)).toBeNull()
})
})
describe('unsavedCount', () => {
it('counts changed entries (a cleared typed override included), one each for name and description', () => {
const initial = formFromRole(CONTRIBUTOR)
const current = {
...initial,
name: 'Contributor 2',
grants: setScope(
setScope(initial.grants, 'content.update', 'all'),
'content.delete',
'own',
),
}
expect(unsavedCount(initial, initial)).toBe(0)
expect(unsavedCount(current, initial)).toBe(4)
})
it('counts a Write own preset on an empty form', () => {
const current = { ...emptyForm(), grants: applyPreset({}, 'write_own') }
expect(unsavedCount(current, emptyForm())).toBe(3)
})
it('Discard returns to zero', () => {
const initial = formFromRole(CONTRIBUTOR)
expect(unsavedCount({ ...initial, grants: { ...initial.grants } }, initial)).toBe(0)
})
})
describe('differences', () => {
it('lists keys that differ between two forms', () => {
const ours = formFromRole(CONTRIBUTOR)
const theirs = formFromRole({
...CONTRIBUTOR,
grants: [{ key: 'content.read', scope: 'all' }],
})
expect(
differences(ours, theirs)
.map((entry) => entry.key)
.sort(),
).toEqual(['content.create', 'content.update', 'content.update:case-studies'])
})
})
+134
View File
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it } from 'vitest'
import { applyPreset, setScope } from '@/lib/roleForm'
import { buildRolePreview } from '@/lib/rolePreview'
import { setLocale } from '@/i18n'
const TYPES = [
{ key: 'article', name: 'Articles' },
{ key: 'events', name: 'Events' },
{ key: 'case-studies', name: 'Case studies' },
]
afterEach(() => setLocale('en'))
describe('buildRolePreview content', () => {
it('reads the Contributor sentence from the Write own grants', () => {
const preview = buildRolePreview({
grants: applyPreset({}, 'write_own'),
types: TYPES,
members: 4,
})
expect(preview.content).toEqual([
{
id: 'all',
label: 'All content types',
sentence:
"Can read any entry, create entries and edit only their own. Can't delete and publish.",
changed: false,
},
])
})
it('says No access to entries when nothing is granted', () => {
const preview = buildRolePreview({ grants: {}, types: TYPES })
expect(preview.content[0]).toMatchObject({
label: 'All content types',
sentence: 'No access to entries.',
})
expect(preview.noAccess).toBe(
'No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.',
)
expect(preview.everything).toEqual([])
})
it('writes one line per group of types that share permissions', () => {
const grants = setScope(applyPreset({}, 'write_own'), 'content.update', 'all', 'events')
const preview = buildRolePreview({ grants, types: TYPES })
expect(preview.content.map((line) => line.label)).toEqual([
'Articles, Case studies',
'Events',
])
expect(preview.content[1]!.sentence).toContain('edit any entry')
})
})
describe('buildRolePreview everything else', () => {
it('reads the Schema, Media and Authors sentences and lists the areas without access', () => {
const preview = buildRolePreview({
grants: {
...applyPreset({}, 'write_own'),
'schema.view': 'all',
'media.view': 'all',
'media.upload': 'all',
'authors.view': 'all',
'authors.edit': 'own',
},
types: TYPES,
})
expect(preview.everything.map((line) => [line.label, line.sentence])).toEqual([
['Schema', 'Can see content types and fields.'],
['Media', 'Can browse the media library and upload files.'],
['Authors', 'Can see authors and edit authors, only their own.'],
])
expect(preview.noAccess).toBe('No access to Activity, Users and invites, Settings, Roles.')
})
})
describe('buildRolePreview own scope without an own wording', () => {
it('never renders the all wording for an own grant on a phrase without an own variant', () => {
const preview = buildRolePreview({
grants: { 'users.view': 'own', 'users.invite': 'all', 'media.delete': 'own' },
types: TYPES,
})
const all = [...preview.everything.map((line) => line.sentence), preview.noAccess ?? '']
const text = all.join(' ')
expect(text).not.toContain('see users')
expect(text).toContain('invite users')
expect(preview.everything.find((line) => line.id === 'media')?.sentence).toContain(
'only their own',
)
})
})
describe('buildRolePreview Changed tagging and members', () => {
it('tags the line whose sentence differs from the initial grants', () => {
const initial = applyPreset({}, 'write_own')
const grants = setScope(initial, 'content.update', 'all')
const preview = buildRolePreview({ grants, types: TYPES, initial, members: 4 })
expect(preview.content[0]!.changed).toBe(true)
expect(preview.content[0]!.sentence).toContain('edit any entry')
})
it('tags nothing when the grants equal the initial ones', () => {
const initial = applyPreset({}, 'write_own')
expect(
buildRolePreview({ grants: initial, types: TYPES, initial }).content[0]!.changed,
).toBe(false)
})
it('tags nothing without initial grants', () => {
expect(
buildRolePreview({ grants: applyPreset({}, 'write_own'), types: TYPES }).content[0]!
.changed,
).toBe(false)
})
it('states the member count, in the singular for one', () => {
expect(buildRolePreview({ grants: {}, types: TYPES, members: 4 }).members).toBe(
'Applies to 4 members, and to the API keys they created.',
)
expect(buildRolePreview({ grants: {}, types: TYPES, members: 1 }).members).toBe(
'Applies to 1 member, and to the API keys they created.',
)
expect(buildRolePreview({ grants: {}, types: TYPES, members: 0 }).members).toBeNull()
})
})
+5
View File
@@ -6,6 +6,11 @@ export const KNOWN_ERROR_CODES = [
'role_built_in',
'built_in_name',
'role_conflict',
'role_name_taken',
'role_has_members',
'invalid_role_name',
'invalid_reassign',
'role_not_found',
'user_role_changed',
'session_required',
] as const
+41
View File
@@ -0,0 +1,41 @@
import { t } from '@/i18n'
import { parseGrantKey, type Scope } from '@/lib/permissions'
import type { RoleItem } from '@/lib/roles'
export type CallerScope = (key: string, options?: { typeKey?: string }) => Scope
const RANK: Record<Scope, number> = { none: 0, own: 1, all: 2 }
const CONTENT_VERBS: Record<string, () => string> = {
'content.read': () => t('read'),
'content.update': () => t('update'),
'content.delete': () => t('delete'),
'content.publish': () => t('publish'),
}
export function lockedScopes(caller: Scope): Scope[] {
if (caller === 'all') {
return []
}
return caller === 'own' ? ['all'] : ['own', 'all']
}
export function lockReason(key: string, caller: Scope): string | undefined {
if (caller === 'all') {
return undefined
}
const base = t('You can only grant what your role has.')
const verb = CONTENT_VERBS[key]
if (caller === 'own' && verb !== undefined) {
return `${base} ${t("Your role can {0} only entries you created, so All isn't available.", [verb()])}`
}
return base
}
export function canCover(role: Pick<RoleItem, 'grants'>, scopeOf: CallerScope): boolean {
return role.grants.every((grant) => {
const { base, typeKey } = parseGrantKey(grant.key)
const held = typeKey === undefined ? scopeOf(base) : scopeOf(base, { typeKey })
return RANK[held] >= RANK[grant.scope]
})
}
+5
View File
@@ -49,6 +49,11 @@ export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCop
case 'role_conflict':
case 'user_role_changed':
return { title: t('This changed somewhere else. Reload and try again.') }
case 'role_name_taken':
case 'role_has_members':
case 'invalid_role_name':
case 'invalid_reassign':
case 'role_not_found':
case 'session_required':
return undefined
}
+44 -1
View File
@@ -1,4 +1,5 @@
import { t } from '@/i18n'
import { parseGrantKey } from '@/lib/permissions'
import type { MessageKey } from '@/i18n/messages'
const NAMES = {
@@ -41,10 +42,52 @@ const NAMES = {
export const PERMISSION_KEYS = Object.keys(NAMES)
export function baseKey(key: string) {
return key.split(':')[0]!
return parseGrantKey(key).base
}
export function permissionName(key: string): string | undefined {
const name = NAMES[baseKey(key) as keyof typeof NAMES]
return name === undefined ? undefined : t(name)
}
const DESCRIPTIONS = {
'content.read': 'See entries of this type.',
'content.create': 'Add new entries.',
'content.update': 'Change existing entries.',
'content.delete': 'Remove entries.',
'content.publish': 'Make entries public.',
'schema.view': 'See content types and their fields.',
'schema.edit': 'Add content types and change their fields.',
'schema.delete': 'Remove content types.',
'media.view': 'Browse the images and files.',
'media.upload': 'Add images and files.',
'media.delete': 'Remove images and files.',
'authors.view': 'See the list of authors.',
'authors.create': 'Add authors.',
'authors.edit': 'Change author profiles.',
'authors.delete': 'Remove authors.',
'activity.view': 'Read the log of who did what.',
'activity.export': 'Download the log as a CSV file.',
'users.view': 'See who has an account.',
'users.invite': 'Send invitations by email.',
'users.change_role': 'Give someone another role.',
'users.remove': 'Take away someone’s access.',
'settings.view': 'Read the site settings.',
'settings.edit_general': 'Change the site settings.',
'settings.manage_sites': 'Add, edit and remove sites.',
'settings.delete_site': 'Remove a site and its content.',
'apikeys.view': 'See the API keys.',
'apikeys.create': 'Make new API keys.',
'apikeys.revoke': 'Stop an API key from working.',
'roles.view': 'See roles and what they allow.',
'roles.manage': 'Create, edit, duplicate and delete roles.',
'roles.edit_owner_only': 'Change a role that carries owner-only abilities.',
'system.apply_updates': 'Install a new version of RenewCMS.',
'system.edit_retention': 'How long the activity log is kept.',
'system.transfer_ownership': 'Make another member the owner.',
} as const satisfies Record<keyof typeof NAMES, MessageKey>
export function permissionDescription(key: string): string | undefined {
const description = DESCRIPTIONS[baseKey(key) as keyof typeof DESCRIPTIONS]
return description === undefined ? undefined : t(description)
}
+13 -1
View File
@@ -29,6 +29,18 @@ export interface ScopeOptions {
const TYPE_SEPARATOR = ':'
export function formatGrantKey(key: string, typeKey?: string) {
return typeKey === undefined ? key : `${key}${TYPE_SEPARATOR}${typeKey}`
}
export function parseGrantKey(key: string): { base: string; typeKey?: string } {
const at = key.indexOf(TYPE_SEPARATOR)
if (at === -1) {
return { base: key }
}
return { base: key.slice(0, at), typeKey: key.slice(at + TYPE_SEPARATOR.length) }
}
function scopeOfGrant(grants: readonly Grant[], key: string): Scope | undefined {
return grants.find((grant) => grant.key === key)?.scope
}
@@ -39,7 +51,7 @@ export function effectiveScope(
options: ScopeOptions = {},
): Scope {
if (options.typeKey) {
const typed = scopeOfGrant(grants, `${key}${TYPE_SEPARATOR}${options.typeKey}`)
const typed = scopeOfGrant(grants, formatGrantKey(key, options.typeKey))
if (typed !== undefined) {
return typed
}
+183
View File
@@ -0,0 +1,183 @@
import { formatGrantKey, parseGrantKey, type Grant, type Scope } from '@/lib/permissions'
import type { RoleItem } from '@/lib/roles'
export type GrantMap = Record<string, Scope>
export interface RoleForm {
name: string
description: string
grants: GrantMap
}
export const CONTENT_ACTIONS = ['read', 'create', 'update', 'delete', 'publish'] as const
export type ContentAction = (typeof CONTENT_ACTIONS)[number]
export const PRESET_NAMES = ['read_only', 'write_own', 'full_content'] as const
export type PresetName = (typeof PRESET_NAMES)[number]
const CONTENT_PREFIX = 'content.'
const PRESETS: Record<PresetName, Record<ContentAction, Scope>> = {
read_only: { read: 'all', create: 'none', update: 'none', delete: 'none', publish: 'none' },
write_own: { read: 'all', create: 'all', update: 'own', delete: 'none', publish: 'none' },
full_content: { read: 'all', create: 'all', update: 'all', delete: 'all', publish: 'all' },
}
export function contentKey(action: ContentAction) {
return `${CONTENT_PREFIX}${action}`
}
export function typedKey(key: string, typeKey: string) {
return formatGrantKey(key, typeKey)
}
function isTyped(key: string) {
return parseGrantKey(key).typeKey !== undefined
}
function typedKeysOf(grants: GrantMap, key: string) {
return Object.keys(grants).filter((existing) => {
const parsed = parseGrantKey(existing)
return parsed.typeKey !== undefined && parsed.base === key
})
}
export function emptyForm(): RoleForm {
return { name: '', description: '', grants: {} }
}
export function formFromRole(role: Pick<RoleItem, 'name' | 'description' | 'grants'>): RoleForm {
const grants: GrantMap = {}
for (const grant of role.grants) {
grants[grant.key] = grant.scope
}
return { name: role.name, description: role.description, grants }
}
export function cloneForm(form: RoleForm): RoleForm {
return { name: form.name, description: form.description, grants: { ...form.grants } }
}
export function scopeAt(grants: GrantMap, key: string, typeKey?: string): Scope {
if (typeKey !== undefined) {
const typed = grants[typedKey(key, typeKey)]
if (typed !== undefined) {
return typed
}
}
return grants[key] ?? 'none'
}
export function setScope(grants: GrantMap, key: string, scope: Scope, typeKey?: string): GrantMap {
const next = { ...grants }
if (typeKey !== undefined) {
next[typedKey(key, typeKey)] = scope
return next
}
for (const typed of typedKeysOf(next, key)) {
delete next[typed]
}
next[key] = scope
return next
}
export function isCustomRow(grants: GrantMap, typeKey: string) {
return CONTENT_ACTIONS.some(
(action) => grants[typedKey(contentKey(action), typeKey)] !== undefined,
)
}
export function resetRow(grants: GrantMap, typeKey: string): GrantMap {
const next = { ...grants }
for (const action of CONTENT_ACTIONS) {
delete next[typedKey(contentKey(action), typeKey)]
}
return next
}
export function applyPreset(grants: GrantMap, preset: PresetName): GrantMap {
const next: GrantMap = {}
for (const [key, scope] of Object.entries(grants)) {
if (!key.startsWith(CONTENT_PREFIX)) {
next[key] = scope
}
}
for (const action of CONTENT_ACTIONS) {
next[contentKey(action)] = PRESETS[preset][action]
}
return next
}
export function activePreset(grants: GrantMap): PresetName | null {
const hasTyped = Object.keys(grants).some(
(key) => key.startsWith(CONTENT_PREFIX) && isTyped(key),
)
if (hasTyped) {
return null
}
return (
PRESET_NAMES.find((preset) =>
CONTENT_ACTIONS.every(
(action) => scopeAt(grants, contentKey(action)) === PRESETS[preset][action],
),
) ?? null
)
}
export function grantsToPayload(grants: GrantMap, ownerOnlyKeys: ReadonlySet<string>): Grant[] {
return Object.entries(grants)
.filter(([key, scope]) => {
if (ownerOnlyKeys.has(key)) {
return false
}
return isTyped(key) || scope !== 'none'
})
.map(([key, scope]) => ({ key, scope }))
}
function rawEntry(grants: GrantMap, key: string): Scope | undefined {
return grants[key] ?? (isTyped(key) ? undefined : 'none')
}
export function unsavedCount(current: RoleForm, initial: RoleForm): number {
let count = 0
if (current.name !== initial.name) {
count++
}
if (current.description !== initial.description) {
count++
}
const keys = new Set([...Object.keys(current.grants), ...Object.keys(initial.grants)])
for (const key of keys) {
if (rawEntry(current.grants, key) !== rawEntry(initial.grants, key)) {
count++
}
}
return count
}
export function isCellChanged(
current: GrantMap,
initial: GrantMap,
key: string,
typeKey?: string,
): boolean {
return scopeAt(current, key, typeKey) !== scopeAt(initial, key, typeKey)
}
export interface FormDifference {
key: string
before: Scope | undefined
after: Scope | undefined
}
export function differences(ours: RoleForm, theirs: RoleForm): FormDifference[] {
const keys = new Set([...Object.keys(ours.grants), ...Object.keys(theirs.grants)])
return [...keys]
.map((key) => ({
key,
before: rawEntry(theirs.grants, key),
after: rawEntry(ours.grants, key),
}))
.filter((entry) => entry.before !== entry.after)
}
+54
View File
@@ -0,0 +1,54 @@
import { t } from '@/i18n'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import type { RoleItem } from '@/lib/roles'
export interface RoleMenuActions {
canManage: boolean
edit: (role: RoleItem) => void
duplicate: (role: RoleItem) => void
remove: (role: RoleItem, trigger: HTMLElement | null) => void
}
function readOnlyHint() {
return t('You can see roles, but only people who manage roles can create or change them.')
}
function deleteHint(role: RoleItem, canManage: boolean): string | undefined {
if (role.locked) {
return t('The Owner role can’t be deleted.')
}
if (role.built_in) {
return t("Built-in roles can't be deleted. Duplicate it to make a custom role.")
}
return canManage ? undefined : readOnlyHint()
}
export function roleMenuItems(role: RoleItem, actions: RoleMenuActions): DirectoryMenuItem[] {
const hint = deleteHint(role, actions.canManage)
return [
{
key: 'edit',
label: t('Edit'),
icon: 'i-lucide-pencil',
onSelect: () => actions.edit(role),
},
{
key: 'duplicate',
label: t('Duplicate'),
icon: 'i-lucide-copy',
unavailable: !actions.canManage,
hint: actions.canManage ? undefined : readOnlyHint(),
onSelect: () => actions.duplicate(role),
},
{
key: 'delete',
label: t('Delete'),
icon: 'i-lucide-trash-2',
destructive: true,
separated: true,
unavailable: hint !== undefined,
hint,
onSelect: (trigger) => actions.remove(role, trigger),
},
]
}
+289
View File
@@ -0,0 +1,289 @@
import { t } from '@/i18n'
import { CONTENT_ACTIONS, contentKey, scopeAt, type GrantMap } from '@/lib/roleForm'
import { parseGrantKey, type Scope } from '@/lib/permissions'
export interface PreviewType {
key: string
name: string
}
export interface PreviewLine {
id: string
label: string
sentence: string
changed: boolean
}
export interface RolePreviewModel {
content: PreviewLine[]
everything: PreviewLine[]
noAccess: string | null
members: string | null
}
export interface PreviewInput {
grants: GrantMap
types: PreviewType[]
members?: number | null
initial?: GrantMap
}
interface Phrase {
key: string
all: () => string
own?: () => string
}
interface Area {
id: string
label: () => string
phrases: Phrase[]
}
const ONLY_THEIR_OWN = (phrase: () => string) => () => `${phrase()}, ${t('only their own')}`
const AREAS: Area[] = [
{
id: 'schema',
label: () => t('Schema'),
phrases: [
{ key: 'schema.view', all: () => t('see content types and fields') },
{ key: 'schema.edit', all: () => t('create and edit content types') },
{ key: 'schema.delete', all: () => t('delete content types') },
],
},
{
id: 'media',
label: () => t('Media'),
phrases: [
{ key: 'media.view', all: () => t('browse the media library') },
{ key: 'media.upload', all: () => t('upload files') },
{
key: 'media.delete',
all: () => t('delete files'),
own: ONLY_THEIR_OWN(() => t('delete files')),
},
],
},
{
id: 'authors',
label: () => t('Authors'),
phrases: [
{ key: 'authors.view', all: () => t('see authors') },
{ key: 'authors.create', all: () => t('create authors') },
{
key: 'authors.edit',
all: () => t('edit authors'),
own: ONLY_THEIR_OWN(() => t('edit authors')),
},
{ key: 'authors.delete', all: () => t('delete authors') },
],
},
{
id: 'activity',
label: () => t('Activity'),
phrases: [
{
key: 'activity.view',
all: () => t('see activity'),
own: ONLY_THEIR_OWN(() => t('see activity')),
},
{ key: 'activity.export', all: () => t('export activity') },
],
},
{
id: 'users',
label: () => t('Users and invites'),
phrases: [
{ key: 'users.view', all: () => t('see users') },
{ key: 'users.invite', all: () => t('invite users') },
{ key: 'users.change_role', all: () => t("change a user's role") },
{ key: 'users.remove', all: () => t('remove users') },
],
},
{
id: 'settings',
label: () => t('Settings'),
phrases: [
{ key: 'settings.view', all: () => t('see settings') },
{ key: 'settings.edit_general', all: () => t('edit general settings') },
{ key: 'settings.manage_sites', all: () => t('manage sites') },
{ key: 'settings.delete_site', all: () => t('delete a site') },
{
key: 'apikeys.view',
all: () => t('see API keys'),
own: ONLY_THEIR_OWN(() => t('see API keys')),
},
{ key: 'apikeys.create', all: () => t('create API keys') },
{
key: 'apikeys.revoke',
all: () => t('revoke API keys'),
own: ONLY_THEIR_OWN(() => t('revoke API keys')),
},
],
},
{
id: 'roles',
label: () => t('Roles'),
phrases: [
{ key: 'roles.view', all: () => t('see roles') },
{ key: 'roles.manage', all: () => t('manage roles') },
{ key: 'roles.edit_owner_only', all: () => t('edit roles with owner-only power') },
],
},
{
id: 'system',
label: () => t('System'),
phrases: [
{ key: 'system.apply_updates', all: () => t('apply software updates') },
{ key: 'system.edit_retention', all: () => t('edit activity retention') },
{ key: 'system.transfer_ownership', all: () => t('transfer ownership') },
],
},
]
const OWNER_ONLY_AREA = 'system'
const GRANTED_CONTENT: Record<string, Record<'all' | 'own', () => string>> = {
read: { all: () => t('read any entry'), own: () => t('read only their own entries') },
create: { all: () => t('create entries'), own: () => t('create entries') },
update: { all: () => t('edit any entry'), own: () => t('edit only their own') },
delete: { all: () => t('delete any entry'), own: () => t('delete only their own entries') },
publish: { all: () => t('publish any entry'), own: () => t('publish only their own entries') },
}
const REFUSED_CONTENT: Record<string, () => string> = {
read: () => t('read'),
create: () => t('create'),
update: () => t('edit'),
delete: () => t('delete'),
publish: () => t('publish'),
}
export function joinList(items: string[]): string {
if (items.length <= 1) {
return items.join('')
}
const head = items.slice(0, -1).join(', ')
return t('{0} and {1}', [head, items[items.length - 1]])
}
function contentSentence(scopes: Scope[]): string {
const granted: string[] = []
const refused: string[] = []
CONTENT_ACTIONS.forEach((action, index) => {
const scope = scopes[index]!
if (scope === 'none') {
refused.push(REFUSED_CONTENT[action]!())
return
}
granted.push(GRANTED_CONTENT[action]![scope]())
})
if (granted.length === 0) {
return t('No access to entries.')
}
const can = t('Can {0}.', [joinList(granted)])
return refused.length === 0 ? can : `${can} ${t("Can't {0}.", [joinList(refused)])}`
}
function contentLines(
grants: GrantMap,
types: PreviewType[],
): { id: string; label: string; sentence: string }[] {
const hasOverrides = Object.keys(grants).some(
(key) => key.startsWith('content.') && parseGrantKey(key).typeKey !== undefined,
)
if (!hasOverrides || types.length === 0) {
const scopes = CONTENT_ACTIONS.map((action) => scopeAt(grants, contentKey(action)))
return [{ id: 'all', label: t('All content types'), sentence: contentSentence(scopes) }]
}
const groups = new Map<string, { names: string[]; scopes: Scope[] }>()
for (const type of types) {
const scopes = CONTENT_ACTIONS.map((action) =>
scopeAt(grants, contentKey(action), type.key),
)
const signature = scopes.join('|')
const group = groups.get(signature) ?? { names: [], scopes }
group.names.push(type.name)
groups.set(signature, group)
}
return [...groups.entries()].map(([signature, group]) => ({
id: signature,
label: group.names.join(', '),
sentence: contentSentence(group.scopes),
}))
}
function areaSentence(area: Area, grants: GrantMap): string | null {
const phrases: string[] = []
for (const phrase of area.phrases) {
const scope = scopeAt(grants, phrase.key)
if (scope === 'none') {
continue
}
if (scope === 'own') {
if (phrase.own) {
phrases.push(phrase.own())
}
continue
}
phrases.push(phrase.all())
}
return phrases.length === 0 ? null : t('Can {0}.', [joinList(phrases)])
}
function everythingLines(grants: GrantMap) {
const lines: { id: string; label: string; sentence: string }[] = []
const missing: string[] = []
for (const area of AREAS) {
const sentence = areaSentence(area, grants)
if (sentence !== null) {
lines.push({ id: area.id, label: area.label(), sentence })
} else if (area.id !== OWNER_ONLY_AREA) {
missing.push(area.label())
}
}
return { lines, missing }
}
function membersLine(members: number | null | undefined): string | null {
if (!members) {
return null
}
return members === 1
? t('Applies to 1 member, and to the API keys they created.')
: t('Applies to {0} members, and to the API keys they created.', [members])
}
function tag(
lines: { id: string; label: string; sentence: string }[],
before: { id: string; sentence: string }[] | undefined,
): PreviewLine[] {
return lines.map((line) => ({
...line,
changed:
before !== undefined &&
!before.some((old) => old.id === line.id && old.sentence === line.sentence),
}))
}
export function buildRolePreview(input: PreviewInput): RolePreviewModel {
const content = contentLines(input.grants, input.types)
const everything = everythingLines(input.grants)
const before =
input.initial === undefined
? undefined
: {
content: contentLines(input.initial, input.types),
everything: everythingLines(input.initial).lines,
}
return {
content: tag(content, before?.content),
everything: tag(everything.lines, before?.everything),
noAccess:
everything.missing.length === 0
? null
: t('No access to {0}.', [everything.missing.join(', ')]),
members: membersLine(input.members),
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { Grant } from '@/lib/permissions'
export interface RoleEditor {
id: number
name: string
}
export interface RoleItem {
id: number
name: string
description: string
built_in: boolean
locked: boolean
version: number
grants: Grant[]
members: number
updated_at: string | null
updated_by: RoleEditor | null
}
export interface RolePayload {
name: string
description: string
grants: Grant[]
}
export type ScopeKind = 'scoped' | 'toggle'
export interface PermissionItem {
key: string
scope_kind: ScopeKind
owner_only: boolean
per_type: boolean
}
export interface PermissionGroupItem {
key: string
items: PermissionItem[]
}
export const OWNER_ONLY_FALLBACK = new Set([
'settings.delete_site',
'roles.edit_owner_only',
'system.apply_updates',
'system.edit_retention',
'system.transfer_ownership',
])
export function formatClock(iso: string | null | undefined, language: string): string {
if (!iso) {
return ''
}
return new Date(iso).toLocaleTimeString(language === 'fr' ? 'fr' : 'en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}