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()
})
})