feat: SPA Roles screens
This commit is contained in:
1 parent
907949aa12
commit
3db490a615
47 files changed
+6947
-9
No files matched your search
@@ -105,9 +105,20 @@ const mediaSettingsChildren = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const accessChildren = computed(() => [
|
||||
{ label: t('Access'), type: 'label' as const },
|
||||
{
|
||||
label: t('Roles'),
|
||||
to: '/settings/roles',
|
||||
icon: 'i-lucide-shield',
|
||||
active: !props.noCurrentNav && route.path.startsWith('/settings/roles'),
|
||||
},
|
||||
])
|
||||
|
||||
const settingsChildren = computed(() => [
|
||||
...(permissions.can('settings.view') ? mediaSettingsChildren.value : []),
|
||||
...(permissions.can('settings.manage_sites') ? publishingChildren.value : []),
|
||||
...(permissions.can('roles.view') ? accessChildren.value : []),
|
||||
])
|
||||
|
||||
const schemaItem = computed(() =>
|
||||
@@ -191,7 +202,10 @@ const navItems = computed(() =>
|
||||
to: '/settings',
|
||||
icon: 'i-lucide-settings',
|
||||
slot: 'settings',
|
||||
show: permissions.can('settings.view') || permissions.can('settings.manage_sites'),
|
||||
show:
|
||||
permissions.can('settings.view') ||
|
||||
permissions.can('settings.manage_sites') ||
|
||||
permissions.can('roles.view'),
|
||||
},
|
||||
]
|
||||
.filter((item) => item.show)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import RolePicker from '@/components/RolePicker.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useRoles } from '@/composables/useRoles'
|
||||
import { refusalCopy } from '@/lib/permissionCopy'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const props = defineProps<{
|
||||
role: RoleItem
|
||||
roles: RoleItem[]
|
||||
canAssign: (role: RoleItem) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deleted: [role: RoleItem]
|
||||
gone: [role: RoleItem]
|
||||
duplicate: [role: RoleItem]
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { reassignRole, deleteRole } = useRoles()
|
||||
|
||||
const target = ref<number | undefined>(undefined)
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const members = ref(props.role.members)
|
||||
|
||||
const stage = computed<'builtin' | 'blocked' | 'confirm'>(() => {
|
||||
if (props.role.built_in) {
|
||||
return 'builtin'
|
||||
}
|
||||
return members.value > 0 ? 'blocked' : 'confirm'
|
||||
})
|
||||
|
||||
const title = computed(() => t('Delete {0}?', [props.role.name]))
|
||||
|
||||
const description = computed(() => {
|
||||
switch (stage.value) {
|
||||
case 'builtin':
|
||||
return t("Built-in roles can't be deleted.")
|
||||
case 'blocked':
|
||||
return t(
|
||||
'{0} members have this role. Move them to another role first. Then {1} can be deleted.',
|
||||
[members.value, props.role.name],
|
||||
)
|
||||
default:
|
||||
return t("No one has this role. This can't be undone.")
|
||||
}
|
||||
})
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
if (stage.value === 'builtin') {
|
||||
return t('Duplicate role')
|
||||
}
|
||||
return stage.value === 'blocked'
|
||||
? t('Move {0} members and delete role', [members.value])
|
||||
: t('Delete role')
|
||||
})
|
||||
|
||||
const pendingLabel = computed(() =>
|
||||
stage.value === 'blocked' ? t('Moving and deleting…') : t('Deleting…'),
|
||||
)
|
||||
|
||||
function showRefusal(error: unknown) {
|
||||
if (!(error instanceof ApiError)) {
|
||||
notice.value = { description: t("Couldn't delete the role. Try again in a moment.") }
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_has_members') {
|
||||
members.value = Math.max(members.value, 1)
|
||||
target.value = undefined
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_not_found') {
|
||||
emit('gone', props.role)
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
const copy = error.code === undefined ? undefined : refusalCopy(error.code, error.permission)
|
||||
notice.value =
|
||||
copy === undefined
|
||||
? { description: t("Couldn't delete the role. Try again in a moment.") }
|
||||
: { title: copy.title, description: copy.description ?? '' }
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (stage.value === 'builtin') {
|
||||
emit('duplicate', props.role)
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (stage.value === 'blocked' && target.value === undefined) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
try {
|
||||
if (stage.value === 'blocked' && target.value !== undefined) {
|
||||
await reassignRole(props.role.id, target.value)
|
||||
members.value = 0
|
||||
}
|
||||
await deleteRole(props.role.id)
|
||||
emit('deleted', props.role)
|
||||
open.value = false
|
||||
} catch (error) {
|
||||
showRefusal(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
v-model:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:confirm-label="confirmLabel"
|
||||
:pending-label="pendingLabel"
|
||||
:cancel-label="stage === 'builtin' ? t('Close') : t('Cancel')"
|
||||
:tone="stage === 'builtin' ? 'neutral' : 'danger'"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:confirm-disabled="stage === 'blocked' && target === undefined"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template v-if="stage === 'blocked'">
|
||||
<UFormField
|
||||
:label="t('Move members to')"
|
||||
:help="t('They keep their API keys. Keys follow the new role.')"
|
||||
>
|
||||
<RolePicker
|
||||
v-model:selected="target"
|
||||
:roles="roles"
|
||||
:source-id="role.id"
|
||||
:can-assign="canAssign"
|
||||
/>
|
||||
</UFormField>
|
||||
</template>
|
||||
<p v-else-if="stage === 'builtin'" class="flex flex-col gap-1 text-muted">
|
||||
<span class="font-medium text-highlighted">{{
|
||||
t('{0} is built in', [role.name])
|
||||
}}</span>
|
||||
<span>{{ t('Duplicate it to make a custom role you can change or delete.') }}</span>
|
||||
</p>
|
||||
<p v-else class="text-muted">{{ t("Deleting a role doesn't affect content.") }}</p>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { useId } from 'vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
reason?: string
|
||||
changed?: boolean
|
||||
}>(),
|
||||
{ disabled: false, reason: undefined, changed: false },
|
||||
)
|
||||
|
||||
defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const uid = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative inline-flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="changed"
|
||||
data-testid="changed-marker"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-1 -left-1 z-10 size-1.5 bg-primary"
|
||||
/>
|
||||
<UIcon v-if="reason" name="i-lucide-lock" class="size-3 text-muted" />
|
||||
<USwitch
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:aria-label="label"
|
||||
:aria-describedby="reason ? `${uid}-reason` : undefined"
|
||||
:label="modelValue ? t('On') : t('Off')"
|
||||
size="sm"
|
||||
:ui="{ label: 'text-xs text-muted' }"
|
||||
@update:model-value="$emit('update:modelValue', Boolean($event))"
|
||||
/>
|
||||
<span v-if="reason" :id="`${uid}-reason`" class="sr-only">{{ reason }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import OnOffCell from '@/components/OnOffCell.vue'
|
||||
import ScopeControl from '@/components/ScopeControl.vue'
|
||||
import { lockReason, lockedScopes, type CallerScope } from '@/lib/grantLocks'
|
||||
import {
|
||||
CONTENT_ACTIONS,
|
||||
contentKey,
|
||||
isCellChanged,
|
||||
isCustomRow,
|
||||
scopeAt,
|
||||
type ContentAction,
|
||||
type GrantMap,
|
||||
} from '@/lib/roleForm'
|
||||
import type { Scope } from '@/lib/permissions'
|
||||
|
||||
export interface GridType {
|
||||
key: string
|
||||
name: string
|
||||
builtin?: boolean
|
||||
}
|
||||
|
||||
export interface GridChange {
|
||||
action: ContentAction
|
||||
scope: Scope
|
||||
typeKey?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
types: GridType[]
|
||||
grants: GrantMap
|
||||
initial: GrantMap
|
||||
callerScope: CallerScope
|
||||
readOnly?: boolean
|
||||
mobile?: boolean
|
||||
}>(),
|
||||
{ readOnly: false, mobile: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ change: [change: GridChange]; reset: [typeKey: string] }>()
|
||||
|
||||
const announcement = ref('')
|
||||
|
||||
const columns = computed(() =>
|
||||
CONTENT_ACTIONS.map((action) => ({ action, label: actionLabel(action) })),
|
||||
)
|
||||
|
||||
const rows = computed(() => [
|
||||
{ typeKey: undefined as string | undefined, name: t('All content types') },
|
||||
...props.types.map((type) => ({ typeKey: type.key as string | undefined, name: type.name })),
|
||||
])
|
||||
|
||||
function actionLabel(action: ContentAction) {
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return t('Read')
|
||||
case 'create':
|
||||
return t('Create')
|
||||
case 'update':
|
||||
return t('Update')
|
||||
case 'delete':
|
||||
return t('Delete')
|
||||
case 'publish':
|
||||
return t('Publish')
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(scope: Scope) {
|
||||
switch (scope) {
|
||||
case 'none':
|
||||
return t('None')
|
||||
case 'own':
|
||||
return t('Own')
|
||||
case 'all':
|
||||
return t('All')
|
||||
}
|
||||
}
|
||||
|
||||
function valueOf(action: ContentAction, typeKey?: string): Scope {
|
||||
return scopeAt(props.grants, contentKey(action), typeKey)
|
||||
}
|
||||
|
||||
function locksOf(action: ContentAction, typeKey?: string) {
|
||||
const key = contentKey(action)
|
||||
const caller = props.callerScope(key, typeKey === undefined ? {} : { typeKey })
|
||||
const locks: Partial<Record<Scope, string>> = {}
|
||||
for (const scope of lockedScopes(caller)) {
|
||||
locks[scope] = lockReason(key, caller)
|
||||
}
|
||||
return locks
|
||||
}
|
||||
|
||||
function createLock(typeKey?: string) {
|
||||
return locksOf('create', typeKey).all
|
||||
}
|
||||
|
||||
function changedOf(action: ContentAction, typeKey?: string) {
|
||||
return isCellChanged(props.grants, props.initial, contentKey(action), typeKey)
|
||||
}
|
||||
|
||||
function nameOf(action: ContentAction, rowName: string) {
|
||||
return `${rowName}: ${actionLabel(action)}`
|
||||
}
|
||||
|
||||
function commit(action: ContentAction, rowName: string, scope: Scope, typeKey?: string) {
|
||||
announcement.value = t('{0}, {1}, set to {2}.', [
|
||||
rowName,
|
||||
actionLabel(action),
|
||||
action === 'create' ? (scope === 'all' ? t('On') : t('Off')) : scopeLabel(scope),
|
||||
])
|
||||
emit('change', { action, scope, typeKey })
|
||||
}
|
||||
|
||||
function isCustom(typeKey?: string) {
|
||||
return typeKey !== undefined && isCustomRow(props.grants, typeKey)
|
||||
}
|
||||
|
||||
function typeOf(typeKey?: string) {
|
||||
return props.types.find((type) => type.key === typeKey)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :role="mobile ? undefined : 'grid'" :aria-label="t('Content permissions')">
|
||||
<p class="sr-only" role="status" aria-live="polite" data-testid="grid-live-region">
|
||||
{{ announcement }}
|
||||
</p>
|
||||
|
||||
<template v-if="!mobile">
|
||||
<div
|
||||
role="row"
|
||||
class="grid grid-cols-[minmax(0,1.6fr)_repeat(5,minmax(0,1fr))] items-center gap-3 border-b border-default bg-elevated px-3 py-2 text-sm font-medium text-muted"
|
||||
>
|
||||
<span role="columnheader">{{ t('Content type') }}</span>
|
||||
<span v-for="column in columns" :key="column.action" role="columnheader">{{
|
||||
column.label
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.typeKey ?? 'all'"
|
||||
role="row"
|
||||
:data-testid="row.typeKey === undefined ? 'row-all' : `row-${row.typeKey}`"
|
||||
class="grid grid-cols-[minmax(0,1.6fr)_repeat(5,minmax(0,1fr))] items-center gap-3 border-b border-default px-3 py-2"
|
||||
>
|
||||
<span role="rowheader" class="flex flex-col gap-0.5">
|
||||
<span class="font-medium text-highlighted">{{ row.name }}</span>
|
||||
<span v-if="row.typeKey === undefined" class="text-xs text-muted">{{
|
||||
t('Sets every row')
|
||||
}}</span>
|
||||
<span v-else class="flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
||||
<template v-if="typeOf(row.typeKey)?.builtin">
|
||||
<UBadge color="neutral" variant="subtle" :label="t('Built-in')" />
|
||||
<UBadge color="neutral" variant="subtle" :label="t('Schema locked')" />
|
||||
</template>
|
||||
<template v-if="isCustom(row.typeKey)">
|
||||
<UBadge color="neutral" variant="outline" :label="t('Custom')" />
|
||||
<UButton
|
||||
variant="link"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
:disabled="readOnly"
|
||||
class="p-0 text-xs"
|
||||
@click="emit('reset', row.typeKey!)"
|
||||
>
|
||||
{{ t('Reset') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
<span v-for="column in columns" :key="column.action" role="gridcell">
|
||||
<OnOffCell
|
||||
v-if="column.action === 'create'"
|
||||
:label="nameOf('create', row.name)"
|
||||
:model-value="valueOf('create', row.typeKey) !== 'none'"
|
||||
:disabled="readOnly || createLock(row.typeKey) !== undefined"
|
||||
:reason="createLock(row.typeKey)"
|
||||
:changed="changedOf('create', row.typeKey)"
|
||||
@update:model-value="
|
||||
commit('create', row.name, $event ? 'all' : 'none', row.typeKey)
|
||||
"
|
||||
/>
|
||||
<ScopeControl
|
||||
v-else
|
||||
:label="nameOf(column.action, row.name)"
|
||||
:model-value="valueOf(column.action, row.typeKey)"
|
||||
:disabled="readOnly"
|
||||
:locks="locksOf(column.action, row.typeKey)"
|
||||
:changed="changedOf(column.action, row.typeKey)"
|
||||
@update:model-value="commit(column.action, row.name, $event, row.typeKey)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.typeKey ?? 'all'"
|
||||
:data-testid="row.typeKey === undefined ? 'card-all' : `card-${row.typeKey}`"
|
||||
class="flex flex-col gap-3 border border-default bg-default p-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-highlighted">{{ row.name }}</span>
|
||||
<span v-if="row.typeKey === undefined" class="text-xs text-muted">{{
|
||||
t('Sets every row')
|
||||
}}</span>
|
||||
<UBadge
|
||||
v-if="typeOf(row.typeKey)?.builtin"
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
:label="t('Built-in')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="isCustom(row.typeKey)"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
:disabled="readOnly"
|
||||
class="p-0 text-xs"
|
||||
@click="emit('reset', row.typeKey!)"
|
||||
>
|
||||
{{ t('Reset') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.action"
|
||||
class="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span class="text-sm text-muted">{{ column.label }}</span>
|
||||
<OnOffCell
|
||||
v-if="column.action === 'create'"
|
||||
:label="nameOf('create', row.name)"
|
||||
:model-value="valueOf('create', row.typeKey) !== 'none'"
|
||||
:disabled="readOnly || createLock(row.typeKey) !== undefined"
|
||||
:reason="createLock(row.typeKey)"
|
||||
:changed="changedOf('create', row.typeKey)"
|
||||
@update:model-value="
|
||||
commit('create', row.name, $event ? 'all' : 'none', row.typeKey)
|
||||
"
|
||||
/>
|
||||
<ScopeControl
|
||||
v-else
|
||||
:label="nameOf(column.action, row.name)"
|
||||
:model-value="valueOf(column.action, row.typeKey)"
|
||||
:disabled="readOnly"
|
||||
:locks="locksOf(column.action, row.typeKey)"
|
||||
:changed="changedOf(column.action, row.typeKey)"
|
||||
@update:model-value="commit(column.action, row.name, $event, row.typeKey)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import OnOffCell from '@/components/OnOffCell.vue'
|
||||
import OwnerOnlyTag from '@/components/OwnerOnlyTag.vue'
|
||||
import ScopeControl from '@/components/ScopeControl.vue'
|
||||
import { lockReason, lockedScopes, type CallerScope } from '@/lib/grantLocks'
|
||||
import { permissionDescription, permissionName } from '@/lib/permissionNames'
|
||||
import { isCellChanged, scopeAt, type GrantMap } from '@/lib/roleForm'
|
||||
import type { Scope } from '@/lib/permissions'
|
||||
import type { PermissionGroupItem } from '@/lib/roles'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
group: PermissionGroupItem
|
||||
grants: GrantMap
|
||||
initial: GrantMap
|
||||
callerScope: CallerScope
|
||||
readOnly?: boolean
|
||||
}>(),
|
||||
{ readOnly: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ change: [change: { key: string; scope: Scope }] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const announcement = ref('')
|
||||
|
||||
const GROUP_LABELS: Record<string, () => string> = {
|
||||
activity: () => t('Activity'),
|
||||
users: () => t('Users and invites'),
|
||||
settings: () => t('Settings'),
|
||||
roles: () => t('Roles'),
|
||||
system: () => t('System'),
|
||||
schema: () => t('Schema'),
|
||||
media: () => t('Media'),
|
||||
authors: () => t('Authors'),
|
||||
}
|
||||
|
||||
const label = computed(() => GROUP_LABELS[props.group.key]?.() ?? props.group.key)
|
||||
const enabled = computed(
|
||||
() => props.group.items.filter((item) => scopeAt(props.grants, item.key) !== 'none').length,
|
||||
)
|
||||
|
||||
function scopeLabel(scope: Scope) {
|
||||
return scope === 'none' ? t('None') : scope === 'own' ? t('Own') : t('All')
|
||||
}
|
||||
|
||||
function locksOf(key: string) {
|
||||
const caller = props.callerScope(key)
|
||||
const locks: Partial<Record<Scope, string>> = {}
|
||||
for (const scope of lockedScopes(caller)) {
|
||||
locks[scope] = lockReason(key, caller)
|
||||
}
|
||||
return locks
|
||||
}
|
||||
|
||||
function commit(key: string, scope: Scope, toggle: boolean) {
|
||||
announcement.value = t('{0}, set to {1}.', [
|
||||
permissionName(key) ?? key,
|
||||
toggle ? (scope === 'all' ? t('On') : t('Off')) : scopeLabel(scope),
|
||||
])
|
||||
emit('change', { key, scope })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCollapsible v-model:open="open" :data-testid="`group-${group.key}`">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
class="h-11 justify-between rounded-none border-b border-default px-3 md:h-10"
|
||||
:ui="{ trailingIcon: open ? 'rotate-180 size-4' : 'size-4' }"
|
||||
>
|
||||
<span class="font-medium text-highlighted">{{ label }}</span>
|
||||
<span class="text-xs text-muted">{{
|
||||
t('{0} of {1} enabled', [enabled, group.items.length])
|
||||
}}</span>
|
||||
</UButton>
|
||||
<template #content>
|
||||
<p class="sr-only" role="status" aria-live="polite">{{ announcement }}</p>
|
||||
<ul class="flex flex-col">
|
||||
<li
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
:data-testid="`permission-${item.key}`"
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-default px-3 py-2.5"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="flex items-center gap-2 font-medium text-highlighted">
|
||||
{{ permissionName(item.key) }}
|
||||
<OwnerOnlyTag v-if="item.owner_only" />
|
||||
</span>
|
||||
<span class="text-xs text-muted">{{
|
||||
permissionDescription(item.key)
|
||||
}}</span>
|
||||
</div>
|
||||
<OnOffCell
|
||||
v-if="item.scope_kind === 'toggle'"
|
||||
:label="permissionName(item.key) ?? item.key"
|
||||
:model-value="scopeAt(grants, item.key) !== 'none'"
|
||||
:disabled="
|
||||
readOnly || item.owner_only || locksOf(item.key).all !== undefined
|
||||
"
|
||||
:reason="
|
||||
item.owner_only
|
||||
? t('Only the owner can grant this.')
|
||||
: locksOf(item.key).all
|
||||
"
|
||||
:changed="isCellChanged(grants, initial, item.key)"
|
||||
@update:model-value="commit(item.key, $event ? 'all' : 'none', true)"
|
||||
/>
|
||||
<ScopeControl
|
||||
v-else
|
||||
:label="permissionName(item.key) ?? item.key"
|
||||
:model-value="scopeAt(grants, item.key)"
|
||||
:disabled="readOnly || item.owner_only"
|
||||
:locks="locksOf(item.key)"
|
||||
:changed="isCellChanged(grants, initial, item.key)"
|
||||
@update:model-value="commit(item.key, $event, false)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</UCollapsible>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { PRESET_NAMES, type PresetName } from '@/lib/roleForm'
|
||||
|
||||
const props = defineProps<{ active: PresetName | null; disabled?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ select: [preset: PresetName] }>()
|
||||
|
||||
function onSelect(preset: PresetName) {
|
||||
if (!props.disabled) {
|
||||
emit('select', preset)
|
||||
}
|
||||
}
|
||||
|
||||
function labelOf(preset: PresetName) {
|
||||
switch (preset) {
|
||||
case 'read_only':
|
||||
return t('Read only')
|
||||
case 'write_own':
|
||||
return t('Write own')
|
||||
case 'full_content':
|
||||
return t('Full content')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-highlighted">{{ t('Quick presets') }}</span>
|
||||
<UButton
|
||||
v-for="preset in PRESET_NAMES"
|
||||
:key="preset"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
:aria-disabled="disabled ? 'true' : undefined"
|
||||
:aria-pressed="active === preset ? 'true' : 'false'"
|
||||
class="h-7 rounded-none border border-accented px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
|
||||
:class="active === preset ? 'bg-elevated font-semibold' : 'bg-default'"
|
||||
@click="onSelect(preset)"
|
||||
>
|
||||
{{ labelOf(preset) }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import type { ConflictState, ReadOnlyReason } from '@/composables/useRoleEditor'
|
||||
import type { RefusalCopy } from '@/lib/permissionCopy'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
defineProps<{
|
||||
role: RoleItem | null
|
||||
reason: ReadOnlyReason | null
|
||||
conflict: ConflictState | null
|
||||
refusal: RefusalCopy | null
|
||||
canDuplicate: boolean
|
||||
warnMembers: boolean
|
||||
reviewing: boolean
|
||||
differences: { key: string; name: string; kind: 'added' | 'removed' | 'changed' }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
duplicate: []
|
||||
reloadRole: []
|
||||
reloadPage: []
|
||||
review: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const ALERT_UI = {
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}
|
||||
|
||||
function kindLabel(kind: 'added' | 'removed' | 'changed') {
|
||||
return kind === 'added' ? t('Added') : kind === 'removed' ? t('Removed') : t('Changed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<UAlert
|
||||
v-if="reason === 'owner'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-lock"
|
||||
:close="false"
|
||||
:title="t('The Owner role is locked')"
|
||||
:description="
|
||||
t(
|
||||
'It always has every permission. To hand it to someone else, transfer ownership from Users.',
|
||||
)
|
||||
"
|
||||
:ui="ALERT_UI"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-8"
|
||||
@click="router.push('/users')"
|
||||
>
|
||||
{{ t('Go to Users') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UAlert
|
||||
v-else-if="reason === 'builtin'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-lock"
|
||||
:close="false"
|
||||
:title="t('Only the owner can edit built-in roles')"
|
||||
:description="
|
||||
t('You can read what {0} allows. To change it, duplicate it into a custom role.', [
|
||||
role?.name ?? '',
|
||||
])
|
||||
"
|
||||
:ui="ALERT_UI"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
v-if="canDuplicate"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-8"
|
||||
@click="emit('duplicate')"
|
||||
>
|
||||
{{ t('Duplicate role') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-else-if="reason === 'view'"
|
||||
:title="t('You can see roles but not change them')"
|
||||
:description="t('Ask an owner or admin if a role needs to change.')"
|
||||
/>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-else-if="reason === 'own'"
|
||||
:title="
|
||||
t('You can\'t change the permissions of your own role. Ask another owner or admin.')
|
||||
"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-else-if="reason === 'lowered'"
|
||||
role="alert"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:close="false"
|
||||
:title="t('Your access changed')"
|
||||
:description="
|
||||
t(
|
||||
'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.',
|
||||
)
|
||||
"
|
||||
:ui="ALERT_UI"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton color="neutral" variant="outline" class="h-8" @click="emit('reloadPage')">
|
||||
{{ t('Reload page') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UAlert
|
||||
v-if="warnMembers && role"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:close="false"
|
||||
:title="
|
||||
role.members === 1
|
||||
? t('This role has 1 member')
|
||||
: t('This role has {0} members', [role.members])
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
'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.',
|
||||
)
|
||||
"
|
||||
:ui="ALERT_UI"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="conflict"
|
||||
role="alert"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:close="false"
|
||||
:title="conflict.title"
|
||||
:description="conflict.description"
|
||||
:ui="ALERT_UI"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton color="neutral" variant="outline" class="h-8" @click="emit('reloadRole')">
|
||||
{{ t('Reload role') }}
|
||||
</UButton>
|
||||
<UButton color="neutral" variant="outline" class="h-8" @click="emit('review')">
|
||||
{{ t('Review differences') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<ul
|
||||
v-if="conflict && reviewing"
|
||||
data-testid="role-differences"
|
||||
class="flex flex-col gap-1 border border-default bg-default p-3"
|
||||
>
|
||||
<li v-for="entry in differences" :key="entry.key" class="flex gap-2 text-sm">
|
||||
<span class="font-medium text-highlighted">{{ kindLabel(entry.kind) }}</span>
|
||||
<span class="text-muted">{{ entry.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<UAlert
|
||||
v-if="refusal"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:close="false"
|
||||
:title="refusal.title"
|
||||
:description="refusal.description"
|
||||
:ui="ALERT_UI"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const props = defineProps<{
|
||||
roles: RoleItem[]
|
||||
sourceId: number
|
||||
canAssign: (role: RoleItem) => boolean
|
||||
}>()
|
||||
|
||||
const selected = defineModel<number | undefined>('selected', { default: undefined })
|
||||
|
||||
const items = computed(() =>
|
||||
props.roles
|
||||
.filter((role) => !role.locked && role.id !== props.sourceId)
|
||||
.map((role) => {
|
||||
const allowed = props.canAssign(role)
|
||||
return {
|
||||
label: role.name,
|
||||
value: role.id,
|
||||
description: allowed
|
||||
? role.description
|
||||
: t("Your role can't assign a role with more power than yours."),
|
||||
disabled: !allowed,
|
||||
}
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="selected"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
:placeholder="t('Choose a role')"
|
||||
:portal="false"
|
||||
:content="{ position: 'inline' }"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none md:h-10',
|
||||
content: 'rounded-none border border-default',
|
||||
item: 'min-h-12 gap-2 px-2 py-1 md:min-h-9',
|
||||
itemDescription: 'text-xs text-muted',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import RolePreviewBody from '@/components/RolePreviewBody.vue'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
import type { RolePreviewModel } from '@/lib/rolePreview'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
model: RolePreviewModel
|
||||
readOnly?: boolean
|
||||
mobile?: boolean
|
||||
}>(),
|
||||
{ readOnly: false, mobile: false },
|
||||
)
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const subtitle = computed(() =>
|
||||
props.readOnly ? t('A summary of what this role allows.') : t('Updates as you edit.'),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-if="mobile"
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('What this role can do')"
|
||||
:description="subtitle"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<template #body>
|
||||
<p class="text-muted">{{ subtitle }}</p>
|
||||
<RolePreviewBody :model="model" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 flex-1 justify-center"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('Close') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
<UCard
|
||||
v-else
|
||||
data-testid="role-preview"
|
||||
:ui="{ root: 'rounded-none', header: 'flex flex-col gap-0.5', body: 'p-4 sm:p-4' }"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-base font-semibold text-highlighted">
|
||||
{{ t('What this role can do') }}
|
||||
</h2>
|
||||
<p class="text-muted">{{ subtitle }}</p>
|
||||
</template>
|
||||
<RolePreviewBody :model="model" />
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import type { PreviewLine, RolePreviewModel } from '@/lib/rolePreview'
|
||||
|
||||
defineProps<{ model: RolePreviewModel }>()
|
||||
|
||||
function key(prefix: string, line: PreviewLine) {
|
||||
return `${prefix}-${line.id}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-sm font-semibold text-highlighted">{{ t('Content') }}</h3>
|
||||
<div
|
||||
v-for="line in model.content"
|
||||
:key="key('content', line)"
|
||||
data-testid="preview-line"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="flex items-center gap-2 font-medium text-highlighted">
|
||||
{{ line.label }}
|
||||
<UBadge
|
||||
v-if="line.changed"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
:label="t('Changed')"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-toned">{{ line.sentence }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="model.everything.length > 0" class="flex flex-col gap-2">
|
||||
<h3 class="text-sm font-semibold text-highlighted">{{ t('Everything else') }}</h3>
|
||||
<div
|
||||
v-for="line in model.everything"
|
||||
:key="key('everything', line)"
|
||||
data-testid="preview-line"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="flex items-center gap-2 font-medium text-highlighted">
|
||||
{{ line.label }}
|
||||
<UBadge
|
||||
v-if="line.changed"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
:label="t('Changed')"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-toned">{{ line.sentence }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="model.noAccess" data-testid="preview-no-access" class="text-muted">
|
||||
{{ model.noAccess }}
|
||||
</p>
|
||||
<p v-if="model.members" data-testid="preview-members" class="text-muted">
|
||||
{{ model.members }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
count: number
|
||||
problems: number
|
||||
saving: boolean
|
||||
conflicted: boolean
|
||||
canSave: boolean
|
||||
nameMissing: boolean
|
||||
isNew: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ discard: []; save: [] }>()
|
||||
|
||||
const message = computed(() => {
|
||||
if (props.saving) {
|
||||
return t('Saving…')
|
||||
}
|
||||
if (props.conflicted) {
|
||||
return props.count === 1
|
||||
? t('1 unsaved change. Save is off until you reload')
|
||||
: t('{0} unsaved changes. Save is off until you reload', [props.count])
|
||||
}
|
||||
if (props.problems > 0) {
|
||||
return props.problems === 1
|
||||
? t('1 problem to fix')
|
||||
: t('{0} problems to fix', [props.problems])
|
||||
}
|
||||
const count =
|
||||
props.count === 0
|
||||
? ''
|
||||
: props.count === 1
|
||||
? t('1 unsaved change')
|
||||
: t('{0} unsaved changes', [props.count])
|
||||
if (props.isNew && props.nameMissing) {
|
||||
return count === ''
|
||||
? t('Name your role to save it')
|
||||
: `${count} · ${t('Name your role to save it')}`
|
||||
}
|
||||
return count
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="role-save-bar"
|
||||
class="flex items-center justify-between gap-3 border-t border-default bg-default px-4 py-3"
|
||||
>
|
||||
<span role="status" class="text-sm text-highlighted">{{ message }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 px-3 md:h-8"
|
||||
:disabled="saving"
|
||||
@click="$emit('discard')"
|
||||
>
|
||||
{{ t('Discard') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
class="h-11 px-3 md:h-8"
|
||||
:loading="saving"
|
||||
:disabled="!canSave"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ t('Save changes') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
|
||||
import { roleMenuItems, type RoleMenuActions } from '@/lib/roleMenu'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const ROW_GRID =
|
||||
'grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_80px_170px_44px] items-center gap-3 px-3'
|
||||
const SKELETON_ROWS = 4
|
||||
|
||||
defineProps<{
|
||||
roles: RoleItem[]
|
||||
loading: boolean
|
||||
desktop: boolean
|
||||
actions: RoleMenuActions
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="desktop"
|
||||
class="border border-default bg-default"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
>
|
||||
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
|
||||
<span>{{ t('Role') }}</span>
|
||||
<span>{{ t('Description') }}</span>
|
||||
<span>{{ t('Members') }}</span>
|
||||
<span>{{ t('Type') }}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<template v-if="loading">
|
||||
<div
|
||||
v-for="n in SKELETON_ROWS"
|
||||
:key="n"
|
||||
:class="ROW_GRID"
|
||||
data-testid="role-skeleton"
|
||||
class="h-12 border-t border-default"
|
||||
>
|
||||
<USkeleton class="h-3 w-[60%] rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-[80%] rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-6 rounded-none bg-accented" />
|
||||
<USkeleton class="h-5 w-16 rounded-none bg-accented" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
data-testid="role-row"
|
||||
:class="ROW_GRID"
|
||||
class="min-h-12 border-t border-default py-2"
|
||||
>
|
||||
<RouterLink
|
||||
:to="`/settings/roles/${role.id}`"
|
||||
class="truncate font-medium text-highlighted hover:underline"
|
||||
>{{ role.name }}</RouterLink
|
||||
>
|
||||
<span class="truncate text-muted">{{ role.description }}</span>
|
||||
<span class="text-muted">{{ role.members }}</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UBadge
|
||||
v-if="role.locked"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-lock"
|
||||
:label="t('Locked')"
|
||||
/>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
:label="role.built_in ? t('Built-in') : t('Custom')"
|
||||
/>
|
||||
</span>
|
||||
<DirectoryRowMenu
|
||||
:items="roleMenuItems(role, actions)"
|
||||
:title="role.name"
|
||||
:desktop="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2" :aria-busy="loading ? 'true' : undefined">
|
||||
<template v-if="loading">
|
||||
<USkeleton
|
||||
v-for="n in SKELETON_ROWS"
|
||||
:key="n"
|
||||
data-testid="role-skeleton"
|
||||
class="h-20 rounded-none bg-accented"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
data-testid="role-card"
|
||||
class="flex items-start justify-between gap-2 border border-default bg-default p-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<RouterLink
|
||||
:to="`/settings/roles/${role.id}`"
|
||||
class="font-medium text-highlighted"
|
||||
>{{ role.name }}</RouterLink
|
||||
>
|
||||
<span class="text-muted">{{ role.description }}</span>
|
||||
<span class="flex items-center gap-1.5 text-xs text-muted">
|
||||
<UBadge
|
||||
v-if="role.locked"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-lock"
|
||||
:label="t('Locked')"
|
||||
/>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
:label="role.built_in ? t('Built-in') : t('Custom')"
|
||||
/>
|
||||
{{ t('{0} members', [role.members]) }}
|
||||
</span>
|
||||
</div>
|
||||
<DirectoryRowMenu
|
||||
:items="roleMenuItems(role, actions)"
|
||||
:title="role.name"
|
||||
:desktop="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, nextTick, ref, useId } from 'vue'
|
||||
import type { Scope } from '@/lib/permissions'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: Scope
|
||||
disabled?: boolean
|
||||
locks?: Partial<Record<Scope, string>>
|
||||
changed?: boolean
|
||||
}>(),
|
||||
{ disabled: false, locks: () => ({}), changed: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [scope: Scope] }>()
|
||||
|
||||
const SCOPES: Scope[] = ['none', 'own', 'all']
|
||||
|
||||
const group = ref<HTMLElement | null>(null)
|
||||
const uid = useId()
|
||||
|
||||
const labels = computed<Record<Scope, string>>(() => ({
|
||||
none: t('None'),
|
||||
own: t('Own'),
|
||||
all: t('All'),
|
||||
}))
|
||||
|
||||
function isLocked(scope: Scope) {
|
||||
return props.disabled || props.locks[scope] !== undefined
|
||||
}
|
||||
|
||||
const enabledScopes = computed(() => SCOPES.filter((scope) => !isLocked(scope)))
|
||||
|
||||
const tabStop = computed(() => {
|
||||
const candidates = props.disabled ? SCOPES : enabledScopes.value
|
||||
return candidates.includes(props.modelValue) ? props.modelValue : candidates[0]
|
||||
})
|
||||
|
||||
function select(scope: Scope) {
|
||||
if (isLocked(scope) || scope === props.modelValue) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', scope)
|
||||
}
|
||||
|
||||
async function moveTo(scope: Scope | undefined) {
|
||||
if (scope === undefined) {
|
||||
return
|
||||
}
|
||||
select(scope)
|
||||
await nextTick()
|
||||
group.value?.querySelector<HTMLElement>(`[data-scope="${scope}"]`)?.focus()
|
||||
}
|
||||
|
||||
function focusedScope(): Scope {
|
||||
const active = document.activeElement?.getAttribute('data-scope')
|
||||
return (SCOPES.find((scope) => scope === active) ?? props.modelValue) as Scope
|
||||
}
|
||||
|
||||
function neighbour(step: 1 | -1): Scope | undefined {
|
||||
const current = SCOPES.indexOf(focusedScope())
|
||||
const ordered = step === 1 ? SCOPES.slice(current + 1) : SCOPES.slice(0, current).reverse()
|
||||
return ordered.find((scope) => !isLocked(scope))
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const handlers: Record<string, () => Scope | undefined> = {
|
||||
ArrowRight: () => neighbour(1),
|
||||
ArrowDown: () => neighbour(1),
|
||||
ArrowLeft: () => neighbour(-1),
|
||||
ArrowUp: () => neighbour(-1),
|
||||
Home: () => enabledScopes.value[0],
|
||||
End: () => enabledScopes.value[enabledScopes.value.length - 1],
|
||||
' ': () => focusedScope(),
|
||||
}
|
||||
const handler = handlers[event.key]
|
||||
if (handler === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
void moveTo(handler())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative inline-flex">
|
||||
<span
|
||||
v-if="changed"
|
||||
data-testid="changed-marker"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-1 -left-1 z-10 size-1.5 bg-primary"
|
||||
/>
|
||||
<span
|
||||
ref="group"
|
||||
role="radiogroup"
|
||||
:aria-label="label"
|
||||
:aria-disabled="disabled ? 'true' : undefined"
|
||||
class="inline-flex border border-default"
|
||||
:class="{ 'opacity-60': disabled }"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<template v-for="scope in SCOPES" :key="scope">
|
||||
<UTooltip :text="locks[scope] ?? ''" :disabled="locks[scope] === undefined">
|
||||
<UButton
|
||||
role="radio"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:data-scope="scope"
|
||||
:aria-checked="modelValue === scope ? 'true' : 'false'"
|
||||
:aria-disabled="isLocked(scope) ? 'true' : undefined"
|
||||
:aria-describedby="locks[scope] ? `${uid}-${scope}-reason` : undefined"
|
||||
:tabindex="tabStop === scope ? 0 : -1"
|
||||
:icon="locks[scope] ? 'i-lucide-lock' : undefined"
|
||||
class="h-6.5 min-w-11 justify-center rounded-none px-2.5 text-xs max-md:h-11 max-md:min-w-14"
|
||||
:class="
|
||||
modelValue === scope
|
||||
? 'border-b-2 border-primary bg-elevated font-semibold text-highlighted'
|
||||
: 'text-muted'
|
||||
"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="select(scope)"
|
||||
>
|
||||
{{ labels[scope] }}
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
<span v-if="locks[scope]" :id="`${uid}-${scope}-reason`" class="sr-only">{{
|
||||
locks[scope]
|
||||
}}</span>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -769,7 +769,7 @@ describe('DashboardShell mobile drawer rows', () => {
|
||||
const drawer = await openDrawer('/settings')
|
||||
|
||||
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
|
||||
expect(links).toHaveLength(12)
|
||||
expect(links).toHaveLength(13)
|
||||
for (const link of links) {
|
||||
expect(classesOf(link)).toContain('max-lg:h-11')
|
||||
}
|
||||
@@ -876,3 +876,51 @@ describe('DashboardShell lists items per permission', () => {
|
||||
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DashboardShell Access group', () => {
|
||||
it('shows Access with Roles (shield icon) under Settings in the sidebar with roles.view', async () => {
|
||||
const wrapper = await mountShell('/settings', {
|
||||
access: customAccess('settings.view, roles.view'),
|
||||
})
|
||||
|
||||
const roles = wrapper.find('a[href="/settings/roles"]')
|
||||
expect(roles.exists()).toBe(true)
|
||||
expect(roles.text()).toContain('Roles')
|
||||
expect(roles.find('svg').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Access')
|
||||
})
|
||||
|
||||
it('shows Roles in the mobile drawer with roles.view', async () => {
|
||||
await mountShell('/settings', {
|
||||
access: customAccess('settings.view, roles.view'),
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
|
||||
const drawer = document.body.querySelector('[role="dialog"]') as HTMLElement
|
||||
expect(drawer.querySelector('a[href="/settings/roles"]')).not.toBeNull()
|
||||
expect(drawer.textContent).toContain('Access')
|
||||
})
|
||||
|
||||
it('has no Roles entry without roles.view', async () => {
|
||||
const wrapper = await mountShell('/settings', { access: customAccess('settings.view') })
|
||||
|
||||
expect(wrapper.find('a[href="/settings/roles"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('Access')
|
||||
})
|
||||
|
||||
it('has no Permissions entry anywhere', async () => {
|
||||
const wrapper = await mountShell('/settings', { role: 'owner' })
|
||||
|
||||
expect(wrapper.find('a[href="/settings/permissions"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('Permissions')
|
||||
})
|
||||
|
||||
it('lists Roles for a person who can only view roles', async () => {
|
||||
const wrapper = await mountShell('/settings', { access: customAccess('roles.view') })
|
||||
|
||||
expect(wrapper.find('a[href="/settings/roles"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,293 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import DeleteRoleDialog from '../DeleteRoleDialog.vue'
|
||||
import { renderInApp } from './roleKit'
|
||||
import { canCover } from '@/lib/grantLocks'
|
||||
import { effectiveScope } from '@/lib/permissions'
|
||||
import { grantsFrom } from '@/test/access'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const reassignRole = vi.fn()
|
||||
const deleteRole = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useRoles', () => ({
|
||||
useRoles: () => ({ reassignRole, deleteRole }),
|
||||
}))
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const OWNER = role({ id: 1, name: 'Owner', built_in: true, locked: true, members: 1 })
|
||||
const ADMIN = role({
|
||||
id: 2,
|
||||
name: 'Admin',
|
||||
built_in: true,
|
||||
description: 'Everything except owner-only.',
|
||||
grants: grantsFrom('content.publish, users.view'),
|
||||
})
|
||||
const WRITER = role({
|
||||
id: 3,
|
||||
name: 'Writer',
|
||||
built_in: true,
|
||||
description: "Reads everything and writes content. Can't delete or publish.",
|
||||
grants: grantsFrom('content.read, content.update'),
|
||||
members: 5,
|
||||
})
|
||||
const CONTRIBUTOR = role({ id: 4, name: 'Contributor', members: 4 })
|
||||
const LEAD = role({
|
||||
id: 5,
|
||||
name: 'Content lead',
|
||||
description: 'Edits and publishes all content.',
|
||||
grants: grantsFrom('content.read, content.update, content.publish'),
|
||||
})
|
||||
const INTERN = role({ id: 6, name: 'Intern', members: 0 })
|
||||
const ROLES = [OWNER, ADMIN, WRITER, CONTRIBUTOR, LEAD, INTERN]
|
||||
|
||||
function ownerCan() {
|
||||
return () => true
|
||||
}
|
||||
|
||||
function limitedCan() {
|
||||
const held = grantsFrom('content.read, content.update')
|
||||
return (target: RoleItem) =>
|
||||
canCover(target, (key, options) => effectiveScope(held, key, options))
|
||||
}
|
||||
|
||||
async function mountDialog(target: RoleItem, canAssign = ownerCan()) {
|
||||
const events: Record<string, unknown[]> = { deleted: [], gone: [], duplicate: [], open: [] }
|
||||
wrapper = renderInApp(DeleteRoleDialog, {
|
||||
role: target,
|
||||
roles: ROLES,
|
||||
canAssign,
|
||||
open: true,
|
||||
onDeleted: (value: RoleItem) => events.deleted!.push(value),
|
||||
onGone: (value: RoleItem) => events.gone!.push(value),
|
||||
onDuplicate: (value: RoleItem) => events.duplicate!.push(value),
|
||||
'onUpdate:open': (value: boolean) => events.open!.push(value),
|
||||
})
|
||||
await flushPromises()
|
||||
return events
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector('[role="alertdialog"]') as HTMLElement
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return Array.from(dialog().querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
) as HTMLButtonElement | undefined
|
||||
}
|
||||
|
||||
async function openPicker() {
|
||||
const trigger = dialog().querySelector('[aria-haspopup="listbox"]') as HTMLElement
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function choose(name: string) {
|
||||
await openPicker()
|
||||
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find((candidate) =>
|
||||
candidate.textContent?.includes(name),
|
||||
) as HTMLElement
|
||||
option.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function optionNamed(name: string) {
|
||||
return Array.from(document.body.querySelectorAll('[role="option"]')).find((candidate) =>
|
||||
candidate.textContent?.includes(name),
|
||||
) as HTMLElement | undefined
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
reassignRole.mockReset()
|
||||
deleteRole.mockReset()
|
||||
reassignRole.mockResolvedValue({ moved: 4 })
|
||||
deleteRole.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Blocked: has members', () => {
|
||||
it('reads the blocked copy and keeps the action disabled until a role is chosen', async () => {
|
||||
await mountDialog(CONTRIBUTOR)
|
||||
|
||||
const text = dialog().textContent!
|
||||
expect(text).toContain('Delete Contributor?')
|
||||
expect(text).toContain(
|
||||
'4 members have this role. Move them to another role first. Then Contributor can be deleted.',
|
||||
)
|
||||
expect(text).toContain('Move members to')
|
||||
expect(text).toContain('They keep their API keys. Keys follow the new role.')
|
||||
expect(dialog().querySelector('[aria-haspopup="listbox"]')?.textContent).toContain(
|
||||
'Choose a role',
|
||||
)
|
||||
expect(button('Move 4 members and delete role')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('moves the members then deletes the role, and closes on both successes', async () => {
|
||||
const events = await mountDialog(CONTRIBUTOR)
|
||||
await choose('Writer')
|
||||
|
||||
button('Move 4 members and delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(reassignRole).toHaveBeenCalledWith(4, 3)
|
||||
expect(deleteRole).toHaveBeenCalledWith(4)
|
||||
expect(reassignRole.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deleteRole.mock.invocationCallOrder[0]!,
|
||||
)
|
||||
expect(events.deleted).toEqual([CONTRIBUTOR])
|
||||
expect(events.open).toContain(false)
|
||||
})
|
||||
|
||||
it('keeps the dialog open with the refusal copy when the reassign fails', async () => {
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(400, undefined, 'invalid_reassign', { code: 'invalid_reassign' }),
|
||||
)
|
||||
const events = await mountDialog(CONTRIBUTOR)
|
||||
await choose('Writer')
|
||||
|
||||
button('Move 4 members and delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
expect(events.deleted).toEqual([])
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the dialog open with the shared refusal copy when the delete fails', async () => {
|
||||
deleteRole.mockRejectedValue(
|
||||
new ApiError(403, undefined, 'escalation', { code: 'escalation' }),
|
||||
)
|
||||
const events = await mountDialog(CONTRIBUTOR)
|
||||
await choose('Writer')
|
||||
|
||||
button('Move 4 members and delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(events.deleted).toEqual([])
|
||||
expect(dialog().textContent).toContain("You can't grant that")
|
||||
expect(dialog().textContent).toContain('You can only grant what your role has.')
|
||||
})
|
||||
|
||||
it('goes back to the blocked state with the live count on role_has_members', async () => {
|
||||
deleteRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'role_has_members', { code: 'role_has_members' }),
|
||||
)
|
||||
const events = await mountDialog(INTERN)
|
||||
|
||||
button('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(events.deleted).toEqual([])
|
||||
expect(dialog().textContent).toContain('Move members to')
|
||||
expect(dialog().textContent).toContain('member')
|
||||
})
|
||||
|
||||
it('asks the list to refresh when the role is already gone', async () => {
|
||||
deleteRole.mockRejectedValue(
|
||||
new ApiError(404, undefined, 'role_not_found', { code: 'role_not_found' }),
|
||||
)
|
||||
const events = await mountDialog(INTERN)
|
||||
|
||||
button('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(events.gone).toEqual([INTERN])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role picker', () => {
|
||||
it('does not offer Owner or the source role', async () => {
|
||||
await mountDialog(CONTRIBUTOR)
|
||||
await openPicker()
|
||||
|
||||
expect(optionNamed('Owner')).toBeUndefined()
|
||||
expect(optionNamed('Contributor')).toBeUndefined()
|
||||
expect(optionNamed('Writer')!.textContent).toContain('Reads everything and writes content.')
|
||||
})
|
||||
|
||||
it('enables Content lead and Admin for a caller who covers them', async () => {
|
||||
await mountDialog(CONTRIBUTOR)
|
||||
await openPicker()
|
||||
|
||||
expect(optionNamed('Content lead')!.getAttribute('data-disabled')).toBeNull()
|
||||
expect(optionNamed('Admin')!.getAttribute('data-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the roles a caller cannot cover, with the reason', async () => {
|
||||
await mountDialog(CONTRIBUTOR, limitedCan())
|
||||
await openPicker()
|
||||
|
||||
for (const name of ['Content lead', 'Admin']) {
|
||||
const option = optionNamed(name)!
|
||||
expect(
|
||||
option.getAttribute('aria-disabled') ?? option.getAttribute('data-disabled'),
|
||||
).not.toBeNull()
|
||||
expect(option.textContent).toContain(
|
||||
"Your role can't assign a role with more power than yours.",
|
||||
)
|
||||
}
|
||||
expect(optionNamed('Writer')!.getAttribute('data-disabled')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Confirm: no members', () => {
|
||||
it('reads the confirm copy and deletes without a reassign call', async () => {
|
||||
const events = await mountDialog(INTERN)
|
||||
|
||||
const text = dialog().textContent!
|
||||
expect(text).toContain('Delete Intern?')
|
||||
expect(text).toContain("No one has this role. This can't be undone.")
|
||||
expect(text).toContain("Deleting a role doesn't affect content.")
|
||||
expect(button('Cancel')).toBeDefined()
|
||||
|
||||
button('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(reassignRole).not.toHaveBeenCalled()
|
||||
expect(deleteRole).toHaveBeenCalledWith(6)
|
||||
expect(events.deleted).toEqual([INTERN])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Built-in role dialog', () => {
|
||||
it('reads that Writer is built in and offers Duplicate role and Close', async () => {
|
||||
const events = await mountDialog(WRITER)
|
||||
|
||||
const text = dialog().textContent!
|
||||
expect(text).toContain('Delete Writer?')
|
||||
expect(text).toContain("Built-in roles can't be deleted.")
|
||||
expect(text).toContain('Writer is built in')
|
||||
expect(text).toContain('Duplicate it to make a custom role you can change or delete.')
|
||||
expect(button('Close')).toBeDefined()
|
||||
|
||||
button('Duplicate role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(events.duplicate).toEqual([WRITER])
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import PermissionGrid, { type GridChange } from '../PermissionGrid.vue'
|
||||
import PresetPills from '../PresetPills.vue'
|
||||
import { radiosOf, renderInApp } from './roleKit'
|
||||
import { effectiveScope } from '@/lib/permissions'
|
||||
import { applyPreset } from '@/lib/roleForm'
|
||||
import { grantsFrom } from '@/test/access'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
const TYPES = [
|
||||
{ key: 'article', name: 'Articles', builtin: true },
|
||||
{ key: 'events', name: 'Events' },
|
||||
{ key: 'case-studies', name: 'Case studies' },
|
||||
]
|
||||
|
||||
function callerFor(spec: string) {
|
||||
const grants = grantsFrom(spec)
|
||||
return (key: string, options?: { typeKey?: string }) => effectiveScope(grants, key, options)
|
||||
}
|
||||
|
||||
function mountGrid(props: Record<string, unknown> = {}) {
|
||||
const changes: GridChange[] = []
|
||||
const resets: string[] = []
|
||||
const grants = (props.grants as Record<string, string>) ?? {}
|
||||
wrapper = renderInApp(PermissionGrid, {
|
||||
types: TYPES,
|
||||
grants,
|
||||
initial: grants,
|
||||
callerScope: callerFor(
|
||||
'content.read, content.create, content.update, content.delete, content.publish',
|
||||
),
|
||||
onChange: (change: GridChange) => changes.push(change),
|
||||
onReset: (typeKey: string) => resets.push(typeKey),
|
||||
...props,
|
||||
})
|
||||
return { changes, resets }
|
||||
}
|
||||
|
||||
describe('PermissionGrid', () => {
|
||||
it('draws a grid with the five action columns, the All content types row and one row per type', () => {
|
||||
mountGrid()
|
||||
|
||||
expect(wrapper!.find('[role="grid"]').exists()).toBe(true)
|
||||
const headers = wrapper!.findAll('[role="columnheader"]').map((header) => header.text())
|
||||
expect(headers).toEqual(['Content type', 'Read', 'Create', 'Update', 'Delete', 'Publish'])
|
||||
expect(wrapper!.find('[data-testid="row-all"]').text()).toContain('Sets every row')
|
||||
for (const type of TYPES) {
|
||||
expect(wrapper!.find(`[data-testid="row-${type.key}"]`).text()).toContain(type.name)
|
||||
}
|
||||
})
|
||||
|
||||
it('tags Articles as Built-in and Schema locked', () => {
|
||||
mountGrid()
|
||||
|
||||
const row = wrapper!.find('[data-testid="row-article"]').text()
|
||||
expect(row).toContain('Built-in')
|
||||
expect(row).toContain('Schema locked')
|
||||
})
|
||||
|
||||
it('renders Read, Update, Delete and Publish as radiogroups and Create as a switch', () => {
|
||||
mountGrid()
|
||||
|
||||
for (const action of ['Read', 'Update', 'Delete', 'Publish']) {
|
||||
expect(radiosOf(wrapper!, `Events: ${action}`)).toHaveLength(3)
|
||||
}
|
||||
expect(radiosOf(wrapper!, 'Events: Create')).toHaveLength(0)
|
||||
expect(wrapper!.find('[role="switch"][aria-label="Events: Create"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the stored values, with typed rows following the wildcard row', () => {
|
||||
mountGrid({ grants: applyPreset({}, 'write_own') })
|
||||
|
||||
const checked = (name: string) =>
|
||||
radiosOf(wrapper!, name)
|
||||
.find((radio) => radio.getAttribute('aria-checked') === 'true')
|
||||
?.textContent?.trim()
|
||||
expect(checked('All content types: Update')).toBe('Own')
|
||||
expect(checked('Events: Update')).toBe('Own')
|
||||
expect(checked('Events: Delete')).toBe('None')
|
||||
expect(checked('Events: Read')).toBe('All')
|
||||
expect(
|
||||
wrapper!
|
||||
.find('[role="switch"][aria-label="Events: Create"]')
|
||||
.attributes('aria-checked'),
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
it('emits the change for a type row and announces it politely', async () => {
|
||||
const { changes } = mountGrid({ grants: applyPreset({}, 'write_own') })
|
||||
|
||||
radiosOf(wrapper!, 'Events: Update')[2]!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(changes).toEqual([{ action: 'update', scope: 'all', typeKey: 'events' }])
|
||||
const live = wrapper!.find('[data-testid="grid-live-region"]')
|
||||
expect(live.attributes('aria-live')).toBe('polite')
|
||||
expect(live.text()).toBe('Events, Update, set to All.')
|
||||
})
|
||||
|
||||
it('emits the wildcard change without a type key from the All content types row', () => {
|
||||
const { changes } = mountGrid()
|
||||
|
||||
radiosOf(wrapper!, 'All content types: Read')[1]!.click()
|
||||
|
||||
expect(changes).toEqual([{ action: 'read', scope: 'own' }])
|
||||
})
|
||||
|
||||
it('turns Create on with scope all', async () => {
|
||||
const { changes } = mountGrid()
|
||||
|
||||
await wrapper!.find('[role="switch"][aria-label="Events: Create"]').trigger('click')
|
||||
|
||||
expect(changes).toEqual([{ action: 'create', scope: 'all', typeKey: 'events' }])
|
||||
})
|
||||
|
||||
it('shows Custom and Reset on a row with a typed grant and emits reset', async () => {
|
||||
const { resets } = mountGrid({
|
||||
grants: { 'content.read': 'all', 'content.update:case-studies': 'all' },
|
||||
})
|
||||
|
||||
const row = wrapper!.find('[data-testid="row-case-studies"]')
|
||||
expect(row.text()).toContain('Custom')
|
||||
expect(wrapper!.find('[data-testid="row-events"]').text()).not.toContain('Custom')
|
||||
await row
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Reset')!
|
||||
.trigger('click')
|
||||
|
||||
expect(resets).toEqual(['case-studies'])
|
||||
})
|
||||
|
||||
it('marks a changed cell with a square marker', () => {
|
||||
mountGrid({
|
||||
grants: { 'content.update': 'all' },
|
||||
initial: { 'content.update': 'own' },
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper!.find('[data-testid="row-events"] [data-testid="changed-marker"]').exists(),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('locks Publish All with a lock reason when the caller can publish only their own', () => {
|
||||
mountGrid({ callerScope: callerFor('content.read, content.publish=own') })
|
||||
|
||||
const radios = radiosOf(wrapper!, 'Events: Publish')
|
||||
const all = radios[2]!
|
||||
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.",
|
||||
)
|
||||
expect(radios[0]!.getAttribute('aria-disabled')).toBeNull()
|
||||
expect(radios[1]!.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('locks the matching cell for a typed override in the session', () => {
|
||||
mountGrid({
|
||||
callerScope: callerFor('content.read, content.update, content.update:article=none'),
|
||||
})
|
||||
|
||||
expect(radiosOf(wrapper!, 'Articles: Update')[1]!.getAttribute('aria-disabled')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(radiosOf(wrapper!, 'Events: Update')[1]!.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('disables every control when read-only', () => {
|
||||
mountGrid({ readOnly: true })
|
||||
|
||||
for (const radio of radiosOf(wrapper!, 'Events: Read')) {
|
||||
expect(radio.getAttribute('aria-disabled')).toBe('true')
|
||||
}
|
||||
expect(
|
||||
wrapper!.find('[role="switch"][aria-label="Events: Create"]').attributes('disabled'),
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('draws one card per type on mobile with the same controls', () => {
|
||||
mountGrid({ mobile: true })
|
||||
|
||||
expect(wrapper!.find('[role="grid"]').exists()).toBe(false)
|
||||
expect(wrapper!.find('[data-testid="card-events"]').exists()).toBe(true)
|
||||
expect(radiosOf(wrapper!, 'Events: Update')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PresetPills', () => {
|
||||
it('lists the three presets and presses the active one', async () => {
|
||||
const chosen: string[] = []
|
||||
wrapper = renderInApp(PresetPills, {
|
||||
active: 'write_own',
|
||||
onSelect: (preset: string) => chosen.push(preset),
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.map((button) => button.text())).toEqual([
|
||||
'Read only',
|
||||
'Write own',
|
||||
'Full content',
|
||||
])
|
||||
expect(buttons.map((button) => button.attributes('aria-pressed'))).toEqual([
|
||||
'false',
|
||||
'true',
|
||||
'false',
|
||||
])
|
||||
await buttons[2]!.trigger('click')
|
||||
|
||||
expect(chosen).toEqual(['full_content'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import PermissionGroup from '../PermissionGroup.vue'
|
||||
import { radiosOf, renderInApp } from './roleKit'
|
||||
import { effectiveScope } from '@/lib/permissions'
|
||||
import { grantsFrom } from '@/test/access'
|
||||
import type { PermissionGroupItem } from '@/lib/roles'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
const ACTIVITY: PermissionGroupItem = {
|
||||
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 },
|
||||
],
|
||||
}
|
||||
|
||||
const SYSTEM: PermissionGroupItem = {
|
||||
key: 'system',
|
||||
items: [
|
||||
{ key: 'system.apply_updates', scope_kind: 'toggle', owner_only: true, per_type: false },
|
||||
{ key: 'system.edit_retention', scope_kind: 'toggle', owner_only: true, per_type: false },
|
||||
],
|
||||
}
|
||||
|
||||
const CALLER = (spec: string) => {
|
||||
const grants = grantsFrom(spec)
|
||||
return (key: string, options?: { typeKey?: string }) => effectiveScope(grants, key, options)
|
||||
}
|
||||
|
||||
function mountGroup(group: PermissionGroupItem, props: Record<string, unknown> = {}) {
|
||||
const changes: { key: string; scope: string }[] = []
|
||||
wrapper = renderInApp(PermissionGroup, {
|
||||
group,
|
||||
grants: { 'activity.view': 'all', 'activity.export': 'all' },
|
||||
initial: { 'activity.view': 'all', 'activity.export': 'all' },
|
||||
callerScope: CALLER('activity.view, activity.export, system.apply_updates'),
|
||||
open: true,
|
||||
onChange: (change: { key: string; scope: string }) => changes.push(change),
|
||||
...props,
|
||||
})
|
||||
return { changes }
|
||||
}
|
||||
|
||||
describe('PermissionGroup', () => {
|
||||
it('reads "x of y enabled" from the grants', () => {
|
||||
mountGroup(ACTIVITY)
|
||||
|
||||
expect(wrapper!.text()).toContain('2 of 2 enabled')
|
||||
})
|
||||
|
||||
it('counts only the enabled items', () => {
|
||||
mountGroup(ACTIVITY, { grants: { 'activity.view': 'own' } })
|
||||
|
||||
expect(wrapper!.text()).toContain('1 of 2 enabled')
|
||||
})
|
||||
|
||||
it('shows the human names and descriptions of the design', () => {
|
||||
mountGroup(ACTIVITY)
|
||||
|
||||
const text = wrapper!.text()
|
||||
expect(text).toContain('See activity')
|
||||
expect(text).toContain('Read the log of who did what.')
|
||||
expect(text).toContain('Export activity')
|
||||
expect(text).toContain('Download the log as a CSV file.')
|
||||
})
|
||||
|
||||
it('renders scoped rows as None, Own, All and toggle rows as a switch', async () => {
|
||||
const { changes } = mountGroup(ACTIVITY)
|
||||
|
||||
expect(radiosOf(wrapper!, 'See activity')).toHaveLength(3)
|
||||
expect(wrapper!.find('[role="switch"][aria-label="Export activity"]').exists()).toBe(true)
|
||||
|
||||
radiosOf(wrapper!, 'See activity')[1]!.click()
|
||||
await wrapper!.find('[role="switch"][aria-label="Export activity"]').trigger('click')
|
||||
|
||||
expect(changes).toEqual([
|
||||
{ key: 'activity.view', scope: 'own' },
|
||||
{ key: 'activity.export', scope: 'none' },
|
||||
])
|
||||
})
|
||||
|
||||
it('announces a change in a polite live region', async () => {
|
||||
mountGroup(ACTIVITY)
|
||||
|
||||
radiosOf(wrapper!, 'See activity')[1]!.click()
|
||||
await flushPromises()
|
||||
|
||||
const live = wrapper!.find('[role="status"][aria-live="polite"]')
|
||||
expect(live.text()).toBe('See activity, set to Own.')
|
||||
})
|
||||
|
||||
it('hides the rows while collapsed', () => {
|
||||
mountGroup(ACTIVITY, { open: false })
|
||||
|
||||
expect(wrapper!.text()).not.toContain('Read the log of who did what.')
|
||||
expect(wrapper!.text()).toContain('2 of 2 enabled')
|
||||
})
|
||||
|
||||
it('disables owner-only items, keeps them off and tags them Owner only', async () => {
|
||||
const { changes } = mountGroup(SYSTEM, { grants: {}, initial: {} })
|
||||
|
||||
expect(wrapper!.findAll('[data-testid="owner-only-tag"]')).toHaveLength(2)
|
||||
const toggle = wrapper!.find('[role="switch"][aria-label="Apply software updates"]')
|
||||
expect(toggle.attributes('aria-checked')).toBe('false')
|
||||
expect(toggle.attributes('disabled')).toBeDefined()
|
||||
await toggle.trigger('click')
|
||||
|
||||
expect(changes).toEqual([])
|
||||
expect(wrapper!.text()).toContain('0 of 2 enabled')
|
||||
})
|
||||
|
||||
it('locks All on a scoped row the caller holds only as Own', () => {
|
||||
mountGroup(ACTIVITY, { callerScope: CALLER('activity.view=own, activity.export') })
|
||||
|
||||
const all = radiosOf(wrapper!, 'See activity')[2]!
|
||||
expect(all.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('disables everything when read-only', () => {
|
||||
mountGroup(ACTIVITY, { readOnly: true })
|
||||
|
||||
for (const radio of radiosOf(wrapper!, 'See activity')) {
|
||||
expect(radio.getAttribute('aria-disabled')).toBe('true')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import RolePreview from '../RolePreview.vue'
|
||||
import { renderInApp } from './roleKit'
|
||||
import { applyPreset } from '@/lib/roleForm'
|
||||
import { buildRolePreview } from '@/lib/rolePreview'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
const TYPES = [{ key: 'article', name: 'Articles' }]
|
||||
|
||||
function model(changed = false) {
|
||||
const initial = applyPreset({}, 'write_own')
|
||||
const grants = changed ? { ...initial, 'content.update': 'all' as const } : initial
|
||||
return buildRolePreview({ grants, types: TYPES, members: 4, initial })
|
||||
}
|
||||
|
||||
describe('RolePreview card', () => {
|
||||
it('titles the card and says it updates as you edit', () => {
|
||||
wrapper = renderInApp(RolePreview, { model: model() })
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('What this role can do')
|
||||
expect(text).toContain('Updates as you edit.')
|
||||
expect(text).toContain('Content')
|
||||
expect(text).toContain('All content types')
|
||||
expect(text).toContain('Applies to 4 members, and to the API keys they created.')
|
||||
expect(text).toContain(
|
||||
'No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.',
|
||||
)
|
||||
})
|
||||
|
||||
it('tags the changed line', () => {
|
||||
wrapper = renderInApp(RolePreview, { model: model(true) })
|
||||
|
||||
expect(wrapper.text()).toContain('Changed')
|
||||
})
|
||||
|
||||
it('reads a summary without "Updates as you edit." when read-only', () => {
|
||||
wrapper = renderInApp(RolePreview, { model: model(), readOnly: true })
|
||||
|
||||
expect(wrapper.text()).toContain('A summary of what this role allows.')
|
||||
expect(wrapper.text()).not.toContain('Updates as you edit.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RolePreview mobile sheet', () => {
|
||||
it('opens a bottom sheet with a Close button that closes it', async () => {
|
||||
const states: boolean[] = []
|
||||
wrapper = renderInApp(RolePreview, {
|
||||
model: model(),
|
||||
mobile: true,
|
||||
open: true,
|
||||
'onUpdate:open': (value: boolean) => states.push(value),
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const sheet = document.body.querySelector('[role="dialog"]')!
|
||||
expect(sheet.textContent).toContain('What this role can do')
|
||||
expect(sheet.textContent).toContain('Updates as you edit.')
|
||||
const close = Array.from(sheet.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Close',
|
||||
)!
|
||||
close.click()
|
||||
|
||||
expect(states).toEqual([false])
|
||||
})
|
||||
|
||||
it('renders nothing while closed', async () => {
|
||||
wrapper = renderInApp(RolePreview, { model: model(), mobile: true, open: false })
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import ScopeControl from '../ScopeControl.vue'
|
||||
import OnOffCell from '../OnOffCell.vue'
|
||||
import { press, radiosOf, renderInApp } from './roleKit'
|
||||
import type { Scope } from '@/lib/permissions'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountControl(initial: Scope, extra: Record<string, unknown> = {}) {
|
||||
const value = ref<Scope>(initial)
|
||||
const changes: Scope[] = []
|
||||
wrapper = renderInApp(ScopeControl, {
|
||||
label: 'Events: Update',
|
||||
modelValue: initial,
|
||||
'onUpdate:modelValue': (scope: Scope) => {
|
||||
changes.push(scope)
|
||||
value.value = scope
|
||||
},
|
||||
...extra,
|
||||
})
|
||||
return { changes }
|
||||
}
|
||||
|
||||
describe('ScopeControl', () => {
|
||||
it('is a radiogroup named after the type and action with None, Own and All', () => {
|
||||
mountControl('own')
|
||||
|
||||
const radios = radiosOf(wrapper!, 'Events: Update')
|
||||
expect(radios.map((radio) => radio.textContent?.trim())).toEqual(['None', 'Own', 'All'])
|
||||
expect(radios.map((radio) => radio.getAttribute('aria-checked'))).toEqual([
|
||||
'false',
|
||||
'true',
|
||||
'false',
|
||||
])
|
||||
})
|
||||
|
||||
it('has one tab stop: the checked radio', () => {
|
||||
mountControl('own')
|
||||
|
||||
expect(radiosOf(wrapper!, 'Events: Update').map((radio) => radio.tabIndex)).toEqual([
|
||||
-1, 0, -1,
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the checked radio as the single tab stop when the whole control is disabled', () => {
|
||||
mountControl('own', { disabled: true })
|
||||
|
||||
const radios = radiosOf(wrapper!, 'Events: Update')
|
||||
expect(radios.map((radio) => radio.tabIndex)).toEqual([-1, 0, -1])
|
||||
expect(radios.map((radio) => radio.getAttribute('aria-disabled'))).toEqual([
|
||||
'true',
|
||||
'true',
|
||||
'true',
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the first option as tab stop when disabled and nothing matches', () => {
|
||||
mountControl('none', { disabled: true })
|
||||
|
||||
expect(radiosOf(wrapper!, 'Events: Update').map((radio) => radio.tabIndex)).toEqual([
|
||||
0, -1, -1,
|
||||
])
|
||||
})
|
||||
|
||||
it('moves the tab stop off a locked checked option only when an enabled one exists', () => {
|
||||
mountControl('all', { locks: { all: 'Locked' } })
|
||||
|
||||
expect(radiosOf(wrapper!, 'Events: Update').map((radio) => radio.tabIndex)).toEqual([
|
||||
0, -1, -1,
|
||||
])
|
||||
})
|
||||
|
||||
it('selects with a click', async () => {
|
||||
const { changes } = mountControl('own')
|
||||
|
||||
radiosOf(wrapper!, 'Events: Update')[2]!.click()
|
||||
|
||||
expect(changes).toEqual(['all'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ArrowRight', 'own', 'all'],
|
||||
['ArrowDown', 'own', 'all'],
|
||||
['ArrowLeft', 'own', 'none'],
|
||||
['ArrowUp', 'own', 'none'],
|
||||
['Home', 'all', 'none'],
|
||||
['End', 'none', 'all'],
|
||||
] as const)('%s moves from %s to %s', async (key, from, to) => {
|
||||
const { changes } = mountControl(from)
|
||||
const current = radiosOf(wrapper!, 'Events: Update').find(
|
||||
(radio) => radio.getAttribute('aria-checked') === 'true',
|
||||
)!
|
||||
current.focus()
|
||||
|
||||
press(current, key)
|
||||
await flushPromises()
|
||||
|
||||
expect(changes).toEqual([to])
|
||||
})
|
||||
|
||||
it('selects the focused radio with Space', async () => {
|
||||
const { changes } = mountControl('none')
|
||||
const all = radiosOf(wrapper!, 'Events: Update')[2]!
|
||||
all.focus()
|
||||
|
||||
press(all, ' ')
|
||||
await flushPromises()
|
||||
|
||||
expect(changes).toEqual(['all'])
|
||||
})
|
||||
|
||||
it('marks a locked option aria-disabled, describes it by its reason and does not select it', async () => {
|
||||
const reason = 'You can only grant what your role has.'
|
||||
const { changes } = mountControl('own', { locks: { all: reason } })
|
||||
|
||||
const all = radiosOf(wrapper!, 'Events: Update')[2]!
|
||||
all.click()
|
||||
const described = document.getElementById(all.getAttribute('aria-describedby')!)
|
||||
|
||||
expect(all.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(described?.textContent).toBe(reason)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('skips locked options with the keyboard and keeps None and Own available', async () => {
|
||||
const { changes } = mountControl('none', { locks: { all: 'x' } })
|
||||
const none = radiosOf(wrapper!, 'Events: Update')[0]!
|
||||
none.focus()
|
||||
|
||||
press(none, 'End')
|
||||
await flushPromises()
|
||||
|
||||
expect(changes).toEqual(['own'])
|
||||
})
|
||||
|
||||
it('disables every option when the control is disabled', () => {
|
||||
mountControl('own', { disabled: true })
|
||||
|
||||
for (const radio of radiosOf(wrapper!, 'Events: Update')) {
|
||||
expect(radio.getAttribute('aria-disabled')).toBe('true')
|
||||
}
|
||||
})
|
||||
|
||||
it('draws a square marker on a changed cell', () => {
|
||||
mountControl('own', { changed: true })
|
||||
|
||||
expect(wrapper!.find('[data-testid="changed-marker"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OnOffCell', () => {
|
||||
it('is a switch named "{Type}: Create" with a visible On or Off label', () => {
|
||||
wrapper = renderInApp(OnOffCell, { label: 'Events: Create', modelValue: true })
|
||||
|
||||
const toggle = wrapper.find('[role="switch"]')
|
||||
expect(toggle.attributes('aria-label')).toBe('Events: Create')
|
||||
expect(toggle.attributes('aria-checked')).toBe('true')
|
||||
expect(wrapper.text()).toContain('On')
|
||||
})
|
||||
|
||||
it('says Off when off and emits the new value on click', async () => {
|
||||
const seen: boolean[] = []
|
||||
wrapper = renderInApp(OnOffCell, {
|
||||
label: 'Events: Create',
|
||||
modelValue: false,
|
||||
'onUpdate:modelValue': (value: boolean) => seen.push(value),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Off')
|
||||
await wrapper.find('[role="switch"]').trigger('click')
|
||||
|
||||
expect(seen).toEqual([true])
|
||||
})
|
||||
|
||||
it('shows the lock reason and cannot be toggled when locked', async () => {
|
||||
const seen: boolean[] = []
|
||||
wrapper = renderInApp(OnOffCell, {
|
||||
label: 'Events: Create',
|
||||
modelValue: false,
|
||||
disabled: true,
|
||||
reason: 'Only the owner can grant this.',
|
||||
'onUpdate:modelValue': (value: boolean) => seen.push(value),
|
||||
})
|
||||
|
||||
await wrapper.find('[role="switch"]').trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('Only the owner can grant this.')
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { h } from 'vue'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import router from '@/router'
|
||||
|
||||
export function renderInApp(component: object, props: Record<string, unknown> = {}) {
|
||||
return mount(() => h(UApp, null, () => h(component, props)), {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
export function radiosOf(root: VueWrapper | Element, name: string) {
|
||||
const element = 'element' in root ? root.element : root
|
||||
const group = element.querySelector(`[role="radiogroup"][aria-label="${name}"]`)
|
||||
return Array.from(group?.querySelectorAll<HTMLElement>('[role="radio"]') ?? [])
|
||||
}
|
||||
|
||||
export function press(target: Element, key: string) {
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { RoleConflictError, useRoles } from '@/composables/useRoles'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const request = vi.fn()
|
||||
|
||||
function role(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 4,
|
||||
name: 'Contributor',
|
||||
description: '',
|
||||
built_in: false,
|
||||
locked: false,
|
||||
version: 2,
|
||||
grants: [],
|
||||
members: 4,
|
||||
updated_at: '2026-10-27T11:02:00Z',
|
||||
updated_by: { id: 2, name: 'Jonas Weber' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
vi.mocked(useApi).mockReturnValue({ request } as unknown as ReturnType<typeof useApi>)
|
||||
})
|
||||
|
||||
describe('useRoles calls', () => {
|
||||
it('lists and gets roles', async () => {
|
||||
request.mockResolvedValue([])
|
||||
await useRoles().listRoles()
|
||||
await useRoles().getRole(4)
|
||||
|
||||
expect(request.mock.calls[0]![0]).toBe('/v1/role/')
|
||||
expect(request.mock.calls[1]![0]).toBe('/v1/role/4')
|
||||
})
|
||||
|
||||
it('reads the permission groups out of the catalogue body', async () => {
|
||||
request.mockResolvedValue({ groups: [{ key: 'content', items: [] }] })
|
||||
|
||||
await expect(useRoles().listPermissions()).resolves.toEqual([{ key: 'content', items: [] }])
|
||||
expect(request.mock.calls[0]![0]).toBe('/v1/permission/')
|
||||
})
|
||||
|
||||
it('creates with name, description and grants only', async () => {
|
||||
request.mockResolvedValue(role())
|
||||
const payload = {
|
||||
name: 'Contributor',
|
||||
description: 'x',
|
||||
grants: [{ key: 'content.read', scope: 'all' as const }],
|
||||
}
|
||||
|
||||
await useRoles().createRole(payload)
|
||||
|
||||
const [path, options] = request.mock.calls[0]!
|
||||
expect(path).toBe('/v1/role/')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(JSON.parse(options.body)).toEqual(payload)
|
||||
})
|
||||
|
||||
it('updates with the version', async () => {
|
||||
request.mockResolvedValue(role())
|
||||
|
||||
await useRoles().updateRole(4, { name: 'C', description: '', grants: [] }, 2)
|
||||
|
||||
const [path, options] = request.mock.calls[0]!
|
||||
expect(path).toBe('/v1/role/4')
|
||||
expect(options.method).toBe('PUT')
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
name: 'C',
|
||||
description: '',
|
||||
grants: [],
|
||||
version: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('resets with the version', async () => {
|
||||
request.mockResolvedValue(role())
|
||||
|
||||
await useRoles().resetRole(3, 5)
|
||||
|
||||
const [path, options] = request.mock.calls[0]!
|
||||
expect(path).toBe('/v1/role/3/reset')
|
||||
expect(JSON.parse(options.body)).toEqual({ version: 5 })
|
||||
})
|
||||
|
||||
it('reassigns then deletes', async () => {
|
||||
request.mockResolvedValue(undefined)
|
||||
|
||||
await useRoles().reassignRole(4, 3)
|
||||
await useRoles().deleteRole(4)
|
||||
|
||||
expect(request.mock.calls[0]![0]).toBe('/v1/role/4/reassign')
|
||||
expect(JSON.parse(request.mock.calls[0]![1].body)).toEqual({ target_role_id: 3 })
|
||||
expect(request.mock.calls[1]![0]).toBe('/v1/role/4')
|
||||
expect(request.mock.calls[1]![1].method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('does not toast a missing permission itself', async () => {
|
||||
request.mockResolvedValue([])
|
||||
await useRoles().listRoles()
|
||||
|
||||
expect(request.mock.calls[0]![1]).toMatchObject({ onMissingPermission: 'passthrough' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('role_conflict', () => {
|
||||
it('re-reads the role on a conflicting update and throws it with the editor', async () => {
|
||||
const latest = role({ version: 3 })
|
||||
request
|
||||
.mockRejectedValueOnce(
|
||||
new ApiError(409, undefined, 'role_conflict', { code: 'role_conflict' }),
|
||||
)
|
||||
.mockResolvedValueOnce(latest)
|
||||
|
||||
const failure = await useRoles()
|
||||
.updateRole(4, { name: 'C', description: '', grants: [] }, 2)
|
||||
.catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(RoleConflictError)
|
||||
expect((failure as RoleConflictError).latest?.updated_by?.name).toBe('Jonas Weber')
|
||||
expect(request.mock.calls[1]![0]).toBe('/v1/role/4')
|
||||
})
|
||||
|
||||
it('lets every other refusal through unchanged', async () => {
|
||||
const refusal = new ApiError(409, undefined, 'role_name_taken', { code: 'role_name_taken' })
|
||||
request.mockRejectedValue(refusal)
|
||||
|
||||
await expect(
|
||||
useRoles().updateRole(4, { name: 'C', description: '', grants: [] }, 2),
|
||||
).rejects.toBe(refusal)
|
||||
})
|
||||
|
||||
it('conflicts on reset too', async () => {
|
||||
request
|
||||
.mockRejectedValueOnce(
|
||||
new ApiError(409, undefined, 'role_conflict', { code: 'role_conflict' }),
|
||||
)
|
||||
.mockResolvedValueOnce(role())
|
||||
|
||||
await expect(useRoles().resetRole(4, 1)).rejects.toBeInstanceOf(RoleConflictError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,557 @@
|
||||
import { t, locale } from '@/i18n'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { RoleConflictError, useRoles } from '@/composables/useRoles'
|
||||
import { refusalCopy, type RefusalCopy } from '@/lib/permissionCopy'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import {
|
||||
applyPreset,
|
||||
cloneForm,
|
||||
differences,
|
||||
emptyForm,
|
||||
formFromRole,
|
||||
grantsToPayload,
|
||||
resetRow,
|
||||
setScope,
|
||||
unsavedCount,
|
||||
activePreset,
|
||||
contentKey,
|
||||
type ContentAction,
|
||||
type PresetName,
|
||||
type RoleForm,
|
||||
} from '@/lib/roleForm'
|
||||
import { buildRolePreview } from '@/lib/rolePreview'
|
||||
import {
|
||||
OWNER_ONLY_FALLBACK,
|
||||
formatClock,
|
||||
type PermissionGroupItem,
|
||||
type RoleItem,
|
||||
} from '@/lib/roles'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
import { parseGrantKey, type Scope } from '@/lib/permissions'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
export type LoadState = 'loading' | 'ready' | 'error' | 'missing'
|
||||
|
||||
export type ReadOnlyReason = 'owner' | 'lowered' | 'view' | 'own' | 'builtin' | 'locked'
|
||||
|
||||
export interface ConflictState {
|
||||
title: string
|
||||
description: string
|
||||
latest: RoleItem | null
|
||||
}
|
||||
|
||||
const NO_CONFLICT = null
|
||||
|
||||
export function useRoleEditor() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useLocalizedToast()
|
||||
const permissions = usePermissionsStore()
|
||||
const session = useSessionStore()
|
||||
const contentTypes = useContentTypesStore()
|
||||
const api = useRoles()
|
||||
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const role = ref<RoleItem | null>(null)
|
||||
const form = ref<RoleForm>(emptyForm())
|
||||
const initial = ref<RoleForm>(emptyForm())
|
||||
const catalogue = ref<PermissionGroupItem[]>([])
|
||||
const saving = ref(false)
|
||||
const nameError = ref('')
|
||||
const conflict = ref<ConflictState | null>(NO_CONFLICT)
|
||||
const lowered = ref(false)
|
||||
const serverLocked = ref(false)
|
||||
const serverOwnRole = ref(false)
|
||||
const refusal = ref<RefusalCopy | null>(null)
|
||||
const reviewing = ref(false)
|
||||
let loadedKey = ''
|
||||
let loadToken = 0
|
||||
|
||||
const isNew = computed(() => route.params.id === undefined)
|
||||
|
||||
const ownerOnlyKeys = computed(() => {
|
||||
const keys = catalogue.value.flatMap((group) =>
|
||||
group.items.filter((item) => item.owner_only).map((item) => item.key),
|
||||
)
|
||||
return keys.length > 0 ? new Set(keys) : OWNER_ONLY_FALLBACK
|
||||
})
|
||||
|
||||
const readOnlyReason = computed<ReadOnlyReason | null>(() => {
|
||||
if (role.value?.locked) {
|
||||
return 'owner'
|
||||
}
|
||||
if (lowered.value) {
|
||||
return 'lowered'
|
||||
}
|
||||
if (!permissions.can('roles.manage')) {
|
||||
return 'view'
|
||||
}
|
||||
if (serverOwnRole.value || (role.value && role.value.id === permissions.role?.id)) {
|
||||
return 'own'
|
||||
}
|
||||
if (role.value?.built_in && !permissions.can('roles.edit_owner_only')) {
|
||||
return 'builtin'
|
||||
}
|
||||
return serverLocked.value ? 'locked' : null
|
||||
})
|
||||
|
||||
const readOnly = computed(() => readOnlyReason.value !== null)
|
||||
const unsaved = computed(() => unsavedCount(form.value, initial.value))
|
||||
const problems = computed(() => (nameError.value === '' ? 0 : 1))
|
||||
const nameMissing = computed(() => form.value.name.trim() === '')
|
||||
const canSave = computed(
|
||||
() =>
|
||||
!readOnly.value &&
|
||||
!saving.value &&
|
||||
conflict.value === null &&
|
||||
!(isNew.value && nameMissing.value),
|
||||
)
|
||||
const barVisible = computed(
|
||||
() =>
|
||||
!readOnly.value &&
|
||||
loadState.value === 'ready' &&
|
||||
(isNew.value ||
|
||||
unsaved.value > 0 ||
|
||||
saving.value ||
|
||||
problems.value > 0 ||
|
||||
conflict.value !== null),
|
||||
)
|
||||
const preset = computed(() => activePreset(form.value.grants))
|
||||
const canReset = computed(
|
||||
() =>
|
||||
role.value !== null &&
|
||||
role.value.built_in &&
|
||||
!role.value.locked &&
|
||||
permissions.can('roles.manage') &&
|
||||
permissions.can('roles.edit_owner_only') &&
|
||||
!readOnly.value,
|
||||
)
|
||||
|
||||
const gridTypes = computed(() => {
|
||||
const named = [
|
||||
{ key: ARTICLE_TYPE_KEY, name: t('Articles'), builtin: true },
|
||||
...contentTypes.items.map((type) => ({
|
||||
key: type.key,
|
||||
name: type.name,
|
||||
builtin: false,
|
||||
})),
|
||||
]
|
||||
const known = new Set(named.map((type) => type.key))
|
||||
const typed = Object.keys(form.value.grants)
|
||||
.map((key) => parseGrantKey(key))
|
||||
.filter(({ base }) => base.startsWith('content.'))
|
||||
.map(({ typeKey }) => typeKey)
|
||||
.filter((key): key is string => key !== undefined)
|
||||
.filter((key, index, all) => !known.has(key) && all.indexOf(key) === index)
|
||||
.map((key) => ({ key, name: key, builtin: false }))
|
||||
return [...named, ...typed]
|
||||
})
|
||||
|
||||
const preview = computed(() =>
|
||||
buildRolePreview({
|
||||
grants: form.value.grants,
|
||||
types: gridTypes.value.map((type) => ({ key: type.key, name: type.name })),
|
||||
members: role.value?.members ?? null,
|
||||
initial: isNew.value || readOnly.value ? undefined : initial.value.grants,
|
||||
}),
|
||||
)
|
||||
|
||||
const differenceRows = computed(() => {
|
||||
const latest = conflict.value?.latest
|
||||
if (!latest) {
|
||||
return []
|
||||
}
|
||||
return differences(form.value, formFromRole(latest)).map((entry) => ({
|
||||
name: permissionName(entry.key) ?? entry.key,
|
||||
key: entry.key,
|
||||
kind:
|
||||
entry.before === undefined || entry.before === 'none'
|
||||
? ('added' as const)
|
||||
: entry.after === undefined || entry.after === 'none'
|
||||
? ('removed' as const)
|
||||
: ('changed' as const),
|
||||
}))
|
||||
})
|
||||
|
||||
function adopt(next: RoleItem) {
|
||||
role.value = next
|
||||
form.value = formFromRole(next)
|
||||
initial.value = cloneForm(form.value)
|
||||
nameError.value = ''
|
||||
conflict.value = NO_CONFLICT
|
||||
reviewing.value = false
|
||||
refusal.value = null
|
||||
}
|
||||
|
||||
function routeKey() {
|
||||
return isNew.value ? `new:${route.query.from ?? ''}` : String(route.params.id)
|
||||
}
|
||||
|
||||
async function loadCatalogue() {
|
||||
try {
|
||||
catalogue.value = await api.listPermissions()
|
||||
} catch {
|
||||
catalogue.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSource(token: number) {
|
||||
if (isNew.value) {
|
||||
const from = Number(route.query.from)
|
||||
if (route.query.from === undefined || !Number.isInteger(from)) {
|
||||
role.value = null
|
||||
form.value = emptyForm()
|
||||
initial.value = emptyForm()
|
||||
return
|
||||
}
|
||||
const source = await api.getRole(from)
|
||||
if (token !== loadToken) {
|
||||
return
|
||||
}
|
||||
role.value = null
|
||||
form.value = { name: '', description: '', grants: formFromRole(source).grants }
|
||||
initial.value = cloneForm(form.value)
|
||||
loadedKey = 'new:'
|
||||
await router.replace({ path: '/settings/roles/new' })
|
||||
return
|
||||
}
|
||||
const id = Number(route.params.id)
|
||||
if (!Number.isInteger(id)) {
|
||||
loadState.value = 'missing'
|
||||
return
|
||||
}
|
||||
const fetched = await api.getRole(id)
|
||||
if (token !== loadToken) {
|
||||
return
|
||||
}
|
||||
adopt(fetched)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loadedKey = routeKey()
|
||||
const token = ++loadToken
|
||||
loadState.value = 'loading'
|
||||
lowered.value = false
|
||||
serverLocked.value = false
|
||||
serverOwnRole.value = false
|
||||
conflict.value = NO_CONFLICT
|
||||
const pending = [loadCatalogue()]
|
||||
if (permissions.can('schema.view')) {
|
||||
pending.push(contentTypes.load())
|
||||
}
|
||||
try {
|
||||
await Promise.all([loadSource(token), ...pending])
|
||||
if (token !== loadToken) {
|
||||
return
|
||||
}
|
||||
if (loadState.value === 'loading') {
|
||||
loadState.value = 'ready'
|
||||
}
|
||||
} catch (error) {
|
||||
if (token !== loadToken) {
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'role_not_found') {
|
||||
loadState.value = 'missing'
|
||||
} else if (error instanceof ApiError && error.code === 'missing_permission') {
|
||||
lowered.value = true
|
||||
loadState.value = 'ready'
|
||||
} else {
|
||||
loadState.value = 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.id, route.query.from],
|
||||
() => {
|
||||
if (routeKey() !== loadedKey) {
|
||||
void load()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function changeCell(change: { action: ContentAction; scope: Scope; typeKey?: string }) {
|
||||
form.value = {
|
||||
...form.value,
|
||||
grants: setScope(
|
||||
form.value.grants,
|
||||
contentKey(change.action),
|
||||
change.scope,
|
||||
change.typeKey,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function changeGeneral(change: { key: string; scope: Scope }) {
|
||||
form.value = {
|
||||
...form.value,
|
||||
grants: setScope(form.value.grants, change.key, change.scope),
|
||||
}
|
||||
}
|
||||
|
||||
function choosePreset(next: PresetName) {
|
||||
form.value = { ...form.value, grants: applyPreset(form.value.grants, next) }
|
||||
}
|
||||
|
||||
function resetType(typeKey: string) {
|
||||
form.value = { ...form.value, grants: resetRow(form.value.grants, typeKey) }
|
||||
}
|
||||
|
||||
function setName(name: string) {
|
||||
form.value = { ...form.value, name }
|
||||
nameError.value = ''
|
||||
}
|
||||
|
||||
function setDescription(description: string) {
|
||||
form.value = { ...form.value, description }
|
||||
}
|
||||
|
||||
function discard() {
|
||||
form.value = cloneForm(initial.value)
|
||||
nameError.value = ''
|
||||
}
|
||||
|
||||
function conflictFrom(latest: RoleItem | null): ConflictState {
|
||||
return {
|
||||
title: t('This role changed while you were editing'),
|
||||
description: t(
|
||||
'{0} saved changes to {1} at {2}. Reload to see them, or review how they differ from your edits.',
|
||||
[
|
||||
latest?.updated_by?.name ?? t('Someone'),
|
||||
latest?.name ?? form.value.name,
|
||||
formatClock(latest?.updated_at, locale.value),
|
||||
],
|
||||
),
|
||||
latest,
|
||||
}
|
||||
}
|
||||
|
||||
function reloadRole() {
|
||||
if (conflict.value?.latest) {
|
||||
adopt(conflict.value.latest)
|
||||
return
|
||||
}
|
||||
void load()
|
||||
}
|
||||
|
||||
async function reloadPage() {
|
||||
await session.refresh()
|
||||
lowered.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
function toastSuccess(id: string, title: string, description: string) {
|
||||
toast.add(() => ({
|
||||
id,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title,
|
||||
description,
|
||||
}))
|
||||
}
|
||||
|
||||
function toastFailure() {
|
||||
toast.add(() => ({
|
||||
id: 'role-save-failed',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: t("Couldn't save the role"),
|
||||
description: t('Try again in a moment.'),
|
||||
}))
|
||||
}
|
||||
|
||||
function memberCountCopy(saved: RoleItem) {
|
||||
return saved.members === 1
|
||||
? t('{0} is updated for 1 member.', [saved.name])
|
||||
: t('{0} is updated for {1} members.', [saved.name, saved.members])
|
||||
}
|
||||
|
||||
function showRefusal(error: ApiError) {
|
||||
const copy =
|
||||
error.code === undefined ? undefined : refusalCopy(error.code, error.permission)
|
||||
if (copy === undefined) {
|
||||
toastFailure()
|
||||
return
|
||||
}
|
||||
refusal.value = error.escalationKey
|
||||
? {
|
||||
title: copy.title,
|
||||
description:
|
||||
`${copy.description ?? ''} ${permissionName(error.escalationKey) ?? error.escalationKey}`.trim(),
|
||||
}
|
||||
: copy
|
||||
}
|
||||
|
||||
function handleSaveError(error: unknown) {
|
||||
if (error instanceof RoleConflictError) {
|
||||
conflict.value = conflictFrom(error.latest)
|
||||
return
|
||||
}
|
||||
if (!(error instanceof ApiError)) {
|
||||
toastFailure()
|
||||
return
|
||||
}
|
||||
switch (error.code) {
|
||||
case 'invalid_role_name':
|
||||
nameError.value = t('Give the role a name.')
|
||||
return
|
||||
case 'role_name_taken':
|
||||
nameError.value = t('A role named "{0}" already exists. Choose a different name.', [
|
||||
form.value.name.trim(),
|
||||
])
|
||||
return
|
||||
case 'built_in_name':
|
||||
nameError.value = t('Built-in roles keep their name.')
|
||||
return
|
||||
case 'missing_permission':
|
||||
lowered.value = true
|
||||
return
|
||||
case 'role_locked':
|
||||
serverLocked.value = true
|
||||
showRefusal(error)
|
||||
return
|
||||
case 'own_role':
|
||||
serverOwnRole.value = true
|
||||
showRefusal(error)
|
||||
return
|
||||
default:
|
||||
showRefusal(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value) {
|
||||
return
|
||||
}
|
||||
nameError.value = ''
|
||||
refusal.value = null
|
||||
if (nameMissing.value) {
|
||||
nameError.value = t('Give the role a name.')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
name: form.value.name.trim(),
|
||||
description: form.value.description,
|
||||
grants: grantsToPayload(form.value.grants, ownerOnlyKeys.value),
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const created = await api.createRole(payload)
|
||||
adopt(created)
|
||||
loadedKey = String(created.id)
|
||||
toastSuccess(
|
||||
`role-created-${created.id}`,
|
||||
t('Role created'),
|
||||
t('{0} is ready. Assign it from Users.', [created.name]),
|
||||
)
|
||||
await router.replace(`/settings/roles/${created.id}`)
|
||||
return
|
||||
}
|
||||
const updated = await api.updateRole(role.value!.id, payload, role.value!.version)
|
||||
adopt(updated)
|
||||
toastSuccess(`role-saved-${updated.id}`, t('Role saved'), memberCountCopy(updated))
|
||||
} catch (error) {
|
||||
handleSaveError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toastResetFailure() {
|
||||
toast.add(() => ({
|
||||
id: 'role-reset-failed',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: t("Couldn't reset the role. Try again in a moment."),
|
||||
}))
|
||||
}
|
||||
|
||||
async function resetRole(): Promise<boolean> {
|
||||
const target = role.value
|
||||
if (!target) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const restored = await api.resetRole(target.id, target.version)
|
||||
adopt(restored)
|
||||
toastSuccess(
|
||||
`role-reset-${restored.id}`,
|
||||
t('Role reset'),
|
||||
t('{0} is back to its defaults.', [restored.name]),
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof RoleConflictError) {
|
||||
conflict.value = conflictFrom(error.latest)
|
||||
} else if (error instanceof ApiError && error.code === 'missing_permission') {
|
||||
lowered.value = true
|
||||
} else if (error instanceof ApiError) {
|
||||
showRefusal(error)
|
||||
} else {
|
||||
toastResetFailure()
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleReview() {
|
||||
reviewing.value = !reviewing.value
|
||||
}
|
||||
|
||||
function duplicate() {
|
||||
if (role.value) {
|
||||
void router.push({
|
||||
path: '/settings/roles/new',
|
||||
query: { from: String(role.value.id) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isNew,
|
||||
loadState,
|
||||
role,
|
||||
form,
|
||||
initial,
|
||||
catalogue,
|
||||
saving,
|
||||
nameError,
|
||||
conflict,
|
||||
lowered,
|
||||
refusal,
|
||||
reviewing,
|
||||
readOnlyReason,
|
||||
readOnly,
|
||||
unsaved,
|
||||
problems,
|
||||
nameMissing,
|
||||
canSave,
|
||||
barVisible,
|
||||
preset,
|
||||
canReset,
|
||||
gridTypes,
|
||||
preview,
|
||||
differenceRows,
|
||||
load,
|
||||
changeCell,
|
||||
changeGeneral,
|
||||
choosePreset,
|
||||
resetType,
|
||||
setName,
|
||||
setDescription,
|
||||
discard,
|
||||
save,
|
||||
reloadRole,
|
||||
reloadPage,
|
||||
resetRole,
|
||||
toggleReview,
|
||||
duplicate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useApi, ApiError } from '@/composables/useApi'
|
||||
import type { PermissionGroupItem, RoleItem, RolePayload } from '@/lib/roles'
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' }
|
||||
const PASSTHROUGH = { onMissingPermission: 'passthrough' } as const
|
||||
|
||||
export class RoleConflictError extends Error {
|
||||
latest: RoleItem | null
|
||||
|
||||
constructor(latest: RoleItem | null) {
|
||||
super('Role changed while editing')
|
||||
this.latest = latest
|
||||
}
|
||||
}
|
||||
|
||||
export function useRoles() {
|
||||
const { request } = useApi()
|
||||
|
||||
function listRoles(): Promise<RoleItem[]> {
|
||||
return request<RoleItem[]>('/v1/role/', PASSTHROUGH)
|
||||
}
|
||||
|
||||
function getRole(id: number): Promise<RoleItem> {
|
||||
return request<RoleItem>(`/v1/role/${id}`, PASSTHROUGH)
|
||||
}
|
||||
|
||||
function listPermissions(): Promise<PermissionGroupItem[]> {
|
||||
return request<{ groups: PermissionGroupItem[] }>('/v1/permission/', PASSTHROUGH).then(
|
||||
(body) => body.groups,
|
||||
)
|
||||
}
|
||||
|
||||
function createRole(payload: RolePayload): Promise<RoleItem> {
|
||||
return request<RoleItem>('/v1/role/', {
|
||||
...PASSTHROUGH,
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
async function rereadAfterConflict<T>(id: number, send: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await send()
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'role_conflict') {
|
||||
const latest = await getRole(id).catch(() => null)
|
||||
throw new RoleConflictError(latest)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function updateRole(id: number, payload: RolePayload, version: number): Promise<RoleItem> {
|
||||
return rereadAfterConflict(id, () =>
|
||||
request<RoleItem>(`/v1/role/${id}`, {
|
||||
...PASSTHROUGH,
|
||||
method: 'PUT',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ ...payload, version }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resetRole(id: number, version: number): Promise<RoleItem> {
|
||||
return rereadAfterConflict(id, () =>
|
||||
request<RoleItem>(`/v1/role/${id}/reset`, {
|
||||
...PASSTHROUGH,
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ version }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function deleteRole(id: number): Promise<void> {
|
||||
return request<void>(`/v1/role/${id}`, { ...PASSTHROUGH, method: 'DELETE' })
|
||||
}
|
||||
|
||||
function reassignRole(id: number, targetRoleId: number): Promise<{ moved: number }> {
|
||||
return request<{ moved: number }>(`/v1/role/${id}/reassign`, {
|
||||
...PASSTHROUGH,
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ target_role_id: targetRoleId }),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
listRoles,
|
||||
getRole,
|
||||
listPermissions,
|
||||
createRole,
|
||||
updateRole,
|
||||
resetRole,
|
||||
deleteRole,
|
||||
reassignRole,
|
||||
}
|
||||
}
|
||||
@@ -2917,6 +2917,226 @@ export const messages = {
|
||||
'Save draft': 'Enregistrer le brouillon',
|
||||
'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.':
|
||||
'L’identifiant du rôle à donner. Absent ou 0, c’est le rôle Rédacteur. Le rôle Propriétaire est refusé.',
|
||||
Access: 'Accès',
|
||||
Roles: 'Rôles',
|
||||
"Built-in roles can't be deleted.": 'Les rôles intégrés ne peuvent pas être supprimés.',
|
||||
'{0} members have this role. Move them to another role first. Then {1} can be deleted.':
|
||||
'{0} membres ont ce rôle. Déplacez-les d’abord vers un autre rôle. Ensuite, {1} pourra être supprimé.',
|
||||
"No one has this role. This can't be undone.":
|
||||
'Personne n’a ce rôle. Cette action est irréversible.',
|
||||
'Duplicate role': 'Dupliquer le rôle',
|
||||
'Move {0} members and delete role': 'Déplacer {0} membres et supprimer le rôle',
|
||||
'Delete role': 'Supprimer le rôle',
|
||||
'Moving and deleting…': 'Déplacement et suppression…',
|
||||
"Couldn't delete the role. Try again in a moment.":
|
||||
'Impossible de supprimer le rôle. Réessayez dans un instant.',
|
||||
'Move members to': 'Déplacer les membres vers',
|
||||
'They keep their API keys. Keys follow the new role.':
|
||||
'Ils gardent leurs clés d’API. Les clés suivent le nouveau rôle.',
|
||||
'{0} is built in': '{0} est intégré',
|
||||
'Duplicate it to make a custom role you can change or delete.':
|
||||
'Dupliquez-le pour créer un rôle personnalisé que vous pouvez modifier ou supprimer.',
|
||||
"Deleting a role doesn't affect content.": 'Supprimer un rôle n’affecte pas le contenu.',
|
||||
Read: 'Lire',
|
||||
Create: 'Créer',
|
||||
Own: 'Les siennes',
|
||||
'{0}, {1}, set to {2}.': '{0}, {1}, réglé sur {2}.',
|
||||
'Content permissions': 'Permissions de contenu',
|
||||
'Content type': 'Type de contenu',
|
||||
'Sets every row': 'Règle toutes les lignes',
|
||||
'Schema locked': 'Schéma verrouillé',
|
||||
'{0}, set to {1}.': '{0}, réglé sur {1}.',
|
||||
'{0} of {1} enabled': '{0} sur {1} activées',
|
||||
'Only the owner can grant this.': 'Seul le propriétaire peut accorder cette permission.',
|
||||
'Read only': 'Lecture seule',
|
||||
'Write own': 'Écrire les siens',
|
||||
'Full content': 'Contenu complet',
|
||||
'Quick presets': 'Préréglages rapides',
|
||||
Added: 'Ajouté',
|
||||
Removed: 'Retiré',
|
||||
'The Owner role is locked': 'Le rôle Propriétaire est verrouillé',
|
||||
'It always has every permission. To hand it to someone else, transfer ownership from Users.':
|
||||
'Il a toujours toutes les permissions. Pour le confier à quelqu’un d’autre, transférez la propriété depuis Utilisateurs.',
|
||||
'Go to Users': 'Aller aux utilisateurs',
|
||||
'Only the owner can edit built-in roles':
|
||||
'Seul le propriétaire peut modifier les rôles intégrés',
|
||||
'You can read what {0} allows. To change it, duplicate it into a custom role.':
|
||||
'Vous pouvez lire ce que {0} autorise. Pour le modifier, dupliquez-le en rôle personnalisé.',
|
||||
'You can see roles but not change them': 'Vous pouvez voir les rôles sans les modifier',
|
||||
'Ask an owner or admin if a role needs to change.':
|
||||
'Demandez à un propriétaire ou à un administrateur si un rôle doit changer.',
|
||||
"You can't change the permissions of your own role. Ask another owner or admin.":
|
||||
'Vous ne pouvez pas modifier les permissions de votre propre rôle. Demandez à un autre propriétaire ou administrateur.',
|
||||
'Your access changed': 'Votre accès a changé',
|
||||
"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.":
|
||||
'Un propriétaire a modifié votre rôle pendant que cette page était ouverte. Vous pouvez toujours voir les rôles, mais pas les modifier. Vos modifications n’ont pas été enregistrées.',
|
||||
'This role has 1 member': 'Ce rôle a 1 membre',
|
||||
'This role has {0} members': 'Ce rôle a {0} membres',
|
||||
'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.':
|
||||
'Les changements s’appliquent à tous à l’enregistrement, ainsi qu’aux clés d’API qu’ils ont créées. Les rôles intégrés peuvent être restaurés avec Réinitialiser.',
|
||||
'Reload role': 'Recharger le rôle',
|
||||
'Review differences': 'Voir les différences',
|
||||
"Your role can't assign a role with more power than yours.":
|
||||
'Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre.',
|
||||
'Choose a role': 'Choisir un rôle',
|
||||
'What this role can do': 'Ce que ce rôle peut faire',
|
||||
'A summary of what this role allows.': 'Un résumé de ce que ce rôle autorise.',
|
||||
'Updates as you edit.': 'Se met à jour pendant vos modifications.',
|
||||
'Everything else': 'Tout le reste',
|
||||
'1 unsaved change. Save is off until you reload':
|
||||
'1 modification non enregistrée. L’enregistrement est désactivé jusqu’au rechargement',
|
||||
'{0} unsaved changes. Save is off until you reload':
|
||||
'{0} modifications non enregistrées. L’enregistrement est désactivé jusqu’au rechargement',
|
||||
'Name your role to save it': 'Nommez votre rôle pour l’enregistrer',
|
||||
'1 unsaved change': '1 modification non enregistrée',
|
||||
'{0} unsaved changes': '{0} modifications non enregistrées',
|
||||
Members: 'Membres',
|
||||
Locked: 'Verrouillé',
|
||||
'{0} members': '{0} membres',
|
||||
'This role changed while you were editing': 'Ce rôle a changé pendant que vous le modifiiez',
|
||||
'{0} saved changes to {1} at {2}. Reload to see them, or review how they differ from your edits.':
|
||||
'{0} a enregistré des modifications sur {1} à {2}. Rechargez pour les voir, ou comparez-les à vos modifications.',
|
||||
Someone: 'Quelqu’un',
|
||||
"Couldn't save the role": 'Impossible d’enregistrer le rôle',
|
||||
'{0} is updated for 1 member.': '{0} est mis à jour pour 1 membre.',
|
||||
'{0} is updated for {1} members.': '{0} est mis à jour pour {1} membres.',
|
||||
'Give the role a name.': 'Donnez un nom au rôle.',
|
||||
'A role named "{0}" already exists. Choose a different name.':
|
||||
'Un rôle nommé « {0} » existe déjà. Choisissez un autre nom.',
|
||||
'Role created': 'Rôle créé',
|
||||
'{0} is ready. Assign it from Users.': '{0} est prêt. Attribuez-le depuis Utilisateurs.',
|
||||
'Role saved': 'Rôle enregistré',
|
||||
'Reset {0} to default?': 'Réinitialiser {0} ?',
|
||||
'Its permissions go back to what RenewCMS ships with.':
|
||||
'Ses permissions reviennent à celles fournies par RenewCMS.',
|
||||
'Reset to default': 'Réinitialiser',
|
||||
'Resetting…': 'Réinitialisation…',
|
||||
'Role reset': 'Rôle réinitialisé',
|
||||
'{0} is back to its defaults.': '{0} est revenu à ses valeurs par défaut.',
|
||||
"Couldn't reset the role. Try again in a moment.":
|
||||
'Impossible de réinitialiser le rôle. Réessayez dans un instant.',
|
||||
read: 'lire',
|
||||
update: 'modifier',
|
||||
delete: 'supprimer',
|
||||
publish: 'publier',
|
||||
"Your role can {0} only entries you created, so All isn't available.":
|
||||
'Votre rôle peut {0} uniquement les entrées que vous avez créées, donc Toutes n’est pas disponible.',
|
||||
'The Owner role can’t be deleted.': 'Le rôle Propriétaire ne peut pas être supprimé.',
|
||||
"Built-in roles can't be deleted. Duplicate it to make a custom role.":
|
||||
'Les rôles intégrés ne peuvent pas être supprimés. Dupliquez-le pour créer un rôle personnalisé.',
|
||||
'You can see roles, but only people who manage roles can create or change them.':
|
||||
'Vous pouvez voir les rôles, mais seules les personnes qui gèrent les rôles peuvent les créer ou les modifier.',
|
||||
'only their own': 'uniquement les siens',
|
||||
'see content types and fields': 'voir les types de contenu et leurs champs',
|
||||
'create and edit content types': 'créer et modifier des types de contenu',
|
||||
'delete content types': 'supprimer des types de contenu',
|
||||
'browse the media library': 'parcourir la médiathèque',
|
||||
'upload files': 'téléverser des fichiers',
|
||||
'delete files': 'supprimer des fichiers',
|
||||
'see authors': 'voir les auteurs',
|
||||
'create authors': 'créer des auteurs',
|
||||
'edit authors': 'modifier des auteurs',
|
||||
'delete authors': 'supprimer des auteurs',
|
||||
'see activity': 'voir l’activité',
|
||||
'export activity': 'exporter l’activité',
|
||||
'see users': 'voir les utilisateurs',
|
||||
'invite users': 'inviter des utilisateurs',
|
||||
"change a user's role": 'changer le rôle d’un utilisateur',
|
||||
'remove users': 'retirer des utilisateurs',
|
||||
'see settings': 'voir les paramètres',
|
||||
'edit general settings': 'modifier les paramètres généraux',
|
||||
'manage sites': 'gérer les sites',
|
||||
'delete a site': 'supprimer un site',
|
||||
'see API keys': 'voir les clés d’API',
|
||||
'create API keys': 'créer des clés d’API',
|
||||
'revoke API keys': 'révoquer des clés d’API',
|
||||
'see roles': 'voir les rôles',
|
||||
'manage roles': 'gérer les rôles',
|
||||
'edit roles with owner-only power': 'modifier les rôles à pouvoir réservé au propriétaire',
|
||||
'apply software updates': 'appliquer les mises à jour du logiciel',
|
||||
'edit activity retention': 'modifier la conservation de l’activité',
|
||||
'transfer ownership': 'transférer la propriété',
|
||||
'read any entry': 'lire n’importe quelle entrée',
|
||||
'read only their own entries': 'lire uniquement leurs propres entrées',
|
||||
'create entries': 'créer des entrées',
|
||||
'edit any entry': 'modifier n’importe quelle entrée',
|
||||
'edit only their own': 'modifier uniquement les leurs',
|
||||
'delete any entry': 'supprimer n’importe quelle entrée',
|
||||
'delete only their own entries': 'supprimer uniquement leurs propres entrées',
|
||||
'publish any entry': 'publier n’importe quelle entrée',
|
||||
'publish only their own entries': 'publier uniquement leurs propres entrées',
|
||||
create: 'créer',
|
||||
edit: 'modifier',
|
||||
'No access to entries.': 'Aucun accès aux entrées.',
|
||||
'Can {0}.': 'Peut {0}.',
|
||||
"Can't {0}.": 'Ne peut pas {0}.',
|
||||
'Applies to 1 member, and to the API keys they created.':
|
||||
'S’applique à 1 membre, et aux clés d’API qu’il a créées.',
|
||||
'Applies to {0} members, and to the API keys they created.':
|
||||
'S’applique à {0} membres, et aux clés d’API qu’ils ont créées.',
|
||||
'No access to {0}.': 'Aucun accès à {0}.',
|
||||
'New role': 'Nouveau rôle',
|
||||
"Couldn't load this role": 'Impossible de charger ce rôle',
|
||||
'This role no longer exists': 'Ce rôle n’existe plus',
|
||||
'It may have been deleted.': 'Il a peut-être été supprimé.',
|
||||
'Back to Roles': 'Retour aux rôles',
|
||||
'1 member': '1 membre',
|
||||
'For example, Contributor': 'Par exemple, Contributeur',
|
||||
'Description (optional)': 'Description (facultatif)',
|
||||
'What this role is for': 'À quoi sert ce rôle',
|
||||
'What this role can do with entries of each content type. Own means entries the person created.':
|
||||
'Ce que ce rôle peut faire avec les entrées de chaque type de contenu. « Les siennes » désigne les entrées créées par la personne.',
|
||||
'General permissions': 'Permissions générales',
|
||||
'Everything outside entries.': 'Tout ce qui n’est pas une entrée.',
|
||||
'Collapse all': 'Tout réduire',
|
||||
'Expand all': 'Tout développer',
|
||||
'Role deleted': 'Rôle supprimé',
|
||||
'{0} is gone.': '{0} a été supprimé.',
|
||||
'Who can do what on this site. Each user has one role.':
|
||||
'Qui peut faire quoi sur ce site. Chaque utilisateur a un seul rôle.',
|
||||
"Couldn't load roles": 'Impossible de charger les rôles',
|
||||
'GET /v1/role failed. Check your connection and try again.':
|
||||
'GET /v1/role a échoué. Vérifiez votre connexion et réessayez.',
|
||||
'No custom roles yet': 'Aucun rôle personnalisé pour l’instant',
|
||||
'The three built-in roles cover most sites. Create a role when someone needs something in between.':
|
||||
'Les trois rôles intégrés couvrent la plupart des sites. Créez un rôle quand quelqu’un a besoin de quelque chose d’intermédiaire.',
|
||||
'See entries of this type.': 'Voir les entrées de ce type.',
|
||||
'Add new entries.': 'Ajouter des entrées.',
|
||||
'Change existing entries.': 'Modifier les entrées existantes.',
|
||||
'Remove entries.': 'Supprimer des entrées.',
|
||||
'Make entries public.': 'Rendre des entrées publiques.',
|
||||
'See content types and their fields.': 'Voir les types de contenu et leurs champs.',
|
||||
'Add content types and change their fields.':
|
||||
'Ajouter des types de contenu et modifier leurs champs.',
|
||||
'Remove content types.': 'Supprimer des types de contenu.',
|
||||
'Browse the images and files.': 'Parcourir les images et les fichiers.',
|
||||
'Add images and files.': 'Ajouter des images et des fichiers.',
|
||||
'Remove images and files.': 'Supprimer des images et des fichiers.',
|
||||
'See the list of authors.': 'Voir la liste des auteurs.',
|
||||
'Add authors.': 'Ajouter des auteurs.',
|
||||
'Change author profiles.': 'Modifier les profils des auteurs.',
|
||||
'Remove authors.': 'Supprimer des auteurs.',
|
||||
'Read the log of who did what.': 'Lire le journal de qui a fait quoi.',
|
||||
'Download the log as a CSV file.': 'Télécharger le journal en fichier CSV.',
|
||||
'See who has an account.': 'Voir qui a un compte.',
|
||||
'Send invitations by email.': 'Envoyer des invitations par e-mail.',
|
||||
'Give someone another role.': 'Donner un autre rôle à quelqu’un.',
|
||||
'Take away someone’s access.': 'Retirer l’accès de quelqu’un.',
|
||||
'Read the site settings.': 'Lire les paramètres du site.',
|
||||
'Change the site settings.': 'Modifier les paramètres du site.',
|
||||
'Add, edit and remove sites.': 'Ajouter, modifier et supprimer des sites.',
|
||||
'Remove a site and its content.': 'Supprimer un site et son contenu.',
|
||||
'See the API keys.': 'Voir les clés d’API.',
|
||||
'Make new API keys.': 'Créer de nouvelles clés d’API.',
|
||||
'Stop an API key from working.': 'Empêcher une clé d’API de fonctionner.',
|
||||
'See roles and what they allow.': 'Voir les rôles et ce qu’ils autorisent.',
|
||||
'Create, edit, duplicate and delete roles.':
|
||||
'Créer, modifier, dupliquer et supprimer des rôles.',
|
||||
'Change a role that carries owner-only abilities.':
|
||||
'Modifier un rôle qui porte des pouvoirs réservés au propriétaire.',
|
||||
'Install a new version of RenewCMS.': 'Installer une nouvelle version de RenewCMS.',
|
||||
'How long the activity log is kept.': 'Durée de conservation du journal d’activité.',
|
||||
'Make another member the owner.': 'Faire d’un autre membre le propriétaire.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { messages } from './messages'
|
||||
import { setLocale, t } from './index'
|
||||
|
||||
const ROLE_KEYS = [
|
||||
'Access',
|
||||
'Roles',
|
||||
'New role',
|
||||
'Who can do what on this site. Each user has one role.',
|
||||
'No custom roles yet',
|
||||
"Couldn't load roles",
|
||||
'You can see roles but not change them',
|
||||
'Content permissions',
|
||||
'Quick presets',
|
||||
'Read only',
|
||||
'Write own',
|
||||
'Full content',
|
||||
'General permissions',
|
||||
'Expand all',
|
||||
'{0} unsaved changes',
|
||||
'Give the role a name.',
|
||||
'A role named "{0}" already exists. Choose a different name.',
|
||||
'This role changed while you were editing',
|
||||
'Your access changed',
|
||||
'The Owner role is locked',
|
||||
'Only the owner can edit built-in roles',
|
||||
'What this role can do',
|
||||
'Role created',
|
||||
'{0} is ready. Assign it from Users.',
|
||||
'Role saved',
|
||||
'{0} members have this role. Move them to another role first. Then {1} can be deleted.',
|
||||
'Move {0} members and delete role',
|
||||
"No one has this role. This can't be undone.",
|
||||
'Can {0}.',
|
||||
"Can't {0}.",
|
||||
] as const
|
||||
|
||||
const PLACEHOLDER = /\{\d+\}/g
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('Roles screens copy', () => {
|
||||
it.each(ROLE_KEYS)('"%s" has a French entry', (key) => {
|
||||
expect((messages as Record<string, string>)[key]).toMatch(/\S/)
|
||||
})
|
||||
|
||||
it('keeps the same placeholders in French as in English for every entry', () => {
|
||||
const mismatches = Object.entries(messages).filter(([english, french]) => {
|
||||
const expected = (english.match(PLACEHOLDER) ?? []).sort().join()
|
||||
const actual = (french.match(PLACEHOLDER) ?? []).sort().join()
|
||||
return expected !== actual
|
||||
})
|
||||
|
||||
expect(mismatches.map(([english]) => english)).toEqual([])
|
||||
})
|
||||
|
||||
it('renders French with its values', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(t('{0} unsaved changes', [3])).toBe('3 modifications non enregistrées')
|
||||
expect(t('Roles')).toBe('Rôles')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it } 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 RouteAccessGate from '@/components/RouteAccessGate.vue'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { customAccess } from '@/test/access'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function open(grants: string, path: string, role = 'Contributor') {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { id: 4, verified: true, displayName: '', access: customAccess(grants, role) }
|
||||
session.status = 'ready'
|
||||
await router.push(path).catch(() => undefined)
|
||||
wrapper = mount(
|
||||
() =>
|
||||
h(UApp, null, () =>
|
||||
h(RouteAccessGate, null, () => h('div', { 'data-testid': 'page' }, 'PAGE')),
|
||||
),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
||||
)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Route access', () => {
|
||||
it.each([
|
||||
['roles.view', '/settings/roles', true],
|
||||
['roles.view', '/settings/roles/new', false],
|
||||
['roles.view, roles.manage', '/settings/roles/new', true],
|
||||
['roles.view', '/settings/roles/4', true],
|
||||
['content.read=all', '/settings/roles', false],
|
||||
['content.read=all', '/settings/roles/4', false],
|
||||
])('%s opening %s renders the page: %s', async (grants, path, allowed) => {
|
||||
const view = await open(grants, path)
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
expect(view.find('[data-testid="page"]').exists()).toBe(allowed)
|
||||
if (!allowed) {
|
||||
expect(view.text()).toContain("You don't have access to this page")
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the page-level 403 with Go to Articles to a Contributor', async () => {
|
||||
const view = await open('content.read=all', '/settings/roles')
|
||||
|
||||
expect(view.text()).toContain(
|
||||
"Your role, Contributor, doesn't include it. Ask an owner or admin if you need it.",
|
||||
)
|
||||
expect(view.text()).toContain('Go to Articles')
|
||||
})
|
||||
})
|
||||
|
||||
describe('/settings/permissions is retired', () => {
|
||||
it('replaces the URL with /settings/roles and flashes no 403', async () => {
|
||||
const view = await open('roles.view', '/settings/permissions')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/roles')
|
||||
expect(view.find('[data-testid="page"]').exists()).toBe(true)
|
||||
expect(view.text()).not.toContain("You don't have access to this page")
|
||||
})
|
||||
|
||||
it('redirects even for someone who cannot see roles, to the same 403 as /settings/roles', async () => {
|
||||
await open('content.read=all', '/settings/permissions')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/roles')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the session is still being checked', () => {
|
||||
it('keeps the boot screen instead of the 403 state', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.status = 'checking'
|
||||
await router.push('/settings/roles').catch(() => undefined)
|
||||
wrapper = mount(
|
||||
() =>
|
||||
h(UApp, null, () =>
|
||||
h(RouteAccessGate, null, () => h('div', { 'data-testid': 'page' })),
|
||||
),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain("You don't have access to this page")
|
||||
})
|
||||
})
|
||||
+20
-5
@@ -9,14 +9,10 @@ import {
|
||||
} from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { installDocumentTitle } from '@/composables/documentTitle'
|
||||
import { ARTICLE_TYPE_KEY, type RouteAccess } from '@/lib/routeAccess'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
|
||||
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
|
||||
|
||||
export function routeAccessOf(route: RouteLocationNormalized): RouteAccess | undefined {
|
||||
return route.meta.access as RouteAccess | undefined
|
||||
}
|
||||
|
||||
const gatedViews = new Map<string, Promise<Component>>()
|
||||
|
||||
function gated(key: string, load: () => Promise<{ default: Component }>) {
|
||||
@@ -226,6 +222,25 @@ const routes: RouteRecordRaw[] = [
|
||||
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/permissions',
|
||||
redirect: '/settings/roles',
|
||||
},
|
||||
{
|
||||
path: '/settings/roles',
|
||||
component: gated('@/views/RolesListView.vue', () => import('@/views/RolesListView.vue')),
|
||||
meta: { auth: 'verified', title: 'Roles', access: { key: 'roles.view' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/roles/new',
|
||||
component: gated('@/views/RoleEditorView.vue', () => import('@/views/RoleEditorView.vue')),
|
||||
meta: { auth: 'verified', title: 'Roles', access: { key: 'roles.manage' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/roles/:id',
|
||||
component: gated('@/views/RoleEditorView.vue', () => import('@/views/RoleEditorView.vue')),
|
||||
meta: { auth: 'verified', title: 'Roles', access: { key: 'roles.view' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/media/cache',
|
||||
component: gated('@/views/CachePage.vue', () => import('@/views/CachePage.vue')),
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user