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

+330
View File
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import DashboardShell from '@/components/DashboardShell.vue'
import PermissionGrid from '@/components/PermissionGrid.vue'
import PermissionGroup from '@/components/PermissionGroup.vue'
import PresetPills from '@/components/PresetPills.vue'
import RoleNotices from '@/components/RoleNotices.vue'
import RolePreview from '@/components/RolePreview.vue'
import RoleSaveBar from '@/components/RoleSaveBar.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useLocalizedOverlay } from '@/i18n/presentation'
import { useRoleEditor } from '@/composables/useRoleEditor'
import type { CallerScope } from '@/lib/grantLocks'
import { usePermissionsStore } from '@/stores/permissions'
const DESKTOP_QUERY = '(min-width: 64rem)'
const CONTENT_GROUP = 'content'
const permissions = usePermissionsStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const editor = useRoleEditor()
const overlay = useLocalizedOverlay()
const sheetOpen = ref(false)
const openGroups = ref<string[]>([])
const callerScope: CallerScope = (key, options) => permissions.scopeOf(key, options)
const generalGroups = computed(() =>
editor.catalogue.value.filter((group) => group.key !== CONTENT_GROUP),
)
const allOpen = computed(
() => generalGroups.value.length > 0 && openGroups.value.length === generalGroups.value.length,
)
const title = computed(() => (editor.isNew.value ? t('New role') : (editor.role.value?.name ?? '')))
const warnMembers = computed(() => editor.role.value?.built_in === true && !editor.readOnly.value)
const canDuplicate = computed(() => permissions.can('roles.manage'))
const footerVisible = computed(
() => editor.barVisible.value || (!isDesktop.value && editor.loadState.value === 'ready'),
)
const fieldsDisabled = computed(() => editor.readOnly.value || editor.saving.value)
const typeLabel = computed(() => {
const role = editor.role.value
if (!role) {
return ''
}
return role.built_in ? t('Built-in') : t('Custom')
})
function toggleGroup(key: string, open: boolean) {
openGroups.value = open
? [...openGroups.value.filter((existing) => existing !== key), key]
: openGroups.value.filter((existing) => existing !== key)
}
function toggleAll() {
openGroups.value = allOpen.value ? [] : generalGroups.value.map((group) => group.key)
}
function confirmReset() {
const target = editor.role.value
if (!target) {
return
}
overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Reset {0} to default?', [target.name])
},
get description() {
return t('Its permissions go back to what RenewCMS ships with.')
},
get confirmLabel() {
return t('Reset to default')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Resetting…')
},
tone: 'danger',
action: editor.resetRole,
},
})
.open()
}
onMounted(editor.load)
</script>
<template>
<DashboardShell>
<div
v-if="editor.loadState.value === 'loading'"
class="flex flex-col gap-3"
aria-busy="true"
>
<USkeleton class="h-8 w-60 rounded-none bg-accented" />
<USkeleton class="h-40 w-full rounded-none bg-accented" />
</div>
<UAlert
v-else-if="editor.loadState.value === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load this role')"
:description="t('Check your connection and try again.')"
>
<template #actions>
<UButton color="neutral" variant="outline" class="h-8" @click="editor.load">
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UEmpty
v-else-if="editor.loadState.value === 'missing'"
variant="naked"
icon="i-lucide-shield"
:title="t('This role no longer exists')"
:description="t('It may have been deleted.')"
:ui="{ root: 'gap-3 border border-default bg-default px-6 py-14' }"
>
<template #actions>
<UButton color="neutral" variant="outline" to="/settings/roles">
{{ t('Back to Roles') }}
</UButton>
</template>
</UEmpty>
<div v-else class="flex flex-col gap-4" :class="{ 'pb-20': !isDesktop }">
<div class="flex flex-wrap items-center gap-3">
<UButton
to="/settings/roles"
color="neutral"
variant="ghost"
icon="i-lucide-arrow-left"
:aria-label="t('Back to Roles')"
class="size-8 justify-center max-md:size-11"
/>
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
<template v-if="editor.role.value">
<UBadge
v-if="editor.role.value.locked"
color="neutral"
variant="outline"
icon="i-lucide-lock"
:label="t('Locked')"
/>
<UBadge color="neutral" variant="subtle" :label="typeLabel" />
<span class="text-muted">{{
editor.role.value.members === 1
? t('1 member')
: t('{0} members', [editor.role.value.members])
}}</span>
</template>
<UButton
v-if="editor.canReset.value"
color="neutral"
variant="outline"
class="ms-auto h-8 px-3"
@click="confirmReset"
>
{{ t('Reset to default') }}
</UButton>
</div>
<RoleNotices
:role="editor.role.value"
:reason="editor.readOnlyReason.value"
:conflict="editor.conflict.value"
:refusal="editor.refusal.value"
:can-duplicate="canDuplicate"
:warn-members="warnMembers"
:reviewing="editor.reviewing.value"
:differences="editor.differenceRows.value"
@duplicate="editor.duplicate"
@reload-role="editor.reloadRole"
@reload-page="editor.reloadPage"
@review="editor.toggleReview"
/>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div
class="flex min-w-0 flex-col gap-6"
:class="{ 'opacity-60': editor.saving.value }"
:aria-busy="editor.saving.value ? 'true' : undefined"
>
<section class="flex flex-col gap-4">
<UFormField
:label="t('Name')"
:error="editor.nameError.value || undefined"
required
>
<UInput
:model-value="editor.form.value.name"
:placeholder="t('For example, Contributor')"
:disabled="fieldsDisabled"
class="w-full"
@update:model-value="editor.setName(String($event))"
/>
</UFormField>
<UFormField :label="t('Description (optional)')">
<UInput
:model-value="editor.form.value.description"
:placeholder="t('What this role is for')"
:disabled="fieldsDisabled"
class="w-full"
@update:model-value="editor.setDescription(String($event))"
/>
</UFormField>
</section>
<section class="flex flex-col gap-3">
<div class="flex flex-col gap-0.5">
<h2 class="text-base font-semibold text-highlighted">
{{ t('Content permissions') }}
</h2>
<p class="text-muted">
{{
t(
'What this role can do with entries of each content type. Own means entries the person created.',
)
}}
</p>
</div>
<PresetPills
:active="editor.preset.value"
:disabled="editor.readOnly.value"
@select="editor.choosePreset"
/>
<PermissionGrid
:types="editor.gridTypes.value"
:grants="editor.form.value.grants"
:initial="editor.initial.value.grants"
:caller-scope="callerScope"
:read-only="editor.readOnly.value"
:mobile="!isDesktop"
@change="editor.changeCell"
@reset="editor.resetType"
/>
</section>
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-end justify-between gap-2">
<div class="flex flex-col gap-0.5">
<h2 class="text-base font-semibold text-highlighted">
{{ t('General permissions') }}
</h2>
<p class="text-muted">{{ t('Everything outside entries.') }}</p>
</div>
<UButton
color="neutral"
variant="outline"
class="h-8 px-3 max-md:h-11"
@click="toggleAll"
>
{{ allOpen ? t('Collapse all') : t('Expand all') }}
</UButton>
</div>
<div class="border border-default bg-default">
<PermissionGroup
v-for="group in generalGroups"
:key="group.key"
:open="openGroups.includes(group.key)"
:group="group"
:grants="editor.form.value.grants"
:initial="editor.initial.value.grants"
:caller-scope="callerScope"
:read-only="editor.readOnly.value"
@update:open="toggleGroup(group.key, $event)"
@change="editor.changeGeneral"
/>
</div>
</section>
</div>
<aside v-if="isDesktop" class="min-w-0">
<div class="sticky top-4">
<RolePreview
:model="editor.preview.value"
:read-only="editor.readOnly.value"
/>
</div>
</aside>
</div>
</div>
<div v-if="footerVisible" class="sticky bottom-0 z-10 -mx-4 mt-4 flex flex-col">
<UButton
v-if="!isDesktop"
color="neutral"
variant="outline"
block
class="h-11 justify-center"
@click="sheetOpen = true"
>
{{ t('What this role can do') }}
</UButton>
<RoleSaveBar
v-if="editor.barVisible.value"
:count="editor.unsaved.value"
:problems="editor.problems.value"
:saving="editor.saving.value"
:conflicted="editor.conflict.value !== null"
:can-save="editor.canSave.value"
:name-missing="editor.nameMissing.value"
:is-new="editor.isNew.value"
@discard="editor.discard"
@save="editor.save"
/>
</div>
<RolePreview
v-if="!isDesktop"
v-model:open="sheetOpen"
mobile
:model="editor.preview.value"
:read-only="editor.readOnly.value"
/>
</DashboardShell>
</template>
+214
View File
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
import DeleteRoleDialog from '@/components/DeleteRoleDialog.vue'
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
import RolesList from '@/components/RolesList.vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import type { LoadState } from '@/composables/useRoleEditor'
import { useRoles } from '@/composables/useRoles'
import { canCover } from '@/lib/grantLocks'
import type { RoleItem } from '@/lib/roles'
import { usePermissionsStore } from '@/stores/permissions'
const DESKTOP_QUERY = '(min-width: 48rem)'
const router = useRouter()
const toast = useLocalizedToast()
const permissions = usePermissionsStore()
const { listRoles } = useRoles()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const skeleton = useDelayedSkeleton()
const loadState = ref<LoadState>('loading')
const roles = ref<RoleItem[]>([])
const deleting = ref<RoleItem | null>(null)
const dialogOpen = ref(false)
const canManage = computed(() => permissions.can('roles.manage'))
const onlyBuiltIn = computed(
() => loadState.value === 'ready' && roles.value.every((role) => role.built_in),
)
const showList = computed(
() => loadState.value === 'ready' || (loadState.value === 'loading' && skeleton.visible.value),
)
const newRoleDisabled = computed(() => !canManage.value || loadState.value === 'loading')
function canAssign(role: RoleItem) {
return canCover(role, permissions.scopeOf)
}
async function load() {
loadState.value = 'loading'
skeleton.start()
try {
roles.value = await listRoles()
loadState.value = 'ready'
} catch {
loadState.value = 'error'
} finally {
skeleton.stop()
}
}
function goNew() {
void router.push('/settings/roles/new')
}
const actions = computed(() => ({
canManage: canManage.value,
edit: (role: RoleItem) => void router.push(`/settings/roles/${role.id}`),
duplicate: (role: RoleItem) =>
void router.push({ path: '/settings/roles/new', query: { from: String(role.id) } }),
remove: (role: RoleItem) => {
deleting.value = role
dialogOpen.value = true
},
}))
function onDeleted(role: RoleItem) {
roles.value = roles.value.filter((item) => item.id !== role.id)
toast.add(() => ({
id: `role-deleted-${role.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Role deleted')
},
get description() {
return t('{0} is gone.', [role.name])
},
}))
}
function onDialogOpenChange(isOpen: boolean) {
if (!isOpen) {
deleting.value = null
}
}
function onDuplicate(role: RoleItem) {
void router.push({ path: '/settings/roles/new', query: { from: String(role.id) } })
}
onMounted(load)
</script>
<template>
<DashboardShell>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Roles') }}</h1>
<p class="text-muted">
{{ t('Who can do what on this site. Each user has one role.') }}
</p>
</div>
<UTooltip
:text="
t(
'You can see roles, but only people who manage roles can create or change them.',
)
"
:disabled="canManage"
>
<span class="inline-flex" :tabindex="canManage ? undefined : 0">
<UButton
icon="i-lucide-plus"
class="h-8 px-3 max-md:h-11"
:disabled="newRoleDisabled"
@click="goNew"
>
{{ t('New role') }}
</UButton>
</span>
</UTooltip>
</div>
<ReadOnlyBanner
v-if="!canManage"
:title="t('You can see roles but not change them')"
:description="t('Ask an owner or admin if a role needs to change.')"
/>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load roles')"
:description="t('GET /v1/role failed. Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
@click="load"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<RolesList
v-if="showList"
:roles="roles"
:loading="loadState === 'loading'"
:desktop="isDesktop"
:actions="actions"
/>
<UEmpty
v-if="onlyBuiltIn"
variant="naked"
icon="i-lucide-shield"
:title="t('No custom roles yet')"
:description="
t(
'The three built-in roles cover most sites. Create a role when someone needs something in between.',
)
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-10',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md/7 font-semibold',
description: 'max-w-90 text-muted text-pretty',
}"
>
<template #actions>
<UButton
icon="i-lucide-plus"
class="h-8 px-4 max-md:h-11"
:disabled="!canManage"
@click="goNew"
>
{{ t('New role') }}
</UButton>
</template>
</UEmpty>
</div>
<DeleteRoleDialog
v-if="deleting"
:key="deleting.id"
v-model:open="dialogOpen"
:role="deleting"
:roles="roles"
:can-assign="canAssign"
@update:open="onDialogOpenChange"
@deleted="onDeleted"
@gone="load"
@duplicate="onDuplicate"
/>
</DashboardShell>
</template>
@@ -0,0 +1,881 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import RoleEditorView from '../RoleEditorView.vue'
import { radiosOf } from '@/components/__tests__/roleKit'
import {
Backend,
CATALOGUE,
buttonNamed,
grants,
mountRoleView,
role,
textOf,
type Handler,
} from './roleKit'
import { adminAccess, customAccess, ownerAccess } from '@/test/access'
import type { Access } from '@/lib/permissions'
import type { RoleItem } from '@/lib/roles'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toastAdd = vi.fn()
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const JONAS = { id: 2, name: 'Jonas Weber' }
const CONTRIBUTOR = role({
id: 4,
name: 'Contributor',
description: 'Creates content and edits only their own.',
version: 2,
members: 4,
grants: grants('content.read, content.create, content.update=own, content.update:case-studies'),
})
const WRITER = role({
id: 3,
name: 'Writer',
built_in: true,
version: 1,
members: 5,
grants: grants('content.read, content.create, content.update'),
})
const OWNER_ROLE = role({ id: 1, name: 'Owner', built_in: true, locked: true, members: 1 })
const TYPES = [
{
id: 1,
key: 'events',
name: 'Events',
icon: null,
fields: [],
created_at: '',
updated_at: '',
},
{
id: 2,
key: 'case-studies',
name: 'Case studies',
icon: null,
fields: [],
created_at: '',
updated_at: '',
},
]
let wrapper: VueWrapper | null = null
function refuse(status: number, code: string, details: Record<string, string> = {}) {
return () => {
throw new ApiError(status, undefined, code, { code: code as never, ...details })
}
}
function backendFor(loaded: RoleItem | null, extra: Record<string, Handler> = {}) {
return new Backend({
'GET /v1/permission/': () => ({ groups: CATALOGUE }),
'GET /v1/content-type': () => TYPES,
...(loaded ? { [`GET /v1/role/${loaded.id}`]: () => loaded } : {}),
...extra,
})
}
async function open(
path: string,
backend: Backend,
access: Access = ownerAccess(),
options: { desktop?: boolean; userId?: number } = {},
) {
wrapper = await mountRoleView(RoleEditorView, path, { access, backend, ...options })
return wrapper
}
beforeEach(() => {
toastAdd.mockReset()
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
function page() {
return textOf(document.body)
}
function nameInput() {
return document.querySelector<HTMLInputElement>(
'input[placeholder="For example, Contributor"]',
)!
}
function descriptionInput() {
return document.querySelector<HTMLInputElement>('input[placeholder="What this role is for"]')!
}
async function type(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function choose(name: string, scope: 'None' | 'Own' | 'All') {
const radio = radiosOf(document.body, name).find((candidate) => textOf(candidate) === scope)!
radio.click()
await flushPromises()
}
async function toggle(name: string) {
document.body.querySelector<HTMLElement>(`[role="switch"][aria-label="${name}"]`)!.click()
await flushPromises()
}
async function press(label: string) {
buttonNamed(label)!.click()
await flushPromises()
}
function checked(name: string) {
return textOf(
radiosOf(document.body, name).find(
(radio) => radio.getAttribute('aria-checked') === 'true',
)!,
)
}
function saveBar() {
return document.body.querySelector('[data-testid="role-save-bar"]')
}
function toastTitles() {
return toastAdd.mock.calls.map((call) => ({
title: call[0].title,
description: call[0].description,
}))
}
describe('Role editor: New role (empty)', () => {
it('starts with nothing granted, a disabled Save and the hint', async () => {
await open('/settings/roles/new', backendFor(null))
const text = page()
expect(text).toContain('New role')
expect(text).toContain('Name your role to save it')
expect(buttonNamed('Save changes')!.disabled).toBe(true)
expect(nameInput().value).toBe('')
expect(descriptionInput().value).toBe('')
for (const copy of [
'Content permissions',
'Quick presets',
'Read only',
'Write own',
'Full content',
'All content types',
'Articles',
'Events',
'General permissions',
'Expand all',
]) {
expect(text).toContain(copy)
}
expect(checked('All content types: Read')).toBe('None')
expect(checked('Events: Delete')).toBe('None')
})
})
describe('Role editor: Editing a custom role', () => {
it('fills the fields and shows the member count, Custom and the Case studies Reset link', async () => {
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
expect(nameInput().value).toBe('Contributor')
expect(descriptionInput().value).toBe('Creates content and edits only their own.')
expect(page()).toContain('4 members')
expect(page()).toContain('Custom')
const row = document.body.querySelector('[data-testid="row-case-studies"]')!
expect(textOf(row)).toContain('Custom')
expect(buttonNamed('Reset', row)).toBeDefined()
expect(saveBar()).toBeNull()
})
it('Reset removes the typed grants and the row follows All content types again', async () => {
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
expect(checked('Case studies: Update')).toBe('All')
buttonNamed(
'Reset',
document.body.querySelector('[data-testid="row-case-studies"]')!,
)!.click()
await flushPromises()
expect(checked('Case studies: Update')).toBe('Own')
expect(
textOf(document.body.querySelector('[data-testid="row-case-studies"]')!),
).not.toContain('Custom')
expect(page()).toContain('1 unsaved change')
})
})
describe('Role editor: Unsaved changes', () => {
it('counts three cell changes, marks them, tags the preview and Discard restores', async () => {
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
await choose('Events: Read', 'None')
await choose('Events: Delete', 'Own')
await toggle('Events: Create')
expect(page()).toContain('3 unsaved changes')
expect(
document.body.querySelectorAll('[data-testid="changed-marker"]').length,
).toBeGreaterThanOrEqual(3)
expect(page()).toContain('Changed')
await press('Discard')
expect(saveBar()).toBeNull()
expect(checked('Events: Read')).toBe('All')
expect(checked('Events: Delete')).toBe('None')
})
})
describe('Role editor: Saving then saved', () => {
it('sends the PUT with the current version, dims while in flight and closes the bar on 200', async () => {
let release: (value: unknown) => void = () => undefined
const inFlight = new Promise((resolve) => {
release = resolve
})
const backend = backendFor(CONTRIBUTOR, { 'PUT /v1/role/4': () => inFlight })
await open('/settings/roles/4', backend)
await choose('All content types: Update', 'All')
await press('Save changes')
const put = backend.callsTo('PUT', '/v1/role/4')[0]!
expect(put.body).toEqual({
name: 'Contributor',
description: 'Creates content and edits only their own.',
grants: [
{ key: 'content.read', scope: 'all' },
{ key: 'content.create', scope: 'all' },
{ key: 'content.update', scope: 'all' },
],
version: 2,
})
expect(page()).toContain('Saving…')
expect(buttonNamed('Discard')!.disabled).toBe(true)
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
release({
...CONTRIBUTOR,
version: 3,
grants: grants('content.read, content.create, content.update'),
})
await flushPromises()
expect(saveBar()).toBeNull()
expect(toastTitles()).toEqual([
{ title: 'Role saved', description: 'Contributor is updated for 4 members.' },
])
await choose('All content types: Update', 'Own')
await press('Save changes')
expect((backend.callsTo('PUT', '/v1/role/4')[1]!.body as { version: number }).version).toBe(
3,
)
})
})
describe('Role editor: Creating', () => {
it.each([
['nothing granted', false, []],
[
'the Write own preset',
true,
[
{ key: 'content.read', scope: 'all' },
{ key: 'content.create', scope: 'all' },
{ key: 'content.update', scope: 'own' },
],
],
])('saves a new role named Contributor with %s', async (_label, preset, expected) => {
const backend = backendFor(null, {
'POST /v1/role/': () => role({ id: 9, name: 'Contributor', version: 1 }),
'GET /v1/role/9': () => role({ id: 9, name: 'Contributor', version: 1 }),
})
await open('/settings/roles/new', backend)
await type(nameInput(), 'Contributor')
if (preset) {
await press('Write own')
}
await press('Save changes')
const post = backend.callsTo('POST', '/v1/role/')[0]!
expect(post.body).toEqual({ name: 'Contributor', description: '', grants: expected })
expect(toastTitles()).toEqual([
{ title: 'Role created', description: 'Contributor is ready. Assign it from Users.' },
])
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/settings/roles/9'))
expect(saveBar()).toBeNull()
})
})
describe('Role editor: Presets', () => {
it.each([
['Read only', ['All', 'None', 'None', 'None']],
['Write own', ['All', 'On', 'Own', 'None']],
['Full content', ['All', 'On', 'All', 'All']],
])('%s sets every type row and counts unsaved changes', async (preset, expected) => {
await open('/settings/roles/new', backendFor(null))
await press(preset)
expect(buttonNamed(preset)!.getAttribute('aria-pressed')).toBe('true')
for (const type of ['Events', 'Articles']) {
const [read, create, update, deleteScope] = expected
expect(checked(`${type}: Read`)).toBe(read)
const create_ = document.body.querySelector(
`[role="switch"][aria-label="${type}: Create"]`,
)!
expect(create_.getAttribute('aria-checked')).toBe(create === 'On' ? 'true' : 'false')
expect(checked(`${type}: Update`)).toBe(update === 'On' ? 'All' : update)
expect(checked(`${type}: Delete`)).toBe(deleteScope)
}
expect(page()).toMatch(/\d+ unsaved changes?/)
})
})
describe('Role editor: Errors on save', () => {
async function failWith(handler: Handler, edit?: () => Promise<void>) {
const backend = backendFor(CONTRIBUTOR, { 'PUT /v1/role/4': handler })
await open('/settings/roles/4', backend)
await choose('Events: Delete', 'Own')
await edit?.()
await press('Save changes')
return backend
}
it('sends nothing for an empty name and reads the field error and the count', async () => {
const backend = await failWith(
() => undefined,
() => type(nameInput(), ''),
)
expect(backend.callsTo('PUT', '/v1/role/4')).toHaveLength(0)
expect(page()).toContain('Give the role a name.')
expect(page()).toContain('1 problem to fix')
})
it('maps 400 invalid_role_name to the same field error and keeps the edits', async () => {
await failWith(refuse(400, 'invalid_role_name'))
expect(page()).toContain('Give the role a name.')
expect(checked('Events: Delete')).toBe('Own')
})
it('maps 409 role_name_taken to the named field error', async () => {
await failWith(refuse(409, 'role_name_taken'), () => type(nameInput(), 'Writer'))
expect(page()).toContain('A role named "Writer" already exists. Choose a different name.')
expect(page()).toContain('1 problem to fix')
expect(nameInput().value).toBe('Writer')
})
it('maps 400 built_in_name to the built-in message', async () => {
await failWith(refuse(400, 'built_in_name'))
expect(page()).toContain('Built-in roles keep their name.')
})
it('shows the Conflict state after re-reading the role, and Reload role replaces the form', async () => {
let reads = 0
const backend = new Backend({
'GET /v1/permission/': () => ({ groups: CATALOGUE }),
'GET /v1/content-type': () => TYPES,
'GET /v1/role/4': () =>
++reads === 1
? CONTRIBUTOR
: {
...CONTRIBUTOR,
version: 3,
name: 'Contributor',
grants: grants('content.read'),
updated_at: '2026-10-27T11:02:00Z',
updated_by: JONAS,
},
'PUT /v1/role/4': refuse(409, 'role_conflict'),
})
await open('/settings/roles/4', backend)
await choose('Events: Delete', 'Own')
await choose('Events: Read', 'None')
await press('Save changes')
expect(page()).toContain('This role changed while you were editing')
expect(page()).toContain(
'Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits.',
)
expect(page()).toContain('2 unsaved changes. Save is off until you reload')
expect(buttonNamed('Save changes')!.disabled).toBe(true)
expect(buttonNamed('Review differences')).toBeDefined()
expect(checked('Events: Delete')).toBe('Own')
await press('Review differences')
expect(document.body.querySelector('[data-testid="role-differences"]')).not.toBeNull()
await press('Reload role')
expect(page()).not.toContain('This role changed while you were editing')
expect(checked('Events: Delete')).toBe('None')
expect(saveBar()).toBeNull()
})
it('turns the page read-only with Your access changed on 403 missing_permission, and Reload page refreshes', async () => {
const backend = await failWith(
refuse(403, 'missing_permission', { permission: 'roles.manage' }),
)
expect(page()).toContain('Your access changed')
expect(page()).toContain(
"An owner changed your role while this page was open. You can still see roles, but you can't change them. Your edits weren't saved.",
)
expect(saveBar()).toBeNull()
expect(radiosOf(document.body, 'Events: Read')[0]!.getAttribute('aria-disabled')).toBe(
'true',
)
expect(nameInput().disabled).toBe(true)
expect(buttonNamed('Reload page')).toBeDefined()
await press('Reload page')
expect(backend.callsTo('GET', '/v1/role/4').length).toBeGreaterThanOrEqual(2)
})
it('shows the escalation refusal and keeps the edits', async () => {
await failWith(refuse(403, 'escalation', { escalationKey: 'content.publish' }))
expect(page()).toContain("You can't grant that")
expect(page()).toContain('You can only grant what your role has.')
expect(page()).toContain('Publish entries')
expect(checked('Events: Delete')).toBe('Own')
})
it('turns read-only with This role is locked on 403 role_locked', async () => {
await failWith(refuse(403, 'role_locked'))
expect(page()).toContain('This role is locked')
expect(page()).toContain(
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.",
)
expect(saveBar()).toBeNull()
expect(nameInput().disabled).toBe(true)
})
it("shows You can't change your own role on 403 own_role", async () => {
await failWith(refuse(403, 'own_role'))
expect(page()).toContain("You can't change your own role")
expect(page()).toContain('Ask another owner or admin.')
})
it('shows a generic toast for another failure', async () => {
await failWith(refuse(500, 'boom'))
expect(toastTitles()).toHaveLength(1)
expect(saveBar()).not.toBeNull()
})
})
describe('Role editor: Read-only editor', () => {
it('warns the owner about the 5 members of Writer, tags Built-in and offers Reset to default', async () => {
await open('/settings/roles/3', backendFor(WRITER))
expect(page()).toContain('This role has 5 members')
expect(page()).toContain(
'Changes apply to all of them when you save, and to the API keys they created. Built-in roles can be restored with Reset to default.',
)
expect(page()).toContain('Built-in')
expect(nameInput().disabled).toBe(false)
expect(buttonNamed('Reset to default')).toBeDefined()
})
it('locks everything for an admin and offers Duplicate role', async () => {
await open('/settings/roles/3', backendFor(WRITER), adminAccess(), { userId: 7 })
expect(page()).toContain('Only the owner can edit built-in roles')
expect(page()).toContain(
'You can read what Writer allows. To change it, duplicate it into a custom role.',
)
expect(nameInput().disabled).toBe(true)
expect(radiosOf(document.body, 'Events: Read')[0]!.getAttribute('aria-disabled')).toBe(
'true',
)
expect(saveBar()).toBeNull()
expect(page()).not.toContain('Reset to default')
expect(page()).not.toContain('Updates as you edit.')
expect(page()).toContain('A summary of what this role allows.')
await press('Duplicate role')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings/roles/new'))
await vi.waitFor(() => expect(nameInput().disabled).toBe(false))
expect(checked('All content types: Update')).toBe('All')
})
it('shows the banner and no Save bar for roles.view only', async () => {
await open(
'/settings/roles/4',
backendFor(CONTRIBUTOR),
customAccess('roles.view', 'Viewer', 9),
)
expect(document.body.querySelector('[data-testid="read-only-banner"]')).not.toBeNull()
expect(nameInput().disabled).toBe(true)
expect(saveBar()).toBeNull()
})
it('reads the own-role message when the person opens their own role', async () => {
await open(
'/settings/roles/4',
backendFor(CONTRIBUTOR),
customAccess('roles.view, roles.manage', 'Contributor', 4),
)
expect(page()).toContain(
"You can't change the permissions of your own role. Ask another owner or admin.",
)
expect(nameInput().disabled).toBe(true)
expect(saveBar()).toBeNull()
})
it('locks the Owner role for anyone with roles.view and offers Go to Users', async () => {
await open('/settings/roles/1', backendFor(OWNER_ROLE), ownerAccess())
expect(page()).toContain('The Owner role is locked')
expect(page()).toContain(
'It always has every permission. To hand it to someone else, transfer ownership from Users.',
)
expect(page()).toContain('Locked')
expect(buttonNamed('Go to Users')).toBeDefined()
expect(nameInput().disabled).toBe(true)
})
it('gates owner-only editing on the permission, not on a role name', async () => {
const renamed = { ...WRITER, id: 8, name: 'Renamed built-in' }
await open('/settings/roles/8', backendFor(renamed), adminAccess(), { userId: 7 })
expect(page()).toContain('Only the owner can edit built-in roles')
})
})
describe('Role editor: General permissions', () => {
it('lists the groups with "x of y enabled" and Expand all opens them', async () => {
const loaded = { ...CONTRIBUTOR, grants: grants('activity.view, activity.export') }
await open('/settings/roles/4', backendFor(loaded))
expect(page()).toContain('Activity')
expect(page()).toContain('2 of 2 enabled')
expect(page()).not.toContain('Read the log of who did what.')
await press('Expand all')
expect(page()).toContain('See activity')
expect(page()).toContain('Read the log of who did what.')
expect(page()).toContain('Export activity')
})
})
describe('Role editor: Owner-only items', () => {
it('keeps them disabled and off for an admin and never sends their key', async () => {
const backend = backendFor(CONTRIBUTOR, {
'PUT /v1/role/4': () => ({ ...CONTRIBUTOR, version: 3 }),
})
await open('/settings/roles/4', backend, adminAccess(), { userId: 7 })
await press('Expand all')
const toggleSwitch = document.body.querySelector(
'[role="switch"][aria-label="Apply software updates"]',
)!
expect(toggleSwitch.getAttribute('aria-checked')).toBe('false')
expect(toggleSwitch.hasAttribute('disabled')).toBe(true)
expect(document.body.querySelector('[data-testid="owner-only-tag"]')).not.toBeNull()
await choose('Events: Delete', 'Own')
await press('Save changes')
const sent = JSON.stringify(backend.callsTo('PUT', '/v1/role/4')[0]!.body)
expect(sent).not.toContain('system.apply_updates')
})
})
describe('Role editor: Escalation locks', () => {
it('locks Publish All when the session can publish only its own, and keeps None and Own', async () => {
await open(
'/settings/roles/new',
backendFor(null),
customAccess(
'roles.view, roles.manage, schema.view, content.read, content.publish=own',
'Editor',
9,
),
)
const radios = radiosOf(document.body, 'Events: Publish')
const all = radios.find((radio) => textOf(radio) === 'All')!
expect(all.getAttribute('aria-disabled')).toBe('true')
expect(document.getElementById(all.getAttribute('aria-describedby')!)!.textContent).toBe(
"You can only grant what your role has. Your role can publish only entries you created, so All isn't available.",
)
for (const label of ['None', 'Own']) {
expect(
radios.find((radio) => textOf(radio) === label)!.getAttribute('aria-disabled'),
).toBeNull()
}
})
it('locks a cell for a typed override in the session', async () => {
await open(
'/settings/roles/new',
backendFor(null),
customAccess(
'roles.view, roles.manage, schema.view, content.read, content.update, content.update:events=none',
'Editor',
9,
),
)
expect(
radiosOf(document.body, 'Events: Update')
.find((radio) => textOf(radio) === 'Own')!
.getAttribute('aria-disabled'),
).toBe('true')
expect(
radiosOf(document.body, 'Case studies: Update')
.find((radio) => textOf(radio) === 'Own')!
.getAttribute('aria-disabled'),
).toBeNull()
})
})
describe('Role editor: Plain-language preview', () => {
it('updates live and tags the changed line', async () => {
await open(
'/settings/roles/4',
backendFor({
...CONTRIBUTOR,
grants: grants('content.read, content.create, content.update=own'),
}),
)
const preview = () => textOf(document.body.querySelector('[data-testid="role-preview"]')!)
expect(preview()).toContain('What this role can do')
expect(preview()).toContain('Updates as you edit.')
expect(preview()).toContain(
"Can read any entry, create entries and edit only their own. Can't delete and publish.",
)
expect(preview()).toContain('Applies to 4 members, and to the API keys they created.')
expect(preview()).not.toContain('Changed')
await choose('All content types: Update', 'All')
expect(preview()).toContain('edit any entry')
expect(preview()).toContain('Changed')
})
it('reads No access to entries for a new role with nothing granted', async () => {
await open('/settings/roles/new', backendFor(null))
const preview = textOf(document.body.querySelector('[data-testid="role-preview"]')!)
expect(preview).toContain('No access to entries.')
expect(preview).toContain(
'No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.',
)
})
it('opens a bottom sheet with Close from the sticky bar on mobile and draws one card per type', async () => {
await open('/settings/roles/new', backendFor(null), ownerAccess(), { desktop: false })
expect(document.body.querySelector('[data-testid="card-events"]')).not.toBeNull()
expect(document.body.querySelector('[role="grid"]')).toBeNull()
await press('What this role can do')
const sheet = document.body.querySelector('[role="dialog"]')!
expect(textOf(sheet)).toContain('Updates as you edit.')
buttonNamed('Close', sheet)!.click()
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
})
describe('Role editor: Duplicate', () => {
it('opens prefilled with the source grants, an empty name, and saves with no built_in or locked field', async () => {
const backend = backendFor(WRITER, {
'POST /v1/role/': () => role({ id: 10, name: 'Writer copy' }),
'GET /v1/role/10': () => role({ id: 10, name: 'Writer copy' }),
})
await open('/settings/roles/new?from=3', backend, adminAccess(), { userId: 7 })
expect(nameInput().value).toBe('')
expect(checked('All content types: Read')).toBe('All')
expect(checked('All content types: Update')).toBe('All')
expect(router.currentRoute.value.fullPath).toBe('/settings/roles/new')
await type(nameInput(), 'Writer copy')
await press('Save changes')
const body = backend.callsTo('POST', '/v1/role/')[0]!.body as Record<string, unknown>
expect(Object.keys(body).sort()).toEqual(['description', 'grants', 'name'])
expect(body.grants).toEqual([
{ key: 'content.read', scope: 'all' },
{ key: 'content.create', scope: 'all' },
{ key: 'content.update', scope: 'all' },
])
})
})
describe('Role editor: stale responses', () => {
it('drops a slow response for a role the user already navigated away from', async () => {
let release: (value: RoleItem) => void = () => {}
const slow = new Promise<RoleItem>((resolve) => {
release = resolve
})
const backend = backendFor(CONTRIBUTOR, { 'GET /v1/role/3': () => slow })
await open('/settings/roles/3', backend)
await router.push('/settings/roles/4')
await flushPromises()
expect(nameInput().value).toBe('Contributor')
release(WRITER)
await flushPromises()
expect(nameInput().value).toBe('Contributor')
expect(buttonNamed('Reset to default')).toBeUndefined()
})
})
describe('Role editor: Reset to default', () => {
it('is absent for a custom role and for people without roles.edit_owner_only', async () => {
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
expect(buttonNamed('Reset to default')).toBeUndefined()
wrapper!.unmount()
await open('/settings/roles/3', backendFor(WRITER), adminAccess(), { userId: 7 })
expect(buttonNamed('Reset to default')).toBeUndefined()
})
it('posts the reset with the version and takes the seeded grants', async () => {
const seeded = { ...WRITER, version: 2, grants: grants('content.read') }
const backend = backendFor(WRITER, { 'POST /v1/role/3/reset': () => seeded })
await open('/settings/roles/3', backend)
await press('Reset to default')
const confirm = Array.from(
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
).find((candidate) => textOf(candidate) === 'Reset to default')!
confirm.click()
await flushPromises()
expect(backend.callsTo('POST', '/v1/role/3/reset')[0]!.body).toEqual({ version: 1 })
expect(checked('All content types: Update')).toBe('None')
expect(toastAdd).toHaveBeenCalledTimes(1)
})
it('shows the Conflict state on 409 role_conflict', async () => {
const backend = backendFor(WRITER, {
'POST /v1/role/3/reset': refuse(409, 'role_conflict'),
})
await open('/settings/roles/3', backend)
await press('Reset to default')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
.find((candidate) => textOf(candidate) === 'Reset to default')!
.click()
await flushPromises()
expect(page()).toContain('This role changed while you were editing')
})
})
describe('Role editor: Create a Contributor in under a minute', () => {
it('follows the six steps', async () => {
const backend = backendFor(null, {
'POST /v1/role/': () => role({ id: 11, name: 'Contributor' }),
'GET /v1/role/11': () => role({ id: 11, name: 'Contributor' }),
})
await open('/settings/roles/new', backend)
await type(nameInput(), 'Contributor')
await type(descriptionInput(), 'Writes posts and edits only their own')
await press('Write own')
expect(page()).toContain(
"Can read any entry, create entries and edit only their own. Can't delete and publish.",
)
await press('Save changes')
expect(backend.callsTo('POST', '/v1/role/')[0]!.body).toEqual({
name: 'Contributor',
description: 'Writes posts and edits only their own',
grants: [
{ key: 'content.read', scope: 'all' },
{ key: 'content.create', scope: 'all' },
{ key: 'content.update', scope: 'own' },
],
})
expect(toastTitles()).toEqual([
{ title: 'Role created', description: 'Contributor is ready. Assign it from Users.' },
])
expect(saveBar()).toBeNull()
})
})
describe('Role editor: missing role', () => {
it('offers Back to Roles when the role is gone', async () => {
const backend = backendFor(null, { 'GET /v1/role/99': refuse(404, 'role_not_found') })
await open('/settings/roles/99', backend)
const back = Array.from(document.querySelectorAll('a')).find(
(link) => link.textContent?.trim() === 'Back to Roles',
)
expect(back?.getAttribute('href')).toBe('/settings/roles')
})
})
describe('Role editor: unknown reset failure', () => {
it('toasts the reset failure and keeps the grants', async () => {
const backend = backendFor(WRITER, {
'POST /v1/role/3/reset': () => {
throw new Error('network down')
},
})
await open('/settings/roles/3', backend)
await press('Reset to default')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
.find((candidate) => textOf(candidate) === 'Reset to default')!
.click()
await flushPromises()
const added = toastAdd.mock.calls.map((call) =>
typeof call[0] === 'function' ? call[0]() : call[0],
)
expect(added).toHaveLength(1)
expect(added[0]).toMatchObject({
id: 'role-reset-failed',
color: 'error',
title: "Couldn't reset the role. Try again in a moment.",
})
expect(page()).not.toContain('This role changed while you were editing')
})
})
@@ -0,0 +1,365 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import RolesListView from '../RolesListView.vue'
import { Backend, buttonNamed, grants, mountRoleView, role, textOf } from './roleKit'
import { customAccess, ownerAccess, adminAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
})
const OWNER = role({
id: 1,
name: 'Owner',
built_in: true,
locked: true,
members: 1,
description: 'Everything.',
})
const ADMIN = role({
id: 2,
name: 'Admin',
built_in: true,
members: 2,
description: 'Everything except owner-only.',
})
const WRITER = role({
id: 3,
name: 'Writer',
built_in: true,
members: 5,
description: 'Writes content.',
})
const CONTRIBUTOR = role({
id: 4,
name: 'Contributor',
members: 4,
description: 'Creates content and edits only their own.',
grants: grants('content.read, content.update=own'),
})
const LEAD = role({
id: 5,
name: 'Content lead',
members: 1,
description: 'Edits and publishes all content.',
})
let wrapper: VueWrapper | null = null
function listBackend(items = [OWNER, ADMIN, WRITER, CONTRIBUTOR, LEAD], extra = {}) {
return new Backend({ 'GET /v1/role/': () => items, ...extra })
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
document.body.innerHTML = ''
await router.push('/login')
})
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
function rows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="role-row"]'))
}
function rowOf(name: string) {
return rows().find((row) => textOf(row).startsWith(name))!
}
async function openMenu(name: string) {
rowOf(name).querySelector<HTMLElement>('button[aria-label^="Actions for"]')!.click()
await flushPromises()
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
}
function item(items: HTMLElement[], label: string) {
return items.find((candidate) => textOf(candidate).startsWith(label))!
}
describe('Roles list: Default', () => {
it('shows the header, the columns and a row per role with its type badge', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend: listBackend(),
})
const text = textOf(document.body)
expect(text).toContain('Who can do what on this site. Each user has one role.')
for (const column of ['Role', 'Description', 'Members', 'Type']) {
expect(text).toContain(column)
}
expect(rows()).toHaveLength(5)
expect(textOf(rowOf('Owner'))).toContain('Locked')
expect(textOf(rowOf('Owner'))).toContain('Built-in')
expect(textOf(rowOf('Writer'))).toContain('Built-in')
expect(textOf(rowOf('Contributor'))).toContain('Custom')
expect(textOf(rowOf('Contributor'))).toContain('4')
expect(textOf(rowOf('Content lead'))).toContain('Custom')
expect(buttonNamed('New role')!.disabled).toBe(false)
})
it('draws each role as a card on mobile', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend: listBackend(),
desktop: false,
})
expect(document.body.querySelectorAll('[data-testid="role-card"]')).toHaveLength(5)
expect(rows()).toHaveLength(0)
})
it('opens a role from its name', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend: listBackend(),
})
expect(rowOf('Contributor').querySelector('a')!.getAttribute('href')).toBe(
'/settings/roles/4',
)
})
})
describe('Roles list: Loading, only built-ins, error', () => {
it('shows skeleton rows after 150 ms with New role disabled, then the list', async () => {
vi.useFakeTimers()
let release: (value: unknown) => void = () => undefined
const pending = new Promise((resolve) => {
release = resolve
})
const backend = new Backend({ 'GET /v1/role/': () => pending })
const mounting = mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend,
})
await vi.advanceTimersByTimeAsync(100)
expect(document.body.querySelectorAll('[data-testid="role-skeleton"]')).toHaveLength(0)
await vi.advanceTimersByTimeAsync(100)
wrapper = await mounting
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="role-skeleton"]').length,
).toBeGreaterThan(0)
expect(buttonNamed('New role')!.disabled).toBe(true)
release([OWNER, ADMIN, WRITER, CONTRIBUTOR])
await flushPromises()
expect(document.body.querySelectorAll('[data-testid="role-skeleton"]')).toHaveLength(0)
expect(rows()).toHaveLength(4)
})
it('reads No custom roles yet when only the built-ins are listed', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend: listBackend([OWNER, ADMIN, WRITER]),
})
const text = textOf(document.body)
expect(text).toContain('No custom roles yet')
expect(text).toContain(
'The three built-in roles cover most sites. Create a role when someone needs something in between.',
)
expect(buttonNamed('New role')!.disabled).toBe(false)
})
it("reads Couldn't load roles on a failure and Try again re-requests the list", async () => {
let attempts = 0
const backend = new Backend({
'GET /v1/role/': () => {
attempts++
if (attempts === 1) {
throw new ApiError(500)
}
return [OWNER, ADMIN, WRITER, CONTRIBUTOR]
},
})
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend,
})
const text = textOf(document.body)
expect(text).toContain("Couldn't load roles")
expect(text).toContain('GET /v1/role failed. Check your connection and try again.')
buttonNamed('Try again')!.click()
await flushPromises()
expect(backend.callsTo('GET', '/v1/role/')).toHaveLength(2)
expect(rows()).toHaveLength(4)
})
})
describe('Roles list: No permission to edit', () => {
it('disables New role and shows the banner for roles.view without roles.manage', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: customAccess('roles.view', 'Viewer'),
backend: listBackend(),
})
expect(buttonNamed('New role')!.disabled).toBe(true)
const banner = document.body.querySelector('[data-testid="read-only-banner"]')!
expect(textOf(banner)).toContain('You can see roles but not change them')
expect(textOf(banner)).toContain('Ask an owner or admin if a role needs to change.')
})
it('shows no banner to someone who manages roles', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend: listBackend(),
})
expect(document.body.querySelector('[data-testid="read-only-banner"]')).toBeNull()
})
})
describe('Roles list: Row menu', () => {
it('offers Edit, Duplicate and an enabled Delete on a custom role', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: customAccess('roles.view, roles.manage', 'Manager', 9),
backend: listBackend(),
})
const items = await openMenu('Contributor')
expect(items.map((entry) => textOf(entry).split(' ')[0])).toEqual([
'Edit',
'Duplicate',
'Delete',
])
expect(item(items, 'Delete').getAttribute('aria-disabled')).not.toBe('true')
expect(item(items, 'Duplicate').getAttribute('aria-disabled')).not.toBe('true')
})
it('disables Delete on a built-in role with the reason', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend: listBackend(),
})
const items = await openMenu('Writer')
expect(item(items, 'Delete').getAttribute('aria-disabled')).toBe('true')
expect(item(items, 'Delete').getAttribute('aria-describedby')).toMatch(/\S+/)
expect(
document.getElementById(item(items, 'Delete').getAttribute('aria-describedby')!)!
.textContent,
).toBe("Built-in roles can't be deleted. Duplicate it to make a custom role.")
expect(item(items, 'Edit').getAttribute('aria-disabled')).not.toBe('true')
})
it('disables Delete on the locked Owner role', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: customAccess('roles.view, roles.manage', 'Manager', 9),
backend: listBackend(),
})
const items = await openMenu('Owner')
expect(item(items, 'Delete').getAttribute('aria-disabled')).toBe('true')
})
it('disables Duplicate and Delete for roles.view only and still offers Edit', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: customAccess('roles.view', 'Viewer', 9),
backend: listBackend(),
})
const items = await openMenu('Contributor')
expect(item(items, 'Edit').getAttribute('aria-disabled')).not.toBe('true')
expect(item(items, 'Duplicate').getAttribute('aria-disabled')).toBe('true')
expect(item(items, 'Delete').getAttribute('aria-disabled')).toBe('true')
})
it('opens the editor from Edit and a prefilled editor from Duplicate', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: adminAccess(),
backend: listBackend(),
})
item(await openMenu('Writer'), 'Edit').click()
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/settings/roles/3'))
await router.push('/settings/roles')
item(await openMenu('Writer'), 'Duplicate').click()
await vi.waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe('/settings/roles/new?from=3'),
)
})
})
describe('Roles list: Delete', () => {
it('opens the blocked dialog for a role with members', async () => {
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend: listBackend(),
})
item(await openMenu('Contributor'), 'Delete').click()
await flushPromises()
expect(textOf(document.body.querySelector('[role="alertdialog"]')!)).toContain(
'4 members have this role. Move them to another role first. Then Contributor can be deleted.',
)
})
it('removes the row once the role without members is deleted', async () => {
const intern = role({ id: 6, name: 'Intern', members: 0 })
const backend = listBackend([OWNER, ADMIN, WRITER, intern], {
'DELETE /v1/role/6': () => undefined,
})
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend,
})
item(await openMenu('Intern'), 'Delete').click()
await flushPromises()
buttonNamed('Delete role')!.click()
await flushPromises()
expect(backend.callsTo('DELETE', '/v1/role/6')).toHaveLength(1)
expect(backend.callsTo('POST', '/v1/role/6/reassign')).toHaveLength(0)
expect(rows().map((row) => textOf(row).split(' ')[0])).not.toContain('Intern')
})
it('refreshes the list when the role is already gone', async () => {
const intern = role({ id: 6, name: 'Intern', members: 0 })
let listed = 0
const backend = new Backend({
'GET /v1/role/': () =>
++listed === 1 ? [OWNER, ADMIN, WRITER, intern] : [OWNER, ADMIN, WRITER],
'DELETE /v1/role/6': () => {
throw new ApiError(404, undefined, 'role_not_found', { code: 'role_not_found' })
},
})
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend,
})
item(await openMenu('Intern'), 'Delete').click()
await flushPromises()
buttonNamed('Delete role')!.click()
await flushPromises()
expect(backend.callsTo('GET', '/v1/role/')).toHaveLength(2)
expect(rows().map((row) => textOf(row).split(' ')[0])).not.toContain('Intern')
})
})
+143
View File
@@ -0,0 +1,143 @@
import { vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { h } from 'vue'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { setViewportWidth, DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { Access, Grant } from '@/lib/permissions'
import type { PermissionGroupItem, RoleItem } from '@/lib/roles'
export interface Call {
method: string
path: string
body: unknown
}
export type Handler = (call: Call) => unknown
export class Backend {
calls: Call[] = []
routes: Record<string, Handler>
constructor(routes: Record<string, Handler>) {
this.routes = routes
}
request = vi.fn(async (path: string, options: RequestInit = {}) => {
const method = options.method ?? 'GET'
const call: Call = {
method,
path,
body: typeof options.body === 'string' ? JSON.parse(options.body) : undefined,
}
this.calls.push(call)
const handler = this.routes[`${method} ${path}`]
if (handler === undefined) {
throw new Error(`Unmocked ${method} ${path}`)
}
return handler(call)
})
callsTo(method: string, path: string) {
return this.calls.filter((call) => call.method === method && call.path === path)
}
}
export function role(overrides: Partial<RoleItem> & Pick<RoleItem, 'id' | 'name'>): RoleItem {
return {
description: '',
built_in: false,
locked: false,
version: 1,
grants: [],
members: 0,
updated_at: null,
updated_by: null,
...overrides,
}
}
export function grants(spec: string): Grant[] {
return spec.split(',').map((part) => {
const [key, scope = 'all'] = part.trim().split('=')
return { key: key!, scope: scope as Grant['scope'] }
})
}
export const CATALOGUE: PermissionGroupItem[] = [
{
key: 'content',
items: ['read', 'create', 'update', 'delete', 'publish'].map((action) => ({
key: `content.${action}`,
scope_kind: action === 'create' ? ('toggle' as const) : ('scoped' as const),
owner_only: false,
per_type: true,
})),
},
{
key: 'activity',
items: [
{ key: 'activity.view', scope_kind: 'scoped', owner_only: false, per_type: false },
{ key: 'activity.export', scope_kind: 'toggle', owner_only: false, per_type: false },
],
},
{
key: 'system',
items: [
{
key: 'system.apply_updates',
scope_kind: 'toggle',
owner_only: true,
per_type: false,
},
],
},
]
export async function mountRoleView(
view: object,
path: string,
{
access,
backend,
desktop = true,
userId = 1,
}: { access: Access; backend: Backend; desktop?: boolean; userId?: number },
) {
setViewportWidth(desktop ? DESKTOP_WIDTH : MOBILE_WIDTH)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { id: userId, verified: true, username: 'marta', displayName: '', access }
session.status = 'ready'
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({
id: userId,
verified: true,
username: 'marta',
displayName: '',
access,
}),
request: backend.request,
} as unknown as ReturnType<typeof useApi>)
await router.push(path)
const wrapper: VueWrapper = mount(() => h(UApp, null, () => h(view)), {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
export function textOf(element: Element) {
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function buttonNamed(label: string, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
(candidate) => textOf(candidate) === label,
)
}