feat: SPA Roles screens
This commit is contained in:
1 parent
907949aa12
commit
3db490a615
47 files changed
+6947
-9
No files matched your search
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user