refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types
This commit is contained in:
1 parent
5d6f378fce
commit
b39a41be88
124 files changed
+8816
-523
No files matched your search
@@ -1,38 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import RolePicker from '@/components/RolePicker.vue'
|
||||
import { useRoles } from '@/composables/useRoles'
|
||||
import { useUsers, type AcceptedUser } from '@/composables/useUsers'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { isCode, inlineRefusal, isMissingPermission, userToast } from '@/lib/userRefusal'
|
||||
import {
|
||||
isCode,
|
||||
inlineRefusal,
|
||||
isMissingPermission,
|
||||
staleToast,
|
||||
userToast,
|
||||
} from '@/lib/userRefusal'
|
||||
import { elideDe } from '@/lib/frenchElision'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const SUCCESS_TOAST_MS = 6000
|
||||
|
||||
const props = defineProps<{ user: AcceptedUser }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [user: AcceptedUser]
|
||||
refreshed: [user: AcceptedUser]
|
||||
stale: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { listRoles } = useRoles()
|
||||
const { changeUserRole } = useUsers()
|
||||
const { changeUserRole, listUsers } = useUsers()
|
||||
const permissions = usePermissionsStore()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const roles = ref<RoleItem[]>([])
|
||||
const loading = ref(true)
|
||||
const currentRole = ref(props.user.role)
|
||||
const selected = ref<number | undefined>(props.user.role?.id)
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
|
||||
const name = computed(() => props.user.display_name || props.user.username)
|
||||
const currentId = computed(() => props.user.role?.id)
|
||||
const currentId = computed(() => currentRole.value?.id)
|
||||
const confirmDisabled = computed(
|
||||
() => loading.value || selected.value === undefined || selected.value === currentId.value,
|
||||
)
|
||||
@@ -49,10 +60,74 @@ async function loadRoles() {
|
||||
}
|
||||
}
|
||||
|
||||
let disposed = false
|
||||
|
||||
function announceStale(title: string, description: string) {
|
||||
toast.add(userToast(`user-role-stale-${props.user.id}`, 'error', title, description))
|
||||
}
|
||||
|
||||
function announceStaleAfterClose() {
|
||||
const id = `user-role-stale-${props.user.id}`
|
||||
toast.add(
|
||||
staleToast(
|
||||
id,
|
||||
() => t('Role not changed'),
|
||||
() =>
|
||||
elideDe(
|
||||
t(
|
||||
"{0}'s role changed in the meantime, so nothing was saved. Reload and try again.",
|
||||
[name.value],
|
||||
),
|
||||
name.value,
|
||||
),
|
||||
() => {
|
||||
void listUsers()
|
||||
toast.remove(id)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function readMember(): Promise<AcceptedUser | undefined> {
|
||||
const users = await listUsers()
|
||||
return users.find(
|
||||
(item): item is AcceptedUser => item.kind === 'user' && item.id === props.user.id,
|
||||
)
|
||||
}
|
||||
|
||||
async function handleRoleChangedFirst() {
|
||||
if (disposed || !open.value) {
|
||||
announceStaleAfterClose()
|
||||
return
|
||||
}
|
||||
let member: AcceptedUser | undefined
|
||||
try {
|
||||
member = await readMember()
|
||||
} catch {
|
||||
notice.value = {
|
||||
title: t("Couldn't change the role"),
|
||||
description: t('Nothing was changed. Try again in a moment.'),
|
||||
}
|
||||
return
|
||||
}
|
||||
if (member === undefined) {
|
||||
emit('stale')
|
||||
announceStale(t('That user no longer exists'), t('The list was refreshed.'))
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
currentRole.value = member.role
|
||||
emit('refreshed', member)
|
||||
notice.value = {
|
||||
title: t('Role changed in the meantime'),
|
||||
tone: 'warning',
|
||||
description: t('{0} is now {1}. Your choice is kept. Check it and try again.', [
|
||||
name.value,
|
||||
member.role?.name ?? '',
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFailure(error: unknown) {
|
||||
const refusal = inlineRefusal(error)
|
||||
if (refusal !== undefined) {
|
||||
@@ -64,9 +139,7 @@ async function handleFailure(error: unknown) {
|
||||
return
|
||||
}
|
||||
if (isCode(error, 409, 'user_role_changed')) {
|
||||
emit('stale')
|
||||
announceStale(t('Their role changed meanwhile'), t('The list was refreshed.'))
|
||||
open.value = false
|
||||
await handleRoleChangedFirst()
|
||||
return
|
||||
}
|
||||
if (isCode(error, 404, 'user_not_found')) {
|
||||
@@ -81,7 +154,10 @@ async function handleFailure(error: unknown) {
|
||||
notice.value = { description: t("That role isn't available anymore. Choose another one.") }
|
||||
return
|
||||
}
|
||||
notice.value = { description: t("Couldn't change the role. Try again in a moment.") }
|
||||
notice.value = {
|
||||
title: t("Couldn't change the role"),
|
||||
description: t('Nothing was changed. Try again in a moment.'),
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
@@ -99,7 +175,12 @@ async function onConfirm() {
|
||||
`user-role-changed-${props.user.id}`,
|
||||
'success',
|
||||
() => t('Role changed'),
|
||||
() => t('{0} now has the {1} role.', [name.value, updated.role?.name ?? '']),
|
||||
() =>
|
||||
t('{0} is now {1}, and their API keys follow the new role.', [
|
||||
name.value,
|
||||
updated.role?.name ?? '',
|
||||
]),
|
||||
SUCCESS_TOAST_MS,
|
||||
),
|
||||
)
|
||||
open.value = false
|
||||
@@ -111,10 +192,14 @@ async function onConfirm() {
|
||||
}
|
||||
|
||||
onMounted(loadRoles)
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
closable
|
||||
v-model:open="open"
|
||||
:title="t('Change role for {0}', [name])"
|
||||
:description="t('Their API keys follow the new role.')"
|
||||
@@ -123,6 +208,7 @@ onMounted(loadRoles)
|
||||
tone="neutral"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:top="notice ? 15 : 25"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
import { t, formatNumber, locale } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
@@ -7,6 +7,7 @@ import ArticleListNotice from '@/components/ArticleListNotice.vue'
|
||||
import ContentEmpty from '@/components/ContentEmpty.vue'
|
||||
import ContentState from '@/components/ContentState.vue'
|
||||
import ContentToolbar from '@/components/ContentToolbar.vue'
|
||||
import OwnScopeEmpty from '@/components/OwnScopeEmpty.vue'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
@@ -18,6 +19,7 @@ import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import { useEntryRowDelete } from '@/composables/useEntryRowDelete'
|
||||
import type { ContentListPage } from '@/composables/useContentListPage'
|
||||
import { entriesLoadErrorLine, typeLoadErrorLine } from '@/lib/entryMessages'
|
||||
import { entryNounOf } from '@/lib/relationSentences'
|
||||
|
||||
const props = defineProps<{ page: ContentListPage }>()
|
||||
|
||||
@@ -56,6 +58,17 @@ const createNote = computed(() =>
|
||||
: '',
|
||||
)
|
||||
|
||||
const headerLine = computed(() =>
|
||||
access.readsOwnOnly.value
|
||||
? t('Your role shows only entries you created.')
|
||||
: createNote.value || subtitle.value,
|
||||
)
|
||||
const ownEmptyNouns = computed(() => {
|
||||
if (locale.value === 'fr') {
|
||||
return { singular: name.value, plural: name.value }
|
||||
}
|
||||
return entryNounOf(name.value)
|
||||
})
|
||||
const singlePageLabel = computed(() =>
|
||||
list.total.value === 1 ? t('1 entry') : t('{0} entries', [formatNumber(list.total.value)]),
|
||||
)
|
||||
@@ -82,7 +95,7 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
|
||||
{{ name }}
|
||||
</h1>
|
||||
<p v-if="type" class="text-muted">{{ createNote || subtitle }}</p>
|
||||
<p v-if="type" class="text-muted">{{ headerLine }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<UButton
|
||||
@@ -97,7 +110,18 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
</div>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-if="access.updatesOwnOnly.value && !failed"
|
||||
v-if="access.readsOwnOnly.value && !failed"
|
||||
icon="i-lucide-eye-off"
|
||||
:title="t('Entries by others aren\'t shown')"
|
||||
:description="
|
||||
t(
|
||||
'Your role reads only entries you created. Ask an owner or admin if you need to see more.',
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-else-if="access.updatesOwnOnly.value && !failed"
|
||||
:title="t('Entries by others are view only')"
|
||||
:description="t('Your role lets you edit entries you created.')"
|
||||
/>
|
||||
@@ -137,6 +161,20 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
|
||||
<EntrySkeleton v-if="showSkeleton" :rows="skeletonRows" :cards="!isDesktop" />
|
||||
|
||||
<OwnScopeEmpty
|
||||
v-else-if="view === 'empty' && access.readsOwnOnly.value"
|
||||
icon="i-lucide-files"
|
||||
:title="t('You haven\'t created {0} yet', [ownEmptyNouns.singular])"
|
||||
:description="
|
||||
t(
|
||||
'Your role shows only entries you created, so other people\'s {0} aren\'t listed here. Use New entry to add one.',
|
||||
[ownEmptyNouns.plural],
|
||||
)
|
||||
"
|
||||
:action="access.canCreate.value ? t('New entry') : undefined"
|
||||
@action="createEntry"
|
||||
/>
|
||||
|
||||
<ContentEmpty v-else-if="view === 'empty'" @create="createEntry" />
|
||||
|
||||
<template v-else-if="showBody">
|
||||
|
||||
@@ -19,16 +19,24 @@ const emit = defineEmits<{
|
||||
deleted: [role: RoleItem]
|
||||
gone: [role: RoleItem]
|
||||
duplicate: [role: RoleItem]
|
||||
conflict: [role: RoleItem]
|
||||
refreshed: [roles: RoleItem[]]
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { reassignRole, deleteRole } = useRoles()
|
||||
const { reassignRole, deleteRole, listRoles } = useRoles()
|
||||
|
||||
const target = ref<number | undefined>(undefined)
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const members = ref(props.role.members)
|
||||
const missingTargets = ref<number[]>([])
|
||||
const gainedMembers = ref(false)
|
||||
|
||||
const targets = computed(() =>
|
||||
props.roles.filter((item) => !missingTargets.value.includes(item.id)),
|
||||
)
|
||||
|
||||
const stage = computed<'builtin' | 'blocked' | 'confirm'>(() => {
|
||||
if (props.role.built_in) {
|
||||
@@ -37,9 +45,14 @@ const stage = computed<'builtin' | 'blocked' | 'confirm'>(() => {
|
||||
return members.value > 0 ? 'blocked' : 'confirm'
|
||||
})
|
||||
|
||||
const deleteTop = computed(() => (notice.value ? 25 : 35))
|
||||
|
||||
const title = computed(() => t('Delete {0}?', [props.role.name]))
|
||||
|
||||
const description = computed(() => {
|
||||
if (gainedMembers.value) {
|
||||
return ''
|
||||
}
|
||||
switch (stage.value) {
|
||||
case 'builtin':
|
||||
return t("Built-in roles can't be deleted.")
|
||||
@@ -58,7 +71,9 @@ const confirmLabel = computed(() => {
|
||||
return t('Duplicate role')
|
||||
}
|
||||
return stage.value === 'blocked'
|
||||
? t('Move {0} members and delete role', [members.value])
|
||||
? members.value === 1
|
||||
? t('Move 1 member and delete role')
|
||||
: t('Move {0} members and delete role', [members.value])
|
||||
: t('Delete role')
|
||||
})
|
||||
|
||||
@@ -66,14 +81,65 @@ 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.") }
|
||||
const GENERIC_FAILURE = "Couldn't delete the role. Try again in a moment."
|
||||
|
||||
async function liveMemberCount() {
|
||||
try {
|
||||
const roles = await listRoles()
|
||||
const live = roles.find((item) => item.id === props.role.id)
|
||||
return Math.max(live?.members ?? 0, 1)
|
||||
} catch {
|
||||
return Math.max(members.value, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function showGainedMembers() {
|
||||
const count = await liveMemberCount()
|
||||
members.value = count
|
||||
gainedMembers.value = true
|
||||
target.value = undefined
|
||||
notice.value = {
|
||||
tone: 'warning',
|
||||
title: t('Role still has members'),
|
||||
description:
|
||||
count === 1
|
||||
? t('{0} now has 1 member. Move them to another role first.', [props.role.name])
|
||||
: t('{0} now has {1} members. Move them to another role first.', [
|
||||
props.role.name,
|
||||
count,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
async function showTargetGone() {
|
||||
const missing = props.roles.find((item) => item.id === target.value)
|
||||
if (missing) {
|
||||
missingTargets.value = [...missingTargets.value, missing.id]
|
||||
}
|
||||
notice.value = {
|
||||
title: t('{0} no longer exists', [missing?.name ?? '']),
|
||||
description: t(
|
||||
'It was deleted somewhere else. Choose another role to move the members to.',
|
||||
),
|
||||
}
|
||||
target.value = undefined
|
||||
try {
|
||||
emit('refreshed', await listRoles())
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_has_members') {
|
||||
members.value = Math.max(members.value, 1)
|
||||
target.value = undefined
|
||||
}
|
||||
|
||||
function showMoveFailure() {
|
||||
notice.value = {
|
||||
title: t("Couldn't move the members"),
|
||||
description: t('Nothing was changed. Try again in a moment.'),
|
||||
}
|
||||
}
|
||||
|
||||
function showRefusal(error: unknown) {
|
||||
if (!(error instanceof ApiError)) {
|
||||
notice.value = { description: t(GENERIC_FAILURE) }
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_not_found') {
|
||||
@@ -81,13 +147,43 @@ function showRefusal(error: unknown) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_conflict') {
|
||||
emit('conflict', 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.") }
|
||||
? { description: t(GENERIC_FAILURE) }
|
||||
: { title: copy.title, description: copy.description ?? '' }
|
||||
}
|
||||
|
||||
async function showMoveRefusal(error: unknown) {
|
||||
if (!(error instanceof ApiError)) {
|
||||
showMoveFailure()
|
||||
return
|
||||
}
|
||||
if (error.code === 'target_role_not_found') {
|
||||
await showTargetGone()
|
||||
return
|
||||
}
|
||||
if (error.code === 'invalid_reassign') {
|
||||
target.value = undefined
|
||||
showRefusal(error)
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_conflict' || error.code === 'role_not_found') {
|
||||
showRefusal(error)
|
||||
return
|
||||
}
|
||||
if (error.code === undefined || refusalCopy(error.code, error.permission) === undefined) {
|
||||
showMoveFailure()
|
||||
return
|
||||
}
|
||||
showRefusal(error)
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (stage.value === 'builtin') {
|
||||
emit('duplicate', props.role)
|
||||
@@ -101,13 +197,22 @@ async function onConfirm() {
|
||||
notice.value = null
|
||||
try {
|
||||
if (stage.value === 'blocked' && target.value !== undefined) {
|
||||
await reassignRole(props.role.id, target.value)
|
||||
try {
|
||||
await reassignRole(props.role.id, target.value)
|
||||
} catch (error) {
|
||||
await showMoveRefusal(error)
|
||||
return
|
||||
}
|
||||
members.value = 0
|
||||
}
|
||||
await deleteRole(props.role.id)
|
||||
emit('deleted', props.role)
|
||||
open.value = false
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'role_has_members') {
|
||||
await showGainedMembers()
|
||||
return
|
||||
}
|
||||
showRefusal(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
@@ -117,6 +222,7 @@ async function onConfirm() {
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
closable
|
||||
v-model:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
@@ -126,17 +232,19 @@ async function onConfirm() {
|
||||
:tone="stage === 'builtin' ? 'neutral' : 'danger'"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:top="deleteTop"
|
||||
:confirm-disabled="stage === 'blocked' && target === undefined"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template v-if="stage === 'blocked'">
|
||||
<UFormField
|
||||
:label="t('Move members to')"
|
||||
required
|
||||
:help="t('They keep their API keys. Keys follow the new role.')"
|
||||
>
|
||||
<RolePicker
|
||||
v-model:selected="target"
|
||||
:roles="roles"
|
||||
:roles="targets"
|
||||
:source-id="role.id"
|
||||
:can-assign="canAssign"
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,8 @@ const props = withDefaults(
|
||||
confirmBlocked?: boolean
|
||||
wide?: boolean
|
||||
closeGutter?: boolean
|
||||
top?: 10 | 15 | 20 | 25 | 30 | 35
|
||||
reviewWidth?: boolean
|
||||
}>(),
|
||||
{
|
||||
cancelLabel: '',
|
||||
@@ -31,6 +33,8 @@ const props = withDefaults(
|
||||
confirmBlocked: false,
|
||||
wide: false,
|
||||
closeGutter: false,
|
||||
top: undefined,
|
||||
reviewWidth: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -39,10 +43,22 @@ const emit = defineEmits<{ confirm: []; close: []; cancel: [] }>()
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const titleElement = ref<HTMLElement | null>(null)
|
||||
const contentClass = computed(
|
||||
() =>
|
||||
`${SHEET_CONTENT_CLASS} ${props.wide ? 'sm:top-15 sm:max-w-140' : 'sm:top-35 sm:max-w-115'}`,
|
||||
)
|
||||
const TOP_CLASSES = {
|
||||
10: 'sm:top-10',
|
||||
15: 'sm:top-15',
|
||||
20: 'sm:top-20',
|
||||
25: 'sm:top-25',
|
||||
30: 'sm:top-30',
|
||||
35: 'sm:top-35',
|
||||
}
|
||||
const contentClass = computed(() => {
|
||||
const top = TOP_CLASSES[props.top ?? (props.wide ? 15 : 35)]
|
||||
let width = props.wide ? 'sm:max-w-140' : 'sm:max-w-115'
|
||||
if (props.reviewWidth) {
|
||||
width = 'sm:max-w-130'
|
||||
}
|
||||
return `${SHEET_CONTENT_CLASS} ${top} ${width}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.pending,
|
||||
@@ -66,7 +82,7 @@ const headerClass = computed(() => {
|
||||
})
|
||||
const closeButton = computed(() =>
|
||||
props.closable && !props.pending
|
||||
? { class: 'size-7 justify-center text-muted max-md:size-11' }
|
||||
? { class: 'top-5 end-6 size-7 justify-center text-muted max-md:top-8 max-md:size-11' }
|
||||
: false,
|
||||
)
|
||||
|
||||
@@ -121,9 +137,11 @@ function onCancel() {
|
||||
<UAlert
|
||||
v-if="notice"
|
||||
role="alert"
|
||||
color="error"
|
||||
:color="notice.tone ?? 'error'"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:icon="
|
||||
notice.tone === 'warning' ? 'i-lucide-triangle-alert' : 'i-lucide-circle-x'
|
||||
"
|
||||
:title="notice.title"
|
||||
:description="notice.description"
|
||||
:ui="{
|
||||
@@ -131,7 +149,21 @@ function onCancel() {
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
>
|
||||
<template v-if="notice.action" #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 px-2.5 text-xs sm:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
:disabled="pending"
|
||||
@click="notice.action.onClick"
|
||||
>
|
||||
{{ notice.action.label }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -87,7 +87,7 @@ const snippetTabs = computed(() => [
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:class="
|
||||
endpoint.access === 'Owner only' || endpoint.access === 'Owner or admin'
|
||||
endpoint.access === 'Owner only' || endpoint.access === 'Needs permission'
|
||||
? CHIP_CLASS.strong
|
||||
: CHIP_CLASS.neutral
|
||||
"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
@@ -13,7 +14,7 @@ import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const EMAIL_PLACEHOLDER = 'name@example.com'
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
|
||||
const INVALID_EMAIL_MESSAGE = 'Enter a full email address, like name@example.com.'
|
||||
const INVALID_EMAIL_MESSAGE = 'Enter a valid email address.'
|
||||
const DEFAULT_ROLE_NAME = 'Writer'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
@@ -30,6 +31,7 @@ const touched = ref(false)
|
||||
const focused = ref(false)
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
const roleError = ref('')
|
||||
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
|
||||
const refusal = ref<DirectoryNotice | null>(null)
|
||||
const roles = ref<RoleItem[]>([])
|
||||
@@ -47,18 +49,27 @@ const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
|
||||
const emailError = computed(
|
||||
() => serverError.value || (touched.value && !emailValid.value ? t(INVALID_EMAIL_MESSAGE) : ''),
|
||||
)
|
||||
const hasFormErrors = computed(
|
||||
() => !!refusal.value || !!formAlert.value || !!emailError.value || !!roleError.value,
|
||||
)
|
||||
const inviteTop = computed(() => (hasFormErrors.value ? 'sm:top-20' : 'sm:top-30'))
|
||||
|
||||
const alertMessage = computed(() => {
|
||||
const alertCopy = computed(() => {
|
||||
if (formAlert.value === 'invalid') {
|
||||
return t("Something in the form isn't valid. Check the highlighted fields.")
|
||||
return {
|
||||
description: t("Something in the form isn't valid. Check the highlighted fields."),
|
||||
}
|
||||
}
|
||||
if (formAlert.value === 'rateLimited') {
|
||||
return t('Too many invites. Wait a moment, then try again.')
|
||||
return { description: t('Too many invites. Wait a moment, then try again.') }
|
||||
}
|
||||
if (formAlert.value === 'failed') {
|
||||
return t("Couldn't send the invite. Try again in a moment.")
|
||||
return {
|
||||
title: t("Couldn't send the invite"),
|
||||
description: t('Try again in a moment.'),
|
||||
}
|
||||
}
|
||||
return ''
|
||||
return null
|
||||
})
|
||||
|
||||
function defaultRoleId() {
|
||||
@@ -88,6 +99,7 @@ function resetForm() {
|
||||
focused.value = false
|
||||
submitting.value = false
|
||||
serverError.value = ''
|
||||
roleError.value = ''
|
||||
formAlert.value = ''
|
||||
}
|
||||
|
||||
@@ -119,12 +131,20 @@ watch(email, () => {
|
||||
serverError.value = ''
|
||||
})
|
||||
|
||||
watch(roleId, () => {
|
||||
roleError.value = ''
|
||||
})
|
||||
|
||||
function onBlur() {
|
||||
touched.value = true
|
||||
focused.value = false
|
||||
}
|
||||
|
||||
function showFailure(error: unknown) {
|
||||
if (showRolePicker.value && isCode(error, 403, 'escalation')) {
|
||||
roleError.value = t("Your role can't assign a role with more power than yours.")
|
||||
return
|
||||
}
|
||||
const refused = inlineRefusal(error)
|
||||
if (refused !== undefined) {
|
||||
refusal.value = refused
|
||||
@@ -167,6 +187,7 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
serverError.value = ''
|
||||
roleError.value = ''
|
||||
formAlert.value = ''
|
||||
refusal.value = null
|
||||
submitting.value = true
|
||||
@@ -209,14 +230,13 @@ function onCancel() {
|
||||
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
|
||||
@update:open="onOpenChange"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
content: `${inviteTop} ${SHEET_CONTENT_CLASS} sm:max-w-115`,
|
||||
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
|
||||
wrapper: 'pe-10',
|
||||
close: 'top-5 end-6 size-7 justify-center p-0',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
@@ -233,14 +253,15 @@ function onCancel() {
|
||||
:description="refusal.description"
|
||||
/>
|
||||
<UAlert
|
||||
v-if="alertMessage"
|
||||
v-if="alertCopy"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
:description="alertMessage"
|
||||
:title="alertCopy.title"
|
||||
:description="alertCopy.description"
|
||||
/>
|
||||
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
|
||||
<UFormField
|
||||
@@ -273,19 +294,34 @@ function onCancel() {
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField v-if="showRolePicker" :label="t('Role')" name="role">
|
||||
<UFormField
|
||||
v-if="showRolePicker"
|
||||
:label="t('Role')"
|
||||
name="role"
|
||||
:error="roleError || undefined"
|
||||
:ui="{ error: 'mt-1.5 text-xs/5' }"
|
||||
>
|
||||
<template v-if="roleError" #error>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ roleError }}
|
||||
</span>
|
||||
</template>
|
||||
<RolePicker
|
||||
v-model:selected="roleId"
|
||||
:roles="roles"
|
||||
:can-assign="permissions.canAssign"
|
||||
/>
|
||||
<div class="mt-1.5 flex flex-col gap-0.5 text-xs/5 text-muted">
|
||||
<span v-if="selectedRole">{{ selectedRole.description }}</span>
|
||||
<span v-if="defaultRoleSelected">{{
|
||||
t('Writer is the default. You can change a role later.')
|
||||
}}</span>
|
||||
<div v-if="selectedRole" class="mt-1.5 text-xs/5 text-muted">
|
||||
{{ selectedRole.description }}
|
||||
</div>
|
||||
</UFormField>
|
||||
<span v-if="showRolePicker && defaultRoleSelected" class="text-muted">{{
|
||||
t('Writer is the default. You can change a role later.')
|
||||
}}</span>
|
||||
</UForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
action?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ action: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
:icon="icon"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-10 sm:px-6 sm:py-10 lg:px-6 lg: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 v-if="action" #actions>
|
||||
<UButton icon="i-lucide-plus" class="h-8 px-4 max-md:h-11" @click="emit('action')">
|
||||
{{ action }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -131,7 +131,7 @@ function typeOf(typeKey?: string) {
|
||||
<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"
|
||||
class="grid grid-cols-[minmax(7.5rem,1fr)_7.375rem_5.25rem_7.375rem_7.375rem_7.375rem] items-center gap-2 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">{{
|
||||
@@ -143,7 +143,7 @@ function typeOf(typeKey?: string) {
|
||||
: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"
|
||||
class="grid grid-cols-[minmax(7.5rem,1fr)_7.375rem_5.25rem_7.375rem_7.375rem_7.375rem] items-center gap-2 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>
|
||||
|
||||
@@ -3,8 +3,9 @@ withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
icon?: string
|
||||
}>(),
|
||||
{ description: undefined },
|
||||
{ description: undefined, icon: 'i-lucide-lock' },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -13,7 +14,7 @@ withDefaults(
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-lock"
|
||||
:icon="icon"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:close="false"
|
||||
|
||||
@@ -33,6 +33,7 @@ interface HighlightPayload {
|
||||
}
|
||||
|
||||
const SKELETON_ROWS = 3
|
||||
const MORE_SKELETON_WIDTHS = ['55%', '70%', '45%']
|
||||
const LISTBOX = '[role="listbox"]'
|
||||
const OWNER_ATTRIBUTE = 'data-relation-owner'
|
||||
const VOWEL = /^[aeiou]/
|
||||
@@ -76,6 +77,7 @@ const searchTerm = ref('')
|
||||
const announcement = ref('')
|
||||
const activeId = ref<string | undefined>(undefined)
|
||||
const listboxId = ref<string | undefined>(undefined)
|
||||
const loadMoreButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const ids = computed(() => relationIdsOf(props.value))
|
||||
const chips = computed<ChipItem[]>(() =>
|
||||
@@ -158,7 +160,8 @@ const menuUi = computed(() => ({
|
||||
tagsInput: 'min-w-20 flex-1 placeholder:text-muted',
|
||||
trailing: 'pe-2.5',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none border border-default p-1 shadow-(--shadow-popover) ring-0',
|
||||
content:
|
||||
'max-md:max-h-88 rounded-none border border-default p-1 shadow-(--shadow-popover) ring-0',
|
||||
empty: 'p-2 text-left text-sm text-muted',
|
||||
group: 'p-0',
|
||||
item: 'min-h-10 flex-col justify-center gap-0 rounded-none px-2 py-1 before:inset-0 before:rounded-none data-highlighted:not-data-disabled:before:bg-elevated max-md:min-h-12',
|
||||
@@ -266,6 +269,35 @@ async function trackListbox() {
|
||||
undefined
|
||||
}
|
||||
|
||||
function focusLoadMore() {
|
||||
loadMoreButton.value?.$el.focus()
|
||||
}
|
||||
|
||||
function focusRow(rowId: number) {
|
||||
const label = rows.value.find((row) => row.value === rowId)?.label
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>(
|
||||
`[${OWNER_ATTRIBUTE}="${id}"] [role="option"], [${OWNER_ATTRIBUTE}="${id}"][role="option"]`,
|
||||
),
|
||||
].find((candidate) => candidate.textContent?.includes(label ?? '\u0000'))
|
||||
option?.focus()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (search.loadingMore.value) {
|
||||
return
|
||||
}
|
||||
const loaded = search.entries.value.length
|
||||
await search.more()
|
||||
await nextTick()
|
||||
const firstNew = search.entries.value[loaded]
|
||||
if (firstNew) {
|
||||
focusRow(firstNew.id)
|
||||
} else {
|
||||
focusLoadMore()
|
||||
}
|
||||
}
|
||||
|
||||
function announceResults() {
|
||||
const total = search.total.value
|
||||
announce(total > 0 ? t('{0} results', [total]) : t('No results'))
|
||||
@@ -431,16 +463,55 @@ onMounted(() => store.load())
|
||||
</div>
|
||||
</template>
|
||||
<template #content-bottom>
|
||||
<UButton
|
||||
v-if="search.hasMore.value && !atCap"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:loading="search.loadingMore.value"
|
||||
class="h-8 w-full justify-center text-xs text-highlighted max-md:h-11"
|
||||
@click="search.more()"
|
||||
<div v-if="search.loadingMore.value" data-testid="relation-more">
|
||||
<span class="sr-only" role="status" aria-live="polite">{{
|
||||
t('Loading…')
|
||||
}}</span>
|
||||
<div
|
||||
v-for="width in MORE_SKELETON_WIDTHS"
|
||||
:key="width"
|
||||
data-testid="relation-more-skeleton"
|
||||
class="flex min-h-10 flex-col justify-center gap-1.5 px-2 py-1 max-md:min-h-12"
|
||||
>
|
||||
<USkeleton
|
||||
class="h-3 rounded-none bg-accented motion-reduce:animate-none"
|
||||
:style="{ width }"
|
||||
/>
|
||||
<USkeleton
|
||||
class="h-2.5 w-2/5 rounded-none bg-accented motion-reduce:animate-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="search.total.value > 0 && search.entries.value.length > 0"
|
||||
data-testid="relation-footer"
|
||||
class="flex items-center justify-between gap-2 border-t border-default px-2 py-1.5"
|
||||
>
|
||||
{{ t('Load more') }}
|
||||
</UButton>
|
||||
<span class="text-xs text-muted">{{
|
||||
t('Showing {0} of {1}', [
|
||||
search.entries.value.length,
|
||||
search.total.value,
|
||||
])
|
||||
}}</span>
|
||||
<UButton
|
||||
v-if="search.hasMore.value && !atCap"
|
||||
ref="loadMoreButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:icon="
|
||||
search.loadingMore.value ? 'i-lucide-loader-circle' : undefined
|
||||
"
|
||||
:aria-disabled="search.loadingMore.value ? 'true' : undefined"
|
||||
class="h-7 justify-center border border-accented bg-default px-2.5 text-xs font-medium text-highlighted ring-0 max-md:h-11"
|
||||
:class="search.loadingMore.value ? 'opacity-40' : ''"
|
||||
:ui="{
|
||||
leadingIcon: 'size-3.5 animate-spin motion-reduce:animate-none',
|
||||
}"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ search.loadingMore.value ? t('Loading…') : t('Load more') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UInputMenu>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import { useUsers, type AcceptedUser } from '@/composables/useUsers'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import {
|
||||
inlineRefusal,
|
||||
isCode,
|
||||
isMissingPermission,
|
||||
refusalToast,
|
||||
userToast,
|
||||
} from '@/lib/userRefusal'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const SUCCESS_TOAST_MS = 6000
|
||||
|
||||
const props = defineProps<{ user: AcceptedUser }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
removed: [user: AcceptedUser]
|
||||
roleChanged: [user: AcceptedUser]
|
||||
stale: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { removeUser } = useUsers()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
|
||||
const name = computed(() => props.user.display_name || props.user.username)
|
||||
|
||||
function handleFailure(error: unknown) {
|
||||
if (isCode(error, 409, 'user_role_changed')) {
|
||||
emit('roleChanged', props.user)
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (isCode(error, 404, 'user_not_found')) {
|
||||
emit('stale')
|
||||
toast.add(
|
||||
userToast(
|
||||
`user-remove-stale-${props.user.id}`,
|
||||
'error',
|
||||
() => t('That user no longer exists'),
|
||||
() => t('The list was refreshed.'),
|
||||
),
|
||||
)
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (isMissingPermission(error)) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
const refusal = inlineRefusal(error)
|
||||
if (refusal !== undefined) {
|
||||
toast.add(refusalToast(`user-remove-refused-${props.user.id}`, refusal))
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
notice.value = {
|
||||
title: t("Couldn't remove the user"),
|
||||
description: t('Nothing was changed. Try again in a moment.'),
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
const expectedRoleId = props.user.role?.id
|
||||
if (pending.value || expectedRoleId === undefined) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
try {
|
||||
await removeUser(props.user.id, expectedRoleId)
|
||||
emit('removed', props.user)
|
||||
toast.add(
|
||||
userToast(
|
||||
`user-removed-${props.user.id}`,
|
||||
'success',
|
||||
() => t('User removed'),
|
||||
() => t('{0} no longer has access.', [name.value]),
|
||||
SUCCESS_TOAST_MS,
|
||||
),
|
||||
)
|
||||
open.value = false
|
||||
} catch (error) {
|
||||
handleFailure(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
closable
|
||||
v-model:open="open"
|
||||
:title="t('Remove {0}?', [name])"
|
||||
:description="
|
||||
t(
|
||||
'They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay.',
|
||||
)
|
||||
"
|
||||
:confirm-label="t('Remove user')"
|
||||
:pending-label="t('Removing…')"
|
||||
tone="danger"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:top="notice ? 25 : undefined"
|
||||
@confirm="onConfirm"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import type { RoleDiffRow } from '@/lib/roleDiff'
|
||||
|
||||
defineProps<{
|
||||
description: string
|
||||
rows: RoleDiffRow[]
|
||||
pending?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ useSaved: []; keepEdits: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
v-model:open="open"
|
||||
:title="t('How the saved role differs from your edits')"
|
||||
:description="description"
|
||||
:confirm-label="t('Keep my edits')"
|
||||
:pending-label="t('Keep my edits')"
|
||||
:cancel-label="t('Use the saved role')"
|
||||
tone="neutral"
|
||||
:pending="pending"
|
||||
wide
|
||||
review-width
|
||||
:top="25"
|
||||
closable
|
||||
@confirm="emit('keepEdits')"
|
||||
@cancel="emit('useSaved')"
|
||||
>
|
||||
<div
|
||||
role="table"
|
||||
:aria-label="t('How the saved role differs from your edits')"
|
||||
class="overflow-x-auto border border-default"
|
||||
>
|
||||
<div
|
||||
role="row"
|
||||
class="grid grid-cols-[minmax(0,1fr)_72px_72px] gap-2 bg-elevated px-3 py-2 font-medium text-muted"
|
||||
>
|
||||
<span role="columnheader">{{ t('Setting') }}</span>
|
||||
<span role="columnheader">{{ t('Saved') }}</span>
|
||||
<span role="columnheader">{{ t('Yours') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
role="row"
|
||||
class="grid grid-cols-[minmax(0,1fr)_72px_72px] gap-2 border-t border-default px-3 py-2.5 text-highlighted"
|
||||
>
|
||||
<span role="cell" class="font-medium break-words">{{ row.setting }}</span>
|
||||
<span role="cell" class="break-words">{{ row.saved }}</span>
|
||||
<span role="cell" class="font-semibold break-words">{{ row.yours }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -1,20 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
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<{
|
||||
const props = defineProps<{
|
||||
role: RoleItem | null
|
||||
reason: ReadOnlyReason | null
|
||||
conflict: ConflictState | null
|
||||
refusal: RefusalCopy | null
|
||||
saveFailed: boolean
|
||||
canDuplicate: boolean
|
||||
warnMembers: boolean
|
||||
reviewing: boolean
|
||||
differences: { key: string; name: string; kind: 'added' | 'removed' | 'changed' }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -22,19 +22,28 @@ const emit = defineEmits<{
|
||||
reloadRole: []
|
||||
reloadPage: []
|
||||
review: []
|
||||
retrySave: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const retryButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.saveFailed,
|
||||
async (failed) => {
|
||||
if (!failed) {
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
retryButton.value?.$el.focus()
|
||||
},
|
||||
)
|
||||
|
||||
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>
|
||||
@@ -158,25 +167,50 @@ function kindLabel(kind: 'added' | 'removed' | 'changed') {
|
||||
:ui="ALERT_UI"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton color="neutral" variant="outline" class="h-8" @click="emit('reloadRole')">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 px-2.5 text-xs md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('reloadRole')"
|
||||
>
|
||||
{{ t('Reload role') }}
|
||||
</UButton>
|
||||
<UButton color="neutral" variant="outline" class="h-8" @click="emit('review')">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 px-2.5 text-xs md:h-7"
|
||||
@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"
|
||||
<UAlert
|
||||
v-if="saveFailed"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:close="false"
|
||||
:title="t('Couldn\'t save the role')"
|
||||
:description="t('Your changes are still here. Try again in a moment.')"
|
||||
:ui="ALERT_UI"
|
||||
>
|
||||
<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>
|
||||
<template #actions>
|
||||
<UButton
|
||||
ref="retryButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-8"
|
||||
@click="emit('retrySave')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UAlert
|
||||
v-if="refusal"
|
||||
|
||||
@@ -51,18 +51,16 @@ const items = computed(() =>
|
||||
variant="card"
|
||||
color="primary"
|
||||
:aria-label="label"
|
||||
:ui="{ item: 'rounded-none', fieldset: 'gap-2' }"
|
||||
:ui="{
|
||||
item: 'rounded-none has-data-[state=checked]:border-primary has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand)',
|
||||
fieldset: 'gap-2',
|
||||
description: 'text-xs',
|
||||
}"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<span class="flex items-center gap-2">
|
||||
{{ item.label }}
|
||||
<UBadge
|
||||
v-if="item.current"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:label="t('Current')"
|
||||
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 text-[0.6875rem]' }"
|
||||
/>
|
||||
<span v-if="item.current" class="text-xs font-medium">· {{ t('Current') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</URadioGroup>
|
||||
@@ -77,7 +75,7 @@ const items = computed(() =>
|
||||
:content="{ position: 'inline' }"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none md:h-10',
|
||||
base: 'h-11 rounded-none md:h-8',
|
||||
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',
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import { useUsers, type AcceptedUser, type UserListItem } from '@/composables/useUsers'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { isCode, isMissingPermission, isSessionRequired, userToast } from '@/lib/userRefusal'
|
||||
import {
|
||||
isCode,
|
||||
isMissingPermission,
|
||||
isSessionRequired,
|
||||
staleToast,
|
||||
userToast,
|
||||
} from '@/lib/userRefusal'
|
||||
import { elideDe } from '@/lib/frenchElision'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
@@ -16,7 +23,7 @@ const emit = defineEmits<{ transferred: []; stale: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { transferOwnership } = useUsers()
|
||||
const { transferOwnership, listUsers } = useUsers()
|
||||
const session = useSessionStore()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
@@ -26,6 +33,8 @@ const typed = ref('')
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const fieldError = ref(false)
|
||||
const awaitingReload = ref(false)
|
||||
let disposed = false
|
||||
|
||||
function displayName(user: AcceptedUser) {
|
||||
return user.display_name || user.username
|
||||
@@ -45,6 +54,12 @@ const options = computed(() =>
|
||||
})),
|
||||
)
|
||||
const picking = computed(() => step.value !== 'confirm')
|
||||
const transferTop = computed(() => {
|
||||
if (picking.value) {
|
||||
return 25
|
||||
}
|
||||
return notice.value ? 10 : 20
|
||||
})
|
||||
const chosen = computed(() => members.value.find((user) => user.id === chosenId.value))
|
||||
const name = computed(() => (chosen.value ? displayName(chosen.value) : ''))
|
||||
const email = computed(() => chosen.value?.email ?? '')
|
||||
@@ -60,7 +75,9 @@ const description = computed(() =>
|
||||
const confirmLabel = computed(() => (picking.value ? t('Continue') : t('Transfer ownership')))
|
||||
const cancelLabel = computed(() => (picking.value ? t('Cancel') : t('Back')))
|
||||
const confirmDisabled = computed(() =>
|
||||
picking.value ? chosen.value === undefined : typed.value !== email.value,
|
||||
picking.value
|
||||
? chosen.value === undefined
|
||||
: typed.value !== email.value || awaitingReload.value,
|
||||
)
|
||||
const consequences = computed(() => [
|
||||
t('{0} becomes the owner.', [name.value]),
|
||||
@@ -77,15 +94,104 @@ function onOpenChange(value: boolean) {
|
||||
open.value = value
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
if (step.value === 'confirm') {
|
||||
step.value = 'back'
|
||||
typed.value = ''
|
||||
awaitingReload.value = false
|
||||
notice.value = null
|
||||
fieldError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function announceStaleAfterClose() {
|
||||
const id = 'ownership-stale'
|
||||
toast.add(
|
||||
staleToast(
|
||||
id,
|
||||
() => t('Ownership not transferred'),
|
||||
() =>
|
||||
elideDe(
|
||||
t(
|
||||
"{0}'s role changed in the meantime, so nothing was transferred. Check it and try again.",
|
||||
[name.value],
|
||||
),
|
||||
name.value,
|
||||
),
|
||||
() => {
|
||||
void listUsers()
|
||||
toast.remove(id)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function findTarget(users: UserListItem[]) {
|
||||
return users.find(
|
||||
(item): item is AcceptedUser => item.kind === 'user' && item.id === chosenId.value,
|
||||
)
|
||||
}
|
||||
|
||||
function staleNotice(role: string): DirectoryNotice {
|
||||
return {
|
||||
title: t('Role changed in the meantime'),
|
||||
tone: 'warning',
|
||||
description: t(
|
||||
'{0} is now {1}. What you typed is kept. Check the change, then try again.',
|
||||
[name.value, role],
|
||||
),
|
||||
action: { label: t('Reload member'), onClick: reloadMember },
|
||||
}
|
||||
}
|
||||
|
||||
function backToPick() {
|
||||
step.value = 'pick'
|
||||
chosenId.value = undefined
|
||||
typed.value = ''
|
||||
awaitingReload.value = false
|
||||
emit('stale')
|
||||
notice.value = {
|
||||
description: t("That person can't become the owner. Choose another member."),
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadMember() {
|
||||
try {
|
||||
const target = findTarget(await listUsers())
|
||||
if (target === undefined || target.role?.locked) {
|
||||
backToPick()
|
||||
return
|
||||
}
|
||||
awaitingReload.value = false
|
||||
notice.value = null
|
||||
emit('stale')
|
||||
} catch {
|
||||
notice.value = { description: t("Couldn't transfer ownership. Try again in a moment.") }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoleChangedFirst() {
|
||||
if (disposed || !open.value) {
|
||||
announceStaleAfterClose()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const target = findTarget(await listUsers())
|
||||
if (target === undefined) {
|
||||
backToPick()
|
||||
return
|
||||
}
|
||||
awaitingReload.value = true
|
||||
notice.value = staleNotice(target.role?.name ?? '')
|
||||
} catch {
|
||||
notice.value = { description: t("Couldn't transfer ownership. Try again in a moment.") }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFailure(error: unknown) {
|
||||
if (isMissingPermission(error)) {
|
||||
open.value = false
|
||||
@@ -109,17 +215,7 @@ async function handleFailure(error: unknown) {
|
||||
return
|
||||
}
|
||||
if (isCode(error, 409, 'user_role_changed')) {
|
||||
await session.refresh()
|
||||
emit('stale')
|
||||
toast.add(
|
||||
userToast(
|
||||
'ownership-changed-meanwhile',
|
||||
'error',
|
||||
() => t('Ownership changed meanwhile'),
|
||||
() => t('The list was refreshed.'),
|
||||
),
|
||||
)
|
||||
open.value = false
|
||||
await handleRoleChangedFirst()
|
||||
return
|
||||
}
|
||||
notice.value = { description: t("Couldn't transfer ownership. Try again in a moment.") }
|
||||
@@ -167,6 +263,7 @@ function onConfirm() {
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
closable
|
||||
:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
@@ -176,6 +273,7 @@ function onConfirm() {
|
||||
:tone="picking ? 'neutral' : 'danger'"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:top="transferTop"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@update:open="onOpenChange"
|
||||
@cancel="onCancel"
|
||||
|
||||
@@ -19,9 +19,18 @@ const model = computed(() => buildHeldRolePreview(permissions.grants))
|
||||
<ProfileCard :title="t('Your role')">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<RoleBadge :role="permissions.role" />
|
||||
<span v-if="permissions.roleAssignedBy" class="text-muted">{{
|
||||
t('Assigned by {0}', [permissions.roleAssignedBy.name])
|
||||
}}</span>
|
||||
<UBadge
|
||||
v-if="permissions.role?.locked"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-lock"
|
||||
:label="t('Locked')"
|
||||
/>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
:label="permissions.role?.built_in ? t('Built-in') : t('Custom')"
|
||||
/>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { AcceptedUser } from '@/composables/useUsers'
|
||||
@@ -12,13 +13,15 @@ import ChangeRoleDialog from '../ChangeRoleDialog.vue'
|
||||
|
||||
const listRoles = vi.fn()
|
||||
const changeUserRole = vi.fn()
|
||||
const listUsers = vi.fn()
|
||||
const toastRemove = vi.fn()
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@/composables/useRoles', () => ({ useRoles: () => ({ listRoles }) }))
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ changeUserRole }) }))
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ changeUserRole, listUsers }) }))
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
return { ...actual, useToast: () => ({ add: toastAdd, remove: toastRemove }) }
|
||||
})
|
||||
|
||||
const INES: AcceptedUser = {
|
||||
@@ -82,6 +85,8 @@ function emitted(name: string) {
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
listRoles.mockResolvedValue(WORLD)
|
||||
listUsers.mockResolvedValue([INES])
|
||||
toastRemove.mockReset()
|
||||
changeUserRole.mockResolvedValue({
|
||||
...INES,
|
||||
role: { id: CONTRIBUTOR.id, name: 'Contributor', built_in: false, locked: false },
|
||||
@@ -94,6 +99,7 @@ afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
listRoles.mockReset()
|
||||
changeUserRole.mockReset()
|
||||
listUsers.mockReset()
|
||||
})
|
||||
|
||||
describe('The dialog', () => {
|
||||
@@ -209,19 +215,6 @@ describe('Changing the role', () => {
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('409 user_role_changed asks for a re-read, toasts and closes', async () => {
|
||||
changeUserRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' }),
|
||||
)
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('404 user_not_found asks for a re-read, toasts and closes', async () => {
|
||||
changeUserRole.mockRejectedValue(
|
||||
new ApiError(404, undefined, 'user_not_found', { code: 'user_not_found' }),
|
||||
@@ -268,3 +261,217 @@ describe('Roles are read through useRoles', () => {
|
||||
expect(roleRefFor('admin')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: final copy', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
function toastContent() {
|
||||
const argument = toastAdd.mock.calls[0]![0]
|
||||
return typeof argument === 'function' ? argument() : argument
|
||||
}
|
||||
|
||||
it('toasts the role change with the member and role names for 6 s', async () => {
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
const toast = toastContent()
|
||||
expect(toast.color).toBe('success')
|
||||
expect(toast.title).toBe('Role changed')
|
||||
expect(toast.description).toBe(
|
||||
'Ines Duarte is now Contributor, and their API keys follow the new role.',
|
||||
)
|
||||
expect(toast.duration).toBe(6000)
|
||||
})
|
||||
|
||||
it('toasts the role change in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
button('Changer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const toast = toastContent()
|
||||
expect(toast.title).toBe('Rôle modifié')
|
||||
expect(toast.description).toBe(
|
||||
'Ines Duarte a maintenant le rôle Contributor, et ses clés API suivent le nouveau rôle.',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', new ApiError(500)],
|
||||
['a network failure', new TypeError('Failed to fetch')],
|
||||
])('shows the alert on %s and keeps the choice', async (_failure, error) => {
|
||||
changeUserRole.mockRejectedValue(error)
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
const alert = dialog().querySelector('[role="alert"]')!
|
||||
expect(alert.textContent).toContain("Couldn't change the role")
|
||||
expect(alert.textContent).toContain('Nothing was changed. Try again in a moment.')
|
||||
expect(optionNamed('Contributor').getAttribute('aria-checked')).toBe('true')
|
||||
expect(button('Change role')!.disabled).toBe(false)
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows the alert in French', async () => {
|
||||
setLocale('fr')
|
||||
changeUserRole.mockRejectedValue(new ApiError(500))
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
button('Changer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const alert = dialog().querySelector('[role="alert"]')!.textContent!
|
||||
expect(alert).toContain('Impossible de modifier le rôle')
|
||||
expect(alert.replace(/\u2019/g, "'")).toContain(
|
||||
"Rien n'a été modifié. Réessayez dans un instant.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: the member role changed first (409)', () => {
|
||||
const STALE = new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' })
|
||||
|
||||
function withRole(name: string, id: number): AcceptedUser {
|
||||
return { ...INES, role: { id, name, built_in: name !== 'Contributor', locked: false } }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
function firstToast() {
|
||||
const argument = toastAdd.mock.calls[0]![0]
|
||||
return typeof argument === 'function' ? argument() : argument
|
||||
}
|
||||
|
||||
function alertText() {
|
||||
return dialog().querySelector('[role="alert"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
async function staleWithAdmin() {
|
||||
changeUserRole.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue([withRole('Admin', 2)])
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
}
|
||||
|
||||
it('keeps the dialog open with the alert, the choice, the moved Current mark and no toast', async () => {
|
||||
await staleWithAdmin()
|
||||
|
||||
expect(listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(alertText()).toContain('Role changed in the meantime')
|
||||
expect(alertText()).toContain(
|
||||
'Ines Duarte is now Admin. Your choice is kept. Check it and try again.',
|
||||
)
|
||||
expect(optionNamed('Contributor').getAttribute('aria-checked')).toBe('true')
|
||||
expect(labelNamed('Admin').textContent).toContain('Current')
|
||||
expect(labelNamed('Writer').textContent).not.toContain('Current')
|
||||
expect(button('Change role')!.disabled).toBe(false)
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(emitted('refreshed')?.[0]?.[0]).toMatchObject({ role: { name: 'Admin' } })
|
||||
})
|
||||
|
||||
it('sends the same role again when Change role is pressed once more', async () => {
|
||||
await staleWithAdmin()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(changeUserRole).toHaveBeenNthCalledWith(2, 7, CONTRIBUTOR.id)
|
||||
})
|
||||
|
||||
it('disables Change role when the chosen role became the current one', async () => {
|
||||
changeUserRole.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue([withRole('Contributor', CONTRIBUTOR.id)])
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(alertText()).toContain('Ines Duarte is now Contributor.')
|
||||
expect(button('Change role')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes, asks for a re-read and toasts when the member is gone', async () => {
|
||||
changeUserRole.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue([])
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
const toast = firstToast()
|
||||
expect(toast.title).toBe('That user no longer exists')
|
||||
})
|
||||
|
||||
it('reads the alert in French', async () => {
|
||||
setLocale('fr')
|
||||
changeUserRole.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue([withRole('Admin', 2)])
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
button('Changer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(alertText()).toContain('Rôle modifié entre-temps')
|
||||
expect(alertText()).toContain(
|
||||
'Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez.',
|
||||
)
|
||||
})
|
||||
|
||||
describe('when the dialog is gone before the answer arrives', () => {
|
||||
async function unmountedBefore409(confirmLabel = 'Change role') {
|
||||
let reject!: (reason: unknown) => void
|
||||
changeUserRole.mockReturnValue(new Promise((_, fail) => (reject = fail)))
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
button(confirmLabel)!.click()
|
||||
await flushPromises()
|
||||
wrapper!.unmount()
|
||||
wrapper = null
|
||||
reject(STALE)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('raises a warning toast that stays, with Reload', async () => {
|
||||
await unmountedBefore409()
|
||||
|
||||
const toast = firstToast()
|
||||
expect(toast.color).toBe('warning')
|
||||
expect(toast.duration).toBe(0)
|
||||
expect(toast.title).toBe('Role not changed')
|
||||
expect(toast.description).toBe(
|
||||
"Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again.",
|
||||
)
|
||||
expect(toast.actions[0].label).toBe('Reload')
|
||||
expect(listUsers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refetches the users and dismisses the toast on Reload', async () => {
|
||||
await unmountedBefore409()
|
||||
const toast = firstToast()
|
||||
|
||||
toast.actions[0].onClick()
|
||||
|
||||
expect(listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(toastRemove).toHaveBeenCalledWith(toast.id)
|
||||
})
|
||||
|
||||
it('words the toast in French', async () => {
|
||||
setLocale('fr')
|
||||
await unmountedBefore409('Changer le rôle')
|
||||
|
||||
const toast = firstToast()
|
||||
expect(toast.title).toBe('Rôle non modifié')
|
||||
expect(toast.actions[0].label).toBe('Recharger')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import DeleteRoleDialog from '../DeleteRoleDialog.vue'
|
||||
import { renderInApp } from './roleKit'
|
||||
import { canCover } from '@/lib/grantLocks'
|
||||
@@ -10,9 +11,10 @@ import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const reassignRole = vi.fn()
|
||||
const deleteRole = vi.fn()
|
||||
const listRoles = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useRoles', () => ({
|
||||
useRoles: () => ({ reassignRole, deleteRole }),
|
||||
useRoles: () => ({ reassignRole, deleteRole, listRoles }),
|
||||
}))
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
@@ -118,11 +120,15 @@ beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
reassignRole.mockReset()
|
||||
deleteRole.mockReset()
|
||||
listRoles.mockReset()
|
||||
listRoles.mockResolvedValue(ROLES)
|
||||
reassignRole.mockResolvedValue({ moved: 4 })
|
||||
deleteRole.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
@@ -291,3 +297,169 @@ describe('Built-in role dialog', () => {
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: inline errors', () => {
|
||||
function alertText() {
|
||||
return dialog().querySelector('[role="alert"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
function pickerText() {
|
||||
return dialog().querySelector('[aria-haspopup="listbox"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
async function moveContributorTo(name: string) {
|
||||
await mountDialog(CONTRIBUTOR)
|
||||
await choose(name)
|
||||
button('Move 4 members and delete role')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('says the target no longer exists on 409 target_role_not_found and clears the picker', async () => {
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'target_role_not_found', {
|
||||
code: 'target_role_not_found',
|
||||
}),
|
||||
)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(alertText()).toContain('Writer no longer exists')
|
||||
expect(alertText()).toContain(
|
||||
'It was deleted somewhere else. Choose another role to move the members to.',
|
||||
)
|
||||
expect(pickerText()).toContain('Choose a role')
|
||||
expect(button('Move 4 members and delete role')!.disabled).toBe(true)
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
await openPicker()
|
||||
expect(optionNamed('Writer')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hands the page the gone role when the move answers 404 role_not_found', async () => {
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(404, undefined, 'role_not_found', { code: 'role_not_found' }),
|
||||
)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(wrapper!.findComponent(DeleteRoleDialog).emitted('gone')).toHaveLength(1)
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-reads GET /v1/role on 409 target_role_not_found and hands the fresh list to the page', async () => {
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'target_role_not_found', {
|
||||
code: 'target_role_not_found',
|
||||
}),
|
||||
)
|
||||
const afterDeletion = ROLES.filter((item) => item.name !== 'Writer')
|
||||
listRoles.mockResolvedValue(afterDeletion)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(listRoles).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper!.findComponent(DeleteRoleDialog).emitted('refreshed')?.[0]).toEqual([
|
||||
afterDeletion,
|
||||
])
|
||||
})
|
||||
|
||||
it('turns into the blocked state with the live count on 409 role_has_members', async () => {
|
||||
deleteRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'role_has_members', { code: 'role_has_members' }),
|
||||
)
|
||||
listRoles.mockResolvedValue(
|
||||
ROLES.map((item) => (item.id === 6 ? { ...item, members: 1 } : item)),
|
||||
)
|
||||
await mountDialog(INTERN)
|
||||
|
||||
button('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(alertText()).toContain('Role still has members')
|
||||
expect(alertText()).toContain('Intern now has 1 member. Move them to another role first.')
|
||||
expect(button('Move 1 member and delete role')!.disabled).toBe(true)
|
||||
expect(pickerText()).toContain('Choose a role')
|
||||
})
|
||||
|
||||
it('uses the plural for several new members', async () => {
|
||||
deleteRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'role_has_members', { code: 'role_has_members' }),
|
||||
)
|
||||
listRoles.mockResolvedValue(
|
||||
ROLES.map((item) => (item.id === 6 ? { ...item, members: 3 } : item)),
|
||||
)
|
||||
await mountDialog(INTERN)
|
||||
|
||||
button('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(alertText()).toContain('Intern now has 3 members. Move them to another role first.')
|
||||
expect(button('Move 3 members and delete role')).toBeDefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', new ApiError(500)],
|
||||
['a network failure', new TypeError('Failed to fetch')],
|
||||
])('keeps the choice and sends no DELETE when the move answers %s', async (_failure, error) => {
|
||||
reassignRole.mockRejectedValue(error)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(alertText()).toContain("Couldn't move the members")
|
||||
expect(alertText()).toContain('Nothing was changed. Try again in a moment.')
|
||||
expect(pickerText()).toContain('Writer')
|
||||
expect(button('Move 4 members and delete role')!.disabled).toBe(false)
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the picker on 400 invalid_reassign and keeps the existing refusal', async () => {
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(400, undefined, 'invalid_reassign', { code: 'invalid_reassign' }),
|
||||
)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(alertText()).toContain("Couldn't delete the role. Try again in a moment.")
|
||||
expect(pickerText()).toContain('Choose a role')
|
||||
expect(button('Move 4 members and delete role')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('reads the three alerts in French', async () => {
|
||||
setLocale('fr')
|
||||
const plain = () => alertText().replace(/\u2019/g, "'")
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'target_role_not_found', {
|
||||
code: 'target_role_not_found',
|
||||
}),
|
||||
)
|
||||
await mountDialog(CONTRIBUTOR)
|
||||
await choose('Writer')
|
||||
button('Déplacer 4 membres et supprimer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(plain()).toContain("Writer n'existe plus")
|
||||
expect(plain()).toContain(
|
||||
'Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.',
|
||||
)
|
||||
|
||||
reassignRole.mockReset()
|
||||
reassignRole.mockRejectedValue(new ApiError(500))
|
||||
await choose('Admin')
|
||||
button('Déplacer 4 membres et supprimer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(plain()).toContain('Impossible de déplacer les membres')
|
||||
expect(plain()).toContain("Rien n'a été modifié. Réessayez dans un instant.")
|
||||
|
||||
wrapper?.unmount()
|
||||
document.body.innerHTML = ''
|
||||
deleteRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'role_has_members', { code: 'role_has_members' }),
|
||||
)
|
||||
listRoles.mockResolvedValue(
|
||||
ROLES.map((item) => (item.id === 6 ? { ...item, members: 1 } : item)),
|
||||
)
|
||||
await mountDialog(INTERN)
|
||||
button('Supprimer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(plain()).toContain('Le rôle a encore des membres')
|
||||
expect(plain()).toContain(
|
||||
"Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle.",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,13 +4,14 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import inviteEmailCases from '../../../../test/testdata/invite_emails.json'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { adminAccess, customAccess, ownerAccess } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import { ADMIN, CONTRIBUTOR, INTERN, LEAD, OWNER, WORLD, WRITER } from './userRolesKit'
|
||||
import InviteUserModal from '../InviteUserModal.vue'
|
||||
|
||||
const INVALID_EMAIL = 'Enter a full email address, like name@example.com.'
|
||||
const INVALID_EMAIL = 'Enter a valid email address.'
|
||||
const ESCALATION_REASON = "Your role can't assign a role with more power than yours."
|
||||
|
||||
const inviteUser = vi.fn()
|
||||
@@ -95,6 +96,8 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
@@ -237,7 +240,6 @@ describe('Invite answers that concern roles', () => {
|
||||
}
|
||||
|
||||
it.each([
|
||||
['escalation', "You can't grant that", 'You can only grant what your role has.'],
|
||||
[
|
||||
'role_locked',
|
||||
'This role is locked',
|
||||
@@ -255,6 +257,22 @@ describe('Invite answers that concern roles', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('403 escalation reads under Role, not in an alert, and keeps the email and the role', async () => {
|
||||
await submitRefused(new ApiError(403, undefined, 'escalation', { code: 'escalation' }))
|
||||
|
||||
const field = [...document.body.querySelectorAll('[data-slot="root"]')].find(
|
||||
(candidate) =>
|
||||
candidate.textContent?.includes('Choose a role') ||
|
||||
candidate.querySelector('[aria-haspopup="listbox"]'),
|
||||
)
|
||||
expect(field?.textContent).toContain(ESCALATION_REASON)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(dialogText()).not.toContain("You can't grant that")
|
||||
expect(emailInput().value).toBe('lea.martin@renew.example')
|
||||
expect(picker()?.textContent).toContain('Writer')
|
||||
expect(sendButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('429 shows the rate limit alert and keeps the dialog and the address', async () => {
|
||||
await submitRefused(new ApiError(429, undefined, undefined, undefined))
|
||||
|
||||
@@ -273,3 +291,70 @@ describe('Invite answers that concern roles', () => {
|
||||
expect(LEAD.name).toBe('Content lead')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: invite answers', () => {
|
||||
async function submitAs(error: unknown) {
|
||||
inviteUser.mockRejectedValue(error)
|
||||
await mountOpenModal()
|
||||
await typeEmail('lea.martin@renew.example')
|
||||
await clickSend()
|
||||
}
|
||||
|
||||
function emailField() {
|
||||
let node: HTMLElement | null = emailInput()
|
||||
while (node && !node.querySelector('label')) {
|
||||
node = node.parentElement
|
||||
}
|
||||
return node!
|
||||
}
|
||||
|
||||
it('409 email_has_account reads under Email and keeps the role', async () => {
|
||||
await submitAs(
|
||||
new ApiError(409, undefined, 'email_has_account', { code: 'email_has_account' }),
|
||||
)
|
||||
|
||||
expect(emailField().textContent).toContain('This address already has an account.')
|
||||
expect(picker()?.textContent).toContain('Writer')
|
||||
expect(emailInput().value).toBe('lea.martin@renew.example')
|
||||
})
|
||||
|
||||
it('a client check failure reads Enter a valid email address. under Email', async () => {
|
||||
inviteUser.mockResolvedValue({ id: 1 })
|
||||
await mountOpenModal()
|
||||
await typeEmail('lea.martin')
|
||||
await clickSend()
|
||||
|
||||
expect(emailField().textContent).toContain('Enter a valid email address.')
|
||||
expect(inviteUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 reads Enter a valid email address. under Email', async () => {
|
||||
await submitAs(new ApiError(400))
|
||||
|
||||
expect(emailField().textContent).toContain('Enter a valid email address.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', new ApiError(500)],
|
||||
['a network failure', new TypeError('Failed to fetch')],
|
||||
])('%s shows the alert and keeps the email and the role', async (_failure, error) => {
|
||||
await submitAs(error)
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')!
|
||||
expect(alert.textContent).toContain("Couldn't send the invite")
|
||||
expect(alert.textContent).toContain('Try again in a moment.')
|
||||
expect(emailInput().value).toBe('lea.martin@renew.example')
|
||||
expect(picker()?.textContent).toContain('Writer')
|
||||
expect(sendButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the send failed alert in French', async () => {
|
||||
await submitAs(new ApiError(500))
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')!.textContent!
|
||||
expect(alert.replace(/\u2019/g, "'")).toContain("Impossible d'envoyer l'invitation")
|
||||
expect(alert).toContain('Réessayez dans un instant.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { renderInApp } from './roleKit'
|
||||
import OwnScopeEmpty from '../OwnScopeEmpty.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
describe('OwnScopeEmpty', () => {
|
||||
it('shows the title and description without an action button', async () => {
|
||||
wrapper = renderInApp(OwnScopeEmpty, {
|
||||
icon: 'i-lucide-file',
|
||||
title: 'Nothing here',
|
||||
description: 'You have no entries yet.',
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Nothing here')
|
||||
expect(wrapper.text()).toContain('You have no entries yet.')
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders the action button and emits action on click', async () => {
|
||||
wrapper = renderInApp(OwnScopeEmpty, {
|
||||
icon: 'i-lucide-file',
|
||||
title: 'Nothing here',
|
||||
description: 'You have no entries yet.',
|
||||
action: 'New entry',
|
||||
})
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]!.text()).toBe('New entry')
|
||||
await buttons[0]!.trigger('click')
|
||||
expect(wrapper.findComponent(OwnScopeEmpty).emitted('action')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { AcceptedUser } from '@/composables/useUsers'
|
||||
import { roleRefFor } from '@/test/access'
|
||||
import { renderInApp } from './roleKit'
|
||||
import RemoveUserDialog from '../RemoveUserDialog.vue'
|
||||
|
||||
const removeUser = vi.fn()
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ removeUser }) }))
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
})
|
||||
|
||||
const INES: AcceptedUser = {
|
||||
kind: 'user',
|
||||
id: 7,
|
||||
username: 'ines',
|
||||
display_name: 'Ines Duarte',
|
||||
email: 'ines.duarte@renew.example',
|
||||
role: roleRefFor('editor'),
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
}
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function mountDialog() {
|
||||
setActivePinia(createPinia())
|
||||
wrapper = renderInApp(RemoveUserDialog, { user: INES, open: true })
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector('[role="alertdialog"]') as HTMLElement
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return [...dialog().querySelectorAll('button')].find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
) as HTMLButtonElement | undefined
|
||||
}
|
||||
|
||||
async function confirm(label = 'Remove user') {
|
||||
button(label)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return wrapper!.findComponent(RemoveUserDialog).emitted(name)
|
||||
}
|
||||
|
||||
function toastContent(index = 0) {
|
||||
const argument = toastAdd.mock.calls[index]![0]
|
||||
return typeof argument === 'function' ? argument() : argument
|
||||
}
|
||||
|
||||
function refuse(status: number, code: string) {
|
||||
return new ApiError(status, undefined, code, { code: code as never })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
removeUser.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
removeUser.mockReset()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Remove user: the confirmation dialog', () => {
|
||||
it('names the member and offers Cancel and a destructive confirm action', async () => {
|
||||
await mountDialog()
|
||||
|
||||
expect(dialog().textContent).toContain('Remove Ines Duarte?')
|
||||
expect(button('Cancel')).toBeDefined()
|
||||
expect(button('Remove user')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('sends the role of the list item as expected_role_id and locks the dialog while in flight', async () => {
|
||||
let release!: (value?: unknown) => void
|
||||
removeUser.mockReturnValue(new Promise((resolve) => (release = resolve)))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(removeUser).toHaveBeenCalledWith(7, INES.role!.id)
|
||||
expect(button('Removing…')!.disabled).toBe(true)
|
||||
expect(button('Cancel')!.disabled).toBe(true)
|
||||
|
||||
release()
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('closes, tells the page and toasts for 6 s on success', async () => {
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(emitted('removed')?.[0]?.[0]).toMatchObject({ id: 7 })
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
expect(toastContent()).toMatchObject({ color: 'success', duration: 6000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remove user: answers', () => {
|
||||
it('409 user_role_changed closes the dialog and hands the member to the page', async () => {
|
||||
removeUser.mockRejectedValue(refuse(409, 'user_role_changed'))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(emitted('roleChanged')?.[0]?.[0]).toMatchObject({ id: 7 })
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
expect(emitted('removed')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('404 user_not_found asks for a re-read, toasts and closes', async () => {
|
||||
removeUser.mockRejectedValue(refuse(404, 'user_not_found'))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
expect(toastContent().title).toBe('That user no longer exists')
|
||||
expect(toastContent().description).toBe('The list was refreshed.')
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['escalation', "You can't grant that", 'You can only grant what your role has.'],
|
||||
[
|
||||
'role_locked',
|
||||
'This role is locked',
|
||||
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.",
|
||||
],
|
||||
['own_role', "You can't change your own role", 'Ask another owner or admin.'],
|
||||
] as const)('403 %s toasts the shared refusal and closes', async (code, title, description) => {
|
||||
removeUser.mockRejectedValue(refuse(403, code))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(toastContent()).toMatchObject({ color: 'error', title, description })
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('403 missing_permission closes and leaves the toast to the API client', async () => {
|
||||
removeUser.mockRejectedValue(refuse(403, 'missing_permission'))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', new ApiError(500)],
|
||||
['a network failure', new TypeError('Failed to fetch')],
|
||||
])(
|
||||
'%s stays open with an inline error and keeps the confirm action on',
|
||||
async (_name, error) => {
|
||||
removeUser.mockRejectedValue(error)
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
expect(button('Remove user')!.disabled).toBe(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Remove user: French', () => {
|
||||
it('reads the dialog in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountDialog()
|
||||
|
||||
expect(dialog().textContent).toContain('Retirer Ines Duarte ?')
|
||||
expect(button('Retirer l’utilisateur')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { renderInApp } from './roleKit'
|
||||
import RoleDifferencesDialog from '../RoleDifferencesDialog.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
const ROWS = [
|
||||
{ id: 'a', setting: 'Articles: delete', saved: 'No', yours: 'Yes' },
|
||||
{ id: 'b', setting: 'Name', saved: 'Editor', yours: 'Writer' },
|
||||
]
|
||||
|
||||
async function mountDialog() {
|
||||
wrapper = renderInApp(RoleDifferencesDialog, {
|
||||
open: true,
|
||||
description: 'Two differences.',
|
||||
rows: ROWS,
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return [...document.body.querySelectorAll('[role="row"]')].map((row) =>
|
||||
[...row.querySelectorAll('[role="cell"],[role="columnheader"]')].map((cell) =>
|
||||
cell.textContent?.trim(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
) as HTMLButtonElement
|
||||
}
|
||||
|
||||
describe('RoleDifferencesDialog', () => {
|
||||
it('lists a header row then one row per difference', async () => {
|
||||
await mountDialog()
|
||||
expect(document.body.textContent).toContain('Two differences.')
|
||||
expect(rows()).toEqual([
|
||||
['Setting', 'Saved', 'Yours'],
|
||||
['Articles: delete', 'No', 'Yes'],
|
||||
['Name', 'Editor', 'Writer'],
|
||||
])
|
||||
})
|
||||
|
||||
it('emits keepEdits and useSaved from the two buttons', async () => {
|
||||
await mountDialog()
|
||||
button('Keep my edits').click()
|
||||
await flushPromises()
|
||||
const component = wrapper!.findComponent(RoleDifferencesDialog)
|
||||
expect(component.emitted('keepEdits')).toHaveLength(1)
|
||||
button('Use the saved role').click()
|
||||
await flushPromises()
|
||||
expect(component.emitted('useSaved')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
@@ -9,13 +10,15 @@ import { renderInApp } from './roleKit'
|
||||
import TransferOwnershipDialog from '../TransferOwnershipDialog.vue'
|
||||
|
||||
const transferOwnership = vi.fn()
|
||||
const listUsers = vi.fn()
|
||||
const toastRemove = vi.fn()
|
||||
const refresh = vi.fn()
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ transferOwnership }) }))
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ transferOwnership, listUsers }) }))
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
return { ...actual, useToast: () => ({ add: toastAdd, remove: toastRemove }) }
|
||||
})
|
||||
|
||||
const JONAS_EMAIL = 'jonas@renewbuild.example'
|
||||
@@ -104,6 +107,8 @@ function emitted(name: string) {
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
refresh.mockResolvedValue(undefined)
|
||||
listUsers.mockResolvedValue(USERS)
|
||||
toastRemove.mockReset()
|
||||
transferOwnership.mockResolvedValue({
|
||||
owner: { id: 2, name: 'Jonas Weber' },
|
||||
former_owner: { id: 1, name: 'Marta Alves', role: roleRefFor('admin') },
|
||||
@@ -116,6 +121,7 @@ afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
transferOwnership.mockReset()
|
||||
refresh.mockReset()
|
||||
listUsers.mockReset()
|
||||
})
|
||||
|
||||
describe('Pick a member', () => {
|
||||
@@ -241,19 +247,6 @@ describe('Transferring', () => {
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('409 user_role_changed re-reads the session and the list, toasts and closes', async () => {
|
||||
transferOwnership.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' }),
|
||||
)
|
||||
await mountDialog()
|
||||
await submit()
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('403 missing_permission closes and leaves the toast to the API client', async () => {
|
||||
transferOwnership.mockRejectedValue(
|
||||
new ApiError(403, undefined, 'missing_permission', { code: 'missing_permission' }),
|
||||
@@ -288,3 +281,135 @@ describe('Transferring', () => {
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: the target role changed first (409)', () => {
|
||||
const STALE = new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' })
|
||||
const JONAS_NOW_WRITER = accepted(2, 'Jonas Weber', JONAS_EMAIL, 'editor')
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
function alertText() {
|
||||
return dialog().querySelector('[role="alert"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
function firstToast() {
|
||||
const argument = toastAdd.mock.calls[0]![0]
|
||||
return typeof argument === 'function' ? argument() : argument
|
||||
}
|
||||
|
||||
async function staleAfterSubmit() {
|
||||
transferOwnership.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue(USERS.map((user) => (user.id === 2 ? JONAS_NOW_WRITER : user)))
|
||||
await mountDialog()
|
||||
await submit()
|
||||
}
|
||||
|
||||
it('shows the alert with Reload member, keeps the typed email and switches the primary action off', async () => {
|
||||
await staleAfterSubmit()
|
||||
|
||||
expect(alertText()).toContain('Role changed in the meantime')
|
||||
expect(alertText()).toContain(
|
||||
'Jonas Weber is now Writer. What you typed is kept. Check the change, then try again.',
|
||||
)
|
||||
expect(button('Reload member')).toBeDefined()
|
||||
expect(field().value).toBe(JONAS_EMAIL)
|
||||
expect(button('Transfer ownership')!.disabled).toBe(true)
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-reads the member on Reload member and turns the primary action back on', async () => {
|
||||
await staleAfterSubmit()
|
||||
listUsers.mockClear()
|
||||
|
||||
button('Reload member')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(dialog().querySelector('[role="alert"]')).toBeNull()
|
||||
expect(field().value).toBe(JONAS_EMAIL)
|
||||
expect(button('Transfer ownership')!.disabled).toBe(false)
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('transfers once reloaded', async () => {
|
||||
await staleAfterSubmit()
|
||||
button('Reload member')!.click()
|
||||
await flushPromises()
|
||||
|
||||
button('Transfer ownership')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(transferOwnership).toHaveBeenLastCalledWith(2, JONAS_EMAIL)
|
||||
expect(emitted('transferred')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('goes back to the member list when the target is gone', async () => {
|
||||
transferOwnership.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue(USERS.filter((user) => user.id !== 2))
|
||||
await mountDialog()
|
||||
await submit()
|
||||
|
||||
expect(dialog().textContent).toContain('Choose who becomes the owner')
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads the alert in French', async () => {
|
||||
setLocale('fr')
|
||||
transferOwnership.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue(USERS.map((user) => (user.id === 2 ? JONAS_NOW_WRITER : user)))
|
||||
await mountDialog()
|
||||
radio('Jonas Weber')!.click()
|
||||
await flushPromises()
|
||||
button('Continuer')!.click()
|
||||
await flushPromises()
|
||||
await type(JONAS_EMAIL)
|
||||
button('Transférer la propriété')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(alertText()).toContain('Rôle modifié entre-temps')
|
||||
expect(alertText()).toContain(
|
||||
'Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez.',
|
||||
)
|
||||
expect(button('Recharger le membre')).toBeDefined()
|
||||
})
|
||||
|
||||
describe('when the dialog is gone before the answer arrives', () => {
|
||||
async function unmountedBefore409() {
|
||||
let reject!: (reason: unknown) => void
|
||||
transferOwnership.mockReturnValue(new Promise((_, fail) => (reject = fail)))
|
||||
await mountDialog()
|
||||
await submit()
|
||||
wrapper!.unmount()
|
||||
wrapper = null
|
||||
reject(STALE)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('raises a warning toast that stays, with Reload', async () => {
|
||||
await unmountedBefore409()
|
||||
|
||||
const toast = firstToast()
|
||||
expect(toast.color).toBe('warning')
|
||||
expect(toast.duration).toBe(0)
|
||||
expect(toast.title).toBe('Ownership not transferred')
|
||||
expect(toast.description).toBe(
|
||||
"Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again.",
|
||||
)
|
||||
expect(toast.actions[0].label).toBe('Reload')
|
||||
})
|
||||
|
||||
it('refetches the users and dismisses the toast on Reload', async () => {
|
||||
await unmountedBefore409()
|
||||
const toast = firstToast()
|
||||
|
||||
toast.actions[0].onClick()
|
||||
|
||||
expect(listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(toastRemove).toHaveBeenCalledWith(toast.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -230,3 +230,71 @@ describe('useRelationSearch load more', () => {
|
||||
expect(searchEntries.mock.calls.map((call) => call[1].page)).toEqual([1, 1, 1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRelationSearch loading more', () => {
|
||||
it('flags loadingMore for a page above 1 only and keeps hasMore while it loads', async () => {
|
||||
const pending = deferred()
|
||||
const search = setup()
|
||||
|
||||
search.search('rue')
|
||||
await debounce()
|
||||
expect(search.loadingMore.value).toBe(false)
|
||||
pending[0]!.resolve(answer([1, 2], 5))
|
||||
await flushPromises()
|
||||
|
||||
search.more()
|
||||
expect(search.loadingMore.value).toBe(true)
|
||||
expect(search.hasMore.value).toBe(true)
|
||||
pending[1]!.resolve(answer([3, 4], 5, 2))
|
||||
await flushPromises()
|
||||
|
||||
expect(search.loadingMore.value).toBe(false)
|
||||
expect(search.entries.value.map((item) => item.id)).toEqual([1, 2, 3, 4])
|
||||
expect(search.total.value).toBe(5)
|
||||
expect(search.hasMore.value).toBe(true)
|
||||
})
|
||||
|
||||
it('sends no second request while the next page is loading', async () => {
|
||||
const pending = deferred()
|
||||
const search = setup()
|
||||
|
||||
search.reload()
|
||||
pending[0]!.resolve(answer([1], 3))
|
||||
await flushPromises()
|
||||
search.more()
|
||||
search.more()
|
||||
|
||||
expect(searchEntries).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores a pending page after a new search', async () => {
|
||||
const pending = deferred()
|
||||
const search = setup()
|
||||
|
||||
search.reload()
|
||||
pending[0]!.resolve(answer([1], 3))
|
||||
await flushPromises()
|
||||
search.more()
|
||||
search.search('port')
|
||||
await debounce()
|
||||
pending[1]!.resolve(answer([9], 3, 2))
|
||||
pending[2]!.resolve(answer([5], 1))
|
||||
await flushPromises()
|
||||
|
||||
expect(search.entries.value.map((item) => item.id)).toEqual([5])
|
||||
expect(search.loadingMore.value).toBe(false)
|
||||
expect(search.total.value).toBe(1)
|
||||
})
|
||||
|
||||
it('offers Load more again after a failed page and keeps the results', async () => {
|
||||
searchEntries.mockResolvedValueOnce(answer([1], 5)).mockRejectedValueOnce(new ApiError(500))
|
||||
const search = setup()
|
||||
|
||||
await search.reload()
|
||||
await search.more()
|
||||
|
||||
expect(search.loadingMore.value).toBe(false)
|
||||
expect(search.entries.value.map((item) => item.id)).toEqual([1])
|
||||
expect(search.hasMore.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,8 @@ export function useContentAccess(typeKey: string, noun: ContentNoun) {
|
||||
const permissions = usePermissionsStore()
|
||||
|
||||
const canCreate = computed(() => permissions.can('content.create', { typeKey }))
|
||||
const readScope = computed(() => permissions.scopeOf('content.read', { typeKey }))
|
||||
const readsOwnOnly = computed(() => readScope.value === 'own')
|
||||
const updateScope = computed(() => permissions.scopeOf('content.update', { typeKey }))
|
||||
const deleteScope = computed(() => permissions.scopeOf('content.delete', { typeKey }))
|
||||
const publishScope = computed(() => permissions.scopeOf('content.publish', { typeKey }))
|
||||
@@ -58,6 +60,8 @@ export function useContentAccess(typeKey: string, noun: ContentNoun) {
|
||||
|
||||
return {
|
||||
canCreate,
|
||||
readScope,
|
||||
readsOwnOnly,
|
||||
updateScope,
|
||||
deleteScope,
|
||||
publishScope,
|
||||
|
||||
@@ -23,11 +23,16 @@ export function useRelationSearch(target: () => string) {
|
||||
const page = ref(0)
|
||||
const status = ref<RelationSearchStatus>('idle')
|
||||
const loadingMore = ref(false)
|
||||
const moreFailed = ref(false)
|
||||
const query = ref('')
|
||||
let token = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const hasMore = computed(() => status.value === 'ready' && entries.value.length < total.value)
|
||||
const hasMore = computed(
|
||||
() =>
|
||||
entries.value.length < total.value &&
|
||||
(status.value === 'ready' || loadingMore.value || moreFailed.value),
|
||||
)
|
||||
const searching = computed(() => status.value === 'loading' && !loadingMore.value)
|
||||
const showSkeleton = computed(() => searching.value && skeleton.visible.value)
|
||||
|
||||
@@ -52,6 +57,7 @@ export function useRelationSearch(target: () => string) {
|
||||
async function load(pageNumber: number) {
|
||||
const current = ++token
|
||||
loadingMore.value = pageNumber > 1
|
||||
moreFailed.value = false
|
||||
status.value = 'loading'
|
||||
skeleton.start()
|
||||
try {
|
||||
@@ -73,6 +79,8 @@ export function useRelationSearch(target: () => string) {
|
||||
page.value = 0
|
||||
}
|
||||
fail(error)
|
||||
moreFailed.value =
|
||||
pageNumber > 1 && (status.value as RelationSearchStatus) === 'failed'
|
||||
}
|
||||
} finally {
|
||||
if (current === token) {
|
||||
@@ -85,6 +93,7 @@ export function useRelationSearch(target: () => string) {
|
||||
function search(text: string) {
|
||||
clearTimer()
|
||||
query.value = text.trim()
|
||||
moreFailed.value = false
|
||||
token++
|
||||
skeleton.stop()
|
||||
loadingMore.value = false
|
||||
@@ -100,7 +109,7 @@ export function useRelationSearch(target: () => string) {
|
||||
}
|
||||
|
||||
function more() {
|
||||
if (hasMore.value) {
|
||||
if (hasMore.value && !loadingMore.value) {
|
||||
return load(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@ 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,
|
||||
reapplyEdits,
|
||||
resetRow,
|
||||
scopeAt,
|
||||
setScope,
|
||||
unsavedCount,
|
||||
activePreset,
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
type RoleForm,
|
||||
} from '@/lib/roleForm'
|
||||
import { buildRolePreview } from '@/lib/rolePreview'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import { roleDiffRows, type RoleDiffContext } from '@/lib/roleDiff'
|
||||
import {
|
||||
OWNER_ONLY_FALLBACK,
|
||||
formatClock,
|
||||
@@ -46,6 +48,7 @@ export interface ConflictState {
|
||||
}
|
||||
|
||||
const NO_CONFLICT = null
|
||||
const INFO_TOAST_MS = 6000
|
||||
|
||||
export function useRoleEditor() {
|
||||
const route = useRoute()
|
||||
@@ -68,7 +71,9 @@ export function useRoleEditor() {
|
||||
const serverLocked = ref(false)
|
||||
const serverOwnRole = ref(false)
|
||||
const refusal = ref<RefusalCopy | null>(null)
|
||||
const saveFailed = ref(false)
|
||||
const reviewing = ref(false)
|
||||
const keeping = ref(false)
|
||||
let loadedKey = ''
|
||||
let loadToken = 0
|
||||
|
||||
@@ -152,6 +157,12 @@ export function useRoleEditor() {
|
||||
return [...named, ...typed]
|
||||
})
|
||||
|
||||
const readsOwn = computed(() =>
|
||||
gridTypes.value.some(
|
||||
(type) => scopeAt(form.value.grants, contentKey('read'), type.key) === 'own',
|
||||
),
|
||||
)
|
||||
|
||||
const preview = computed(() =>
|
||||
buildRolePreview({
|
||||
grants: form.value.grants,
|
||||
@@ -161,21 +172,32 @@ export function useRoleEditor() {
|
||||
}),
|
||||
)
|
||||
|
||||
const diffContext = computed<RoleDiffContext>(() => {
|
||||
const items = catalogue.value.flatMap((group) => group.items)
|
||||
return {
|
||||
types: gridTypes.value.map((type) => ({ key: type.key, name: type.name })),
|
||||
toggles: new Set(
|
||||
items.filter((item) => item.scope_kind === 'toggle').map((item) => item.key),
|
||||
),
|
||||
order: items.map((item) => item.key),
|
||||
}
|
||||
})
|
||||
|
||||
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),
|
||||
}))
|
||||
return roleDiffRows(formFromRole(latest), form.value, diffContext.value)
|
||||
})
|
||||
|
||||
const conflictSummary = computed(() => {
|
||||
const latest = conflict.value?.latest
|
||||
return t('{0} saved changes to {1} at {2}.', [
|
||||
latest?.updated_by?.name ?? t('Someone'),
|
||||
latest?.name ?? form.value.name,
|
||||
formatClock(latest?.updated_at, locale.value),
|
||||
])
|
||||
})
|
||||
|
||||
function adopt(next: RoleItem) {
|
||||
@@ -186,6 +208,7 @@ export function useRoleEditor() {
|
||||
conflict.value = NO_CONFLICT
|
||||
reviewing.value = false
|
||||
refusal.value = null
|
||||
saveFailed.value = false
|
||||
}
|
||||
|
||||
function routeKey() {
|
||||
@@ -357,13 +380,7 @@ export function useRoleEditor() {
|
||||
}
|
||||
|
||||
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.'),
|
||||
}))
|
||||
saveFailed.value = true
|
||||
}
|
||||
|
||||
function memberCountCopy(saved: RoleItem) {
|
||||
@@ -431,6 +448,7 @@ export function useRoleEditor() {
|
||||
}
|
||||
nameError.value = ''
|
||||
refusal.value = null
|
||||
saveFailed.value = false
|
||||
if (nameMissing.value) {
|
||||
nameError.value = t('Give the role a name.')
|
||||
return
|
||||
@@ -501,8 +519,53 @@ export function useRoleEditor() {
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleReview() {
|
||||
reviewing.value = !reviewing.value
|
||||
function openReview() {
|
||||
reviewing.value = true
|
||||
}
|
||||
|
||||
function closeReview() {
|
||||
reviewing.value = false
|
||||
}
|
||||
|
||||
function keptEditsCopy(count: number) {
|
||||
return count === 1
|
||||
? t('Your 1 change is still here. Review it, then save.')
|
||||
: t('Your {0} changes are still here. Review them, then save.', [count])
|
||||
}
|
||||
|
||||
async function keepEdits() {
|
||||
const target = role.value
|
||||
if (!target || keeping.value) {
|
||||
return
|
||||
}
|
||||
keeping.value = true
|
||||
try {
|
||||
const latest = await api.getRole(target.id)
|
||||
const merged = reapplyEdits(formFromRole(latest), initial.value, form.value)
|
||||
role.value = latest
|
||||
initial.value = formFromRole(latest)
|
||||
form.value = merged
|
||||
conflict.value = NO_CONFLICT
|
||||
reviewing.value = false
|
||||
const count = unsavedCount(form.value, initial.value)
|
||||
if (count > 0) {
|
||||
toast.add(() => ({
|
||||
id: `role-reloaded-${latest.id}`,
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
duration: INFO_TOAST_MS,
|
||||
title: t('Role reloaded'),
|
||||
description: keptEditsCopy(count),
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'role_not_found') {
|
||||
reviewing.value = false
|
||||
loadState.value = 'missing'
|
||||
}
|
||||
} finally {
|
||||
keeping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function duplicate() {
|
||||
@@ -526,7 +589,9 @@ export function useRoleEditor() {
|
||||
conflict,
|
||||
lowered,
|
||||
refusal,
|
||||
saveFailed,
|
||||
reviewing,
|
||||
keeping,
|
||||
readOnlyReason,
|
||||
readOnly,
|
||||
unsaved,
|
||||
@@ -538,7 +603,9 @@ export function useRoleEditor() {
|
||||
canReset,
|
||||
gridTypes,
|
||||
preview,
|
||||
readsOwn,
|
||||
differenceRows,
|
||||
conflictSummary,
|
||||
load,
|
||||
changeCell,
|
||||
changeGeneral,
|
||||
@@ -551,7 +618,9 @@ export function useRoleEditor() {
|
||||
reloadRole,
|
||||
reloadPage,
|
||||
resetRole,
|
||||
toggleReview,
|
||||
openReview,
|
||||
closeReview,
|
||||
keepEdits,
|
||||
duplicate,
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,12 @@ export function useUsers() {
|
||||
})
|
||||
}
|
||||
|
||||
function removeUser(id: number, expectedRoleId: number): Promise<void> {
|
||||
return request<void>(`/v1/user/${id}?expected_role_id=${expectedRoleId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
function cancelInvite(id: number): Promise<void> {
|
||||
return request<void>(`/v1/user/invite/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -85,6 +91,7 @@ export function useUsers() {
|
||||
inviteUser,
|
||||
changeUserRole,
|
||||
transferOwnership,
|
||||
removeUser,
|
||||
cancelInvite,
|
||||
renewInvite,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ERROR_ROWS,
|
||||
LOGIN_FAMILY_CAPTION,
|
||||
NO_CONTENT_RESPONSE,
|
||||
OWNER_OR_ADMIN_MESSAGE,
|
||||
OWNER_REQUIRED_MESSAGE,
|
||||
PUBLIC_AUTH_ROWS,
|
||||
RATE_LIMIT_SENTENCE,
|
||||
@@ -316,7 +315,7 @@ describe('Catalogue completeness', () => {
|
||||
const examples = endpointExamples(endpoint, ORIGIN)
|
||||
expect(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).toContain(endpoint.method)
|
||||
expect(endpoint.path.startsWith('/v1/')).toBe(true)
|
||||
expect(['Public', 'Verified user', 'Owner or admin', 'Owner only']).toContain(
|
||||
expect(['Public', 'Verified user', 'Needs permission', 'Owner only']).toContain(
|
||||
endpoint.access,
|
||||
)
|
||||
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
|
||||
@@ -407,32 +406,90 @@ describe('SEO output routes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Owner or admin purge routes', () => {
|
||||
it('gives Owner or admin to the content type writes, the built-in types and the four purge routes', () => {
|
||||
const GATED_ROUTES: [string, string][] = [
|
||||
['POST /v1/content-type/', 'Create and edit content types'],
|
||||
['PUT /v1/content-type/{key}', 'Create and edit content types'],
|
||||
['DELETE /v1/content-type/{key}', 'Delete content types'],
|
||||
['GET /v1/builtin-type/', 'View content types'],
|
||||
['GET /v1/builtin-type/{key}', 'View content types'],
|
||||
['GET /v1/media/purge-settings', 'View settings'],
|
||||
['PATCH /v1/media/purge-settings', 'Edit general settings'],
|
||||
['GET /v1/media/purge-preview', 'Delete files'],
|
||||
['POST /v1/media/purge', 'Delete files'],
|
||||
]
|
||||
|
||||
describe('Needs permission routes', () => {
|
||||
it('gives Needs permission to the nine gated routes and no route keeps Owner or admin', () => {
|
||||
expect(
|
||||
allEndpoints()
|
||||
.filter((endpoint) => endpoint.access === 'Owner or admin')
|
||||
.filter((endpoint) => endpoint.access === 'Needs permission')
|
||||
.map(label),
|
||||
).toEqual([
|
||||
'POST /v1/content-type/',
|
||||
'PUT /v1/content-type/{key}',
|
||||
'DELETE /v1/content-type/{key}',
|
||||
'GET /v1/builtin-type/',
|
||||
'GET /v1/builtin-type/{key}',
|
||||
'GET /v1/media/purge-settings',
|
||||
'PATCH /v1/media/purge-settings',
|
||||
'GET /v1/media/purge-preview',
|
||||
'POST /v1/media/purge',
|
||||
])
|
||||
).toEqual(GATED_ROUTES.map(([route]) => route))
|
||||
expect(allEndpoints().map((endpoint) => endpoint.access as string)).not.toContain(
|
||||
'Owner or admin',
|
||||
)
|
||||
})
|
||||
|
||||
it('lists the owner or admin 403 on each purge route', () => {
|
||||
for (const endpoint of allEndpoints().filter((item) => item.access === 'Owner or admin')) {
|
||||
expect(endpoint.errors).toContainEqual({
|
||||
status: 403,
|
||||
caption: 'missing_permission',
|
||||
})
|
||||
}
|
||||
it.each(GATED_ROUTES)(
|
||||
'%s ends its description with the permission sentence',
|
||||
(route, permission) => {
|
||||
const endpoint = find(route)
|
||||
|
||||
expect(endpoint.description.endsWith(` Needs the permission "${permission}".`)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(endpoint.access).not.toBe('Owner only')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(GATED_ROUTES)('%s lists the missing_permission 403 in code style', (route) => {
|
||||
expect(find(route).errors).toContainEqual({
|
||||
status: 403,
|
||||
caption: 'missing_permission',
|
||||
literal: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Owner only on the owner-only routes', () => {
|
||||
expect(
|
||||
allEndpoints().filter((endpoint) => endpoint.access === 'Owner only').length,
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('never says only owners and admins can do this', () => {
|
||||
const everything = JSON.stringify([API_GROUPS, ERROR_ROWS])
|
||||
|
||||
expect(everything).not.toContain('Only owners and admins')
|
||||
expect(everything).not.toContain('Only the owner can do this')
|
||||
expect(everything).not.toContain('Owner or admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: section copy', () => {
|
||||
function groupNamed(title: string) {
|
||||
return API_GROUPS.find((group) => group.title === title)!
|
||||
}
|
||||
|
||||
it('words the Content types and Entries intros around permissions', () => {
|
||||
expect(groupNamed('Content types').description).toBe(
|
||||
'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
|
||||
)
|
||||
expect(groupNamed('Entries').description).toBe(
|
||||
"Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role.",
|
||||
)
|
||||
})
|
||||
|
||||
it('lists missing_permission as a code on the 403 row with the literal-body note', () => {
|
||||
const row = ERROR_ROWS.find((item) => item.status === 403)!
|
||||
|
||||
expect(row.messages).toEqual([
|
||||
SESSION_REQUIRED_MESSAGE,
|
||||
'You must verify your account to access this resource.',
|
||||
])
|
||||
expect(row.codes).toEqual(['missing_permission'])
|
||||
expect(row.note).toBe(
|
||||
'`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -852,12 +909,8 @@ describe('Errors table', () => {
|
||||
[401, ['You must be logged in to access this resource.'], []],
|
||||
[
|
||||
403,
|
||||
[
|
||||
SESSION_REQUIRED_MESSAGE,
|
||||
OWNER_OR_ADMIN_MESSAGE,
|
||||
'You must verify your account to access this resource.',
|
||||
],
|
||||
[],
|
||||
[SESSION_REQUIRED_MESSAGE, 'You must verify your account to access this resource.'],
|
||||
['missing_permission'],
|
||||
],
|
||||
[
|
||||
404,
|
||||
@@ -993,12 +1046,17 @@ describe('Custom content types round', () => {
|
||||
find(route)
|
||||
.errors.filter((error) => error.literal)
|
||||
.map((error) => `${error.status} ${error.caption}`)
|
||||
expect(literalErrors('POST /v1/content-type/')).toEqual(['409 content_type_key_taken'])
|
||||
expect(literalErrors('POST /v1/content-type/')).toEqual([
|
||||
'403 missing_permission',
|
||||
'409 content_type_key_taken',
|
||||
])
|
||||
expect(literalErrors('PUT /v1/content-type/{key}')).toEqual([
|
||||
'403 missing_permission',
|
||||
'404 content_type_not_found',
|
||||
'409 schema_incompatible',
|
||||
])
|
||||
expect(literalErrors('DELETE /v1/content-type/{key}')).toEqual([
|
||||
'403 missing_permission',
|
||||
'404 content_type_not_found',
|
||||
'409 content_type_has_entries',
|
||||
'409 content_type_referenced',
|
||||
@@ -1008,7 +1066,10 @@ describe('Custom content types round', () => {
|
||||
'409 invalid_entry: title must be unique',
|
||||
'422 invalid_entry: capacity must be at most 5000',
|
||||
])
|
||||
expect(literalErrors('GET /v1/builtin-type/{key}')).toEqual(['404 builtin_type_not_found'])
|
||||
expect(literalErrors('GET /v1/builtin-type/{key}')).toEqual([
|
||||
'403 missing_permission',
|
||||
'404 builtin_type_not_found',
|
||||
])
|
||||
})
|
||||
|
||||
it('takes the entry data and indexes the events keys as body fields', () => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import { allEndpoints } from '../apiCatalogue'
|
||||
|
||||
const GATING: [string, string, string][] = [
|
||||
['POST /v1/content-type/', 'schema.edit', 'Create and edit content types'],
|
||||
['PUT /v1/content-type/{key}', 'schema.edit', 'Create and edit content types'],
|
||||
['DELETE /v1/content-type/{key}', 'schema.delete', 'Delete content types'],
|
||||
['GET /v1/builtin-type/', 'schema.view', 'View content types'],
|
||||
['GET /v1/builtin-type/{key}', 'schema.view', 'View content types'],
|
||||
['GET /v1/media/purge-settings', 'settings.view', 'View settings'],
|
||||
['PATCH /v1/media/purge-settings', 'settings.edit_general', 'Edit general settings'],
|
||||
['GET /v1/media/purge-preview', 'media.delete', 'Delete files'],
|
||||
['POST /v1/media/purge', 'media.delete', 'Delete files'],
|
||||
]
|
||||
|
||||
function endpointFor(route: string) {
|
||||
return allEndpoints().find((endpoint) => `${endpoint.method} ${endpoint.path}` === route)!
|
||||
}
|
||||
|
||||
describe('API gating of the permission-gated routes', () => {
|
||||
it.each(GATING)('%s needs %s, read as "%s"', (route, key, label) => {
|
||||
expect(permissionName(key)).toBe(label)
|
||||
expect(endpointFor(route).description.endsWith(`Needs the permission "${label}".`)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(endpointFor(route).access).toBe('Needs permission')
|
||||
})
|
||||
|
||||
it('covers exactly the routes the catalogue marks as Needs permission', () => {
|
||||
expect(
|
||||
allEndpoints()
|
||||
.filter((endpoint) => endpoint.access === 'Needs permission')
|
||||
.map((endpoint) => `${endpoint.method} ${endpoint.path}`),
|
||||
).toEqual(GATING.map(([route]) => route))
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
export type AccessLevel = 'Public' | 'Verified user' | 'Owner or admin' | 'Owner only'
|
||||
export type AccessLevel = 'Public' | 'Verified user' | 'Needs permission' | 'Owner only'
|
||||
|
||||
export interface ApiField {
|
||||
name: string
|
||||
@@ -94,7 +94,7 @@ export const API_KEY_PLACEHOLDER = 'rcms_Kf3x9QwL0aBc…'
|
||||
export const NO_CONTENT_RESPONSE = '204 No Content'
|
||||
export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
|
||||
export const OWNER_REQUIRED_MESSAGE = 'missing_permission'
|
||||
export const OWNER_OR_ADMIN_MESSAGE = 'missing_permission'
|
||||
export const MISSING_PERMISSION_BODY = 'missing_permission'
|
||||
export const IMAGES_AFTER_GROUP = 'sites'
|
||||
export const FIELD_TYPES_ANCHOR = 'ct-fields'
|
||||
export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.'
|
||||
@@ -649,7 +649,7 @@ const literal = (status: number, caption: string, tail?: string): ApiError => ({
|
||||
})
|
||||
|
||||
const e401 = e(401, UNAUTHORIZED)
|
||||
const eOwnerOrAdmin = e(403, OWNER_OR_ADMIN_MESSAGE)
|
||||
const eMissingPermission = literal(403, MISSING_PERMISSION_BODY)
|
||||
const contentTypeNotFound = literal(404, 'content_type_not_found')
|
||||
const entryNotFound = literal(404, 'entry_not_found')
|
||||
const invalidEntryUnique = literal(409, 'invalid_entry: title must be unique')
|
||||
@@ -662,11 +662,7 @@ const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)]
|
||||
const verifiedAfterLogin = (errors: ApiError[]) =>
|
||||
errors.flatMap((error) => (error.status === 401 ? [error, e(403, VERIFY_REQUIRED)] : [error]))
|
||||
|
||||
const ownerOrAdminErrors = (...own: ApiError[]) => [
|
||||
...own,
|
||||
e(401, UNAUTHORIZED),
|
||||
e(403, OWNER_OR_ADMIN_MESSAGE),
|
||||
]
|
||||
const permissionErrors = (...own: ApiError[]) => [...own, e(401, UNAUTHORIZED), eMissingPermission]
|
||||
|
||||
const articlePage = () => ({
|
||||
items: [
|
||||
@@ -1032,7 +1028,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
key: 'ctypes',
|
||||
title: 'Content types',
|
||||
description:
|
||||
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
|
||||
'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
|
||||
note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.',
|
||||
fieldTypes: FIELD_TYPE_ROWS,
|
||||
snippet: { key: 'field', label: 'Field object', code: json(eventFields[2]) },
|
||||
@@ -1059,12 +1055,12 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'ct-create',
|
||||
'POST',
|
||||
'/v1/content-type/',
|
||||
'Create a content type.',
|
||||
'Owner or admin',
|
||||
'Create a content type. Needs the permission "Create and edit content types".',
|
||||
'Needs permission',
|
||||
[
|
||||
e(400, BODY_MISMATCH),
|
||||
e401,
|
||||
eOwnerOrAdmin,
|
||||
eMissingPermission,
|
||||
literal(409, 'content_type_key_taken'),
|
||||
],
|
||||
json(eventType),
|
||||
@@ -1095,12 +1091,12 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'ct-update',
|
||||
'PUT',
|
||||
'/v1/content-type/{key}',
|
||||
"Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can't change.",
|
||||
'Owner or admin',
|
||||
'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change. Needs the permission "Create and edit content types".',
|
||||
'Needs permission',
|
||||
[
|
||||
e(400, BODY_MISMATCH),
|
||||
e401,
|
||||
eOwnerOrAdmin,
|
||||
eMissingPermission,
|
||||
contentTypeNotFound,
|
||||
literal(
|
||||
409,
|
||||
@@ -1128,11 +1124,11 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'ct-delete',
|
||||
'DELETE',
|
||||
'/v1/content-type/{key}',
|
||||
'Delete a content type. Only possible when it has no entries and no relation field points at it.',
|
||||
'Owner or admin',
|
||||
'Delete a content type. Only possible when it has no entries and no relation field points at it. Needs the permission "Delete content types".',
|
||||
'Needs permission',
|
||||
[
|
||||
e401,
|
||||
eOwnerOrAdmin,
|
||||
eMissingPermission,
|
||||
contentTypeNotFound,
|
||||
literal(409, 'content_type_has_entries'),
|
||||
literal(409, 'content_type_referenced'),
|
||||
@@ -1145,7 +1141,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
key: 'entries',
|
||||
title: 'Entries',
|
||||
description:
|
||||
'Read and write the content of a custom type. Any verified user can write entries.',
|
||||
"Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role.",
|
||||
note: "An entry's `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields, keyed by field key: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).",
|
||||
snippet: { key: 'entry', label: 'Entry', code: json(eventEntry()) },
|
||||
endpoints: [
|
||||
@@ -1258,9 +1254,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'bt-list',
|
||||
'GET',
|
||||
'/v1/builtin-type/',
|
||||
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.',
|
||||
'Owner or admin',
|
||||
[e401, eOwnerOrAdmin],
|
||||
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user. Needs the permission "View content types".',
|
||||
'Needs permission',
|
||||
[e401, eMissingPermission],
|
||||
json([
|
||||
{ key: 'article', name: 'Articles', fields: articleBuiltinFields },
|
||||
{ key: 'author', name: 'Authors', fields: authorBuiltinFields },
|
||||
@@ -1270,9 +1266,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'bt-get',
|
||||
'GET',
|
||||
'/v1/builtin-type/{key}',
|
||||
'Get one built-in type. Keys are article, author, site, media and user.',
|
||||
'Owner or admin',
|
||||
[e401, eOwnerOrAdmin, literal(404, 'builtin_type_not_found')],
|
||||
'Get one built-in type. Keys are article, author, site, media and user. Needs the permission "View content types".',
|
||||
'Needs permission',
|
||||
[e401, eMissingPermission, literal(404, 'builtin_type_not_found')],
|
||||
json({ key: 'author', name: 'Authors', fields: authorBuiltinFields }),
|
||||
),
|
||||
],
|
||||
@@ -1431,18 +1427,18 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'med-purge-get',
|
||||
'GET',
|
||||
'/v1/media/purge-settings',
|
||||
'Get the automatic duplicate purge settings.',
|
||||
'Owner or admin',
|
||||
ownerOrAdminErrors(),
|
||||
'Get the automatic duplicate purge settings. Needs the permission "View settings".',
|
||||
'Needs permission',
|
||||
permissionErrors(),
|
||||
json(PURGE_SETTINGS),
|
||||
),
|
||||
endpoint(
|
||||
'med-purge-set',
|
||||
'PATCH',
|
||||
'/v1/media/purge-settings',
|
||||
'Turn the automatic purge on or off, or change its interval.',
|
||||
'Owner or admin',
|
||||
ownerOrAdminErrors(e(400, BODY_MISMATCH), e(400, PURGE_INTERVAL)),
|
||||
'Turn the automatic purge on or off, or change its interval. Needs the permission "Edit general settings".',
|
||||
'Needs permission',
|
||||
permissionErrors(e(400, BODY_MISMATCH), e(400, PURGE_INTERVAL)),
|
||||
json(PURGE_SETTINGS),
|
||||
{
|
||||
bodyKind: 'json',
|
||||
@@ -1470,9 +1466,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'med-purge-prev',
|
||||
'GET',
|
||||
'/v1/media/purge-preview',
|
||||
'Preview what a purge would merge. Nothing changes.',
|
||||
'Owner or admin',
|
||||
ownerOrAdminErrors(),
|
||||
'Preview what a purge would merge. Nothing changes. Needs the permission "Delete files".',
|
||||
'Needs permission',
|
||||
permissionErrors(),
|
||||
json({
|
||||
groups: 1,
|
||||
copies: 2,
|
||||
@@ -1494,13 +1490,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'med-purge-run',
|
||||
'POST',
|
||||
'/v1/media/purge',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged.',
|
||||
'Owner or admin',
|
||||
ownerOrAdminErrors(
|
||||
e(400, BODY_MISMATCH),
|
||||
e(409, PURGE_CONFLICT),
|
||||
e(409, PURGE_BUSY),
|
||||
),
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged. Needs the permission "Delete files".',
|
||||
'Needs permission',
|
||||
permissionErrors(e(400, BODY_MISMATCH), e(409, PURGE_CONFLICT), e(409, PURGE_BUSY)),
|
||||
json({
|
||||
groups_total: 1,
|
||||
groups_purged: 1,
|
||||
@@ -1885,8 +1877,9 @@ export const ERROR_ROWS: ErrorRow[] = [
|
||||
{
|
||||
status: 403,
|
||||
meaning: 'Forbidden',
|
||||
messages: [SESSION_REQUIRED_MESSAGE, OWNER_OR_ADMIN_MESSAGE, VERIFY_REQUIRED],
|
||||
note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
|
||||
messages: [SESSION_REQUIRED_MESSAGE, VERIFY_REQUIRED],
|
||||
codes: [MISSING_PERMISSION_BODY],
|
||||
note: '`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
|
||||
+122
-36
@@ -590,8 +590,6 @@ export const messages = {
|
||||
"There's already a pending invite for this address.":
|
||||
'Une invitation est déjà en attente pour cette adresse.',
|
||||
'This address already has an account.': 'Cette adresse a déjà un compte.',
|
||||
"Couldn't send the invite. Try again in a moment.":
|
||||
'Impossible d’envoyer l’invitation. Réessayez dans un instant.',
|
||||
'Loading users': 'Chargement des utilisateurs',
|
||||
"Couldn't load users": 'Impossible de charger les utilisateurs',
|
||||
Name: 'Nom',
|
||||
@@ -928,7 +926,6 @@ export const messages = {
|
||||
Public: 'Public',
|
||||
'Verified user': 'Utilisateur vérifié',
|
||||
'Owner only': 'Propriétaire uniquement',
|
||||
'Owner or admin': 'Propriétaire ou administrateur',
|
||||
'Settings · owner only': 'Paramètres · propriétaire uniquement',
|
||||
'Activity log': 'Journal d’activité',
|
||||
Authentication: 'Authentification',
|
||||
@@ -958,8 +955,6 @@ export const messages = {
|
||||
'Aucune clé nécessaire. Deux routes en lecture seule, et seuls les articles publiés sont renvoyés.',
|
||||
'API key': 'Clé d’API',
|
||||
'Full access': 'Accès complet',
|
||||
"Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.":
|
||||
'Envoyez `Authorization: Bearer rcms_…`. Les requêtes agissent comme la personne qui a créé la clé, avec son rôle (propriétaire ou administrateur). Une clé n’a jamais plus de droits que son créateur.',
|
||||
'Keys start with `rcms_` and are shown in full once, when you create them.':
|
||||
'Les clés commencent par `rcms_` et ne sont affichées en entier qu’une fois, à leur création.',
|
||||
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Routes that need a permission answer 403 "missing_permission" to a key when the role of its creator does not hold it.':
|
||||
@@ -1657,12 +1652,6 @@ export const messages = {
|
||||
'Delete an image. Articles that use it show a broken image.':
|
||||
'Supprime une image. Les articles qui l’utilisent affichent une image cassée.',
|
||||
'Merge identical copies into one image.': 'Fusionne les copies identiques en une seule image.',
|
||||
'Get the automatic duplicate purge settings.':
|
||||
'Récupère les paramètres de la purge automatique des doublons.',
|
||||
'Turn the automatic purge on or off, or change its interval.':
|
||||
'Active ou désactive la purge automatique, ou modifie son intervalle.',
|
||||
'Preview what a purge would merge. Nothing changes.':
|
||||
'Prévisualise ce qu’une purge fusionnerait. Rien n’est modifié.',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library.':
|
||||
'Fusionne chaque groupe de copies identiques. Envoyez l’empreinte de l’aperçu pour refuser une médiathèque modifiée.',
|
||||
'List users and invites.': 'Liste les utilisateurs et les invitations.',
|
||||
@@ -2244,8 +2233,6 @@ export const messages = {
|
||||
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau de { url, modified_at, noindex }. Le total est dans l’en-tête X-Total-Count.',
|
||||
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.':
|
||||
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par le propriétaire.',
|
||||
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.':
|
||||
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire. Les champs SEO d’un article demandent seulement le droit de modifier l’article.',
|
||||
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).':
|
||||
'Le serveur attend un identifiant au format d’un entier non signé de 32 bits (uint32).',
|
||||
'Article not found': 'Article introuvable',
|
||||
@@ -2742,8 +2729,6 @@ export const messages = {
|
||||
'Status codes and messages': 'Codes d’état et messages',
|
||||
'Images, CORS and ?format=': 'Images, CORS et ?format=',
|
||||
'{0} routes, not available to API keys': '{0} routes, indisponibles avec une clé API',
|
||||
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.':
|
||||
'Définissez votre propre type de contenu, comme des événements ou des recettes. Un propriétaire ou un administrateur crée un type composé de 1 à 50 champs ordonnés ; le type a alors ses propres routes d’entrées.',
|
||||
'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.':
|
||||
'Un type de contenu a une `key` (2 à 40 lettres minuscules, chiffres ou tirets, choisie à la création et jamais modifiée) et des champs. Chaque champ a une `key` (lettres, chiffres et tirets bas, commençant par une lettre), un `label`, un `type` et des règles.',
|
||||
'Field object': 'Objet champ',
|
||||
@@ -2767,21 +2752,14 @@ export const messages = {
|
||||
'identifiants d’entrées d’un autre type de contenu ; `relation: { content_type, kind }`, kind valant one_to_one | one_to_many | many_to_many',
|
||||
'List all custom content types.': 'Liste tous les types de contenu personnalisés.',
|
||||
'Get one content type with its fields.': 'Renvoie un type de contenu avec ses champs.',
|
||||
'Create a content type.': 'Crée un type de contenu.',
|
||||
"2 to 40 characters. Lowercase letters, digits and hyphens. Can't be changed later.":
|
||||
'2 à 40 caractères. Lettres minuscules, chiffres et tirets. Ne peut pas être modifiée ensuite.',
|
||||
'Up to 100 characters.': 'Jusqu’à 100 caractères.',
|
||||
'Optional.': 'Facultatif.',
|
||||
'1 to 50 fields, in order. See Field types.':
|
||||
'1 à 50 champs, dans l’ordre. Voir Types de champ.',
|
||||
"Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can't change.":
|
||||
'Remplace le nom, l’icône et les champs d’un type. La clé ne change jamais. Les clés et types des champs enregistrés ne peuvent pas changer.',
|
||||
"Some existing entries don't fit the new fields. Nothing was changed.":
|
||||
'Certaines entrées existantes ne conviennent pas aux nouveaux champs. Rien n’a été modifié.',
|
||||
'Delete a content type. Only possible when it has no entries and no relation field points at it.':
|
||||
'Supprime un type de contenu. Possible seulement s’il n’a aucune entrée et si aucun champ de relation ne pointe vers lui.',
|
||||
'Read and write the content of a custom type. Any verified user can write entries.':
|
||||
'Lisez et écrivez le contenu d’un type personnalisé. Tout utilisateur vérifié peut écrire des entrées.',
|
||||
"An entry's `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields, keyed by field key: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).":
|
||||
'Les `data` d’une entrée forment un objet indexé par clé de champ, validé selon le type. Les réponses ajoutent `media` (les images résolues des champs média, indexées par clé de champ : id, filename, width, height, size et thumbnail_url, ou { id, missing: true } si l’image a été supprimée) et `relations` (le premier champ résolu des entrées liées).',
|
||||
'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.':
|
||||
@@ -2799,12 +2777,6 @@ export const messages = {
|
||||
'Les champs des articles, auteurs, sites, médias et utilisateurs, dans la même forme que les champs d’un type de contenu.',
|
||||
'Built-in types are read-only: they cannot be changed through the API.':
|
||||
'Les types intégrés sont en lecture seule : ils ne peuvent pas être modifiés par l’API.',
|
||||
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.':
|
||||
'Liste les types intégrés (articles, auteurs, sites, médias, utilisateurs) et leurs champs. Les clés sont article, author, site, media et user.',
|
||||
'Get one built-in type. Keys are article, author, site, media and user.':
|
||||
'Renvoie un type intégré. Les clés sont article, author, site, media et user.',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged.':
|
||||
'Fusionne chaque groupe de copies identiques. Envoyez l’empreinte de l’aperçu pour refuser une bibliothèque modifiée. failed_groups compte les groupes qui n’ont pas pu être purgés ; les autres l’ont été.',
|
||||
'Sites and authors': 'Sites et auteurs',
|
||||
'Read the sites and authors your content belongs to. Creating and changing them needs a session.':
|
||||
'Lisez les sites et les auteurs auxquels votre contenu appartient. Les créer et les modifier demande une session.',
|
||||
@@ -2998,7 +2970,6 @@ export const messages = {
|
||||
'{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.',
|
||||
@@ -3058,7 +3029,9 @@ export const messages = {
|
||||
'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',
|
||||
'read online articles and their own drafts':
|
||||
'lire les articles en ligne et leurs propres brouillons',
|
||||
'read only entries they created': 'lire uniquement les entrées créées par la personne',
|
||||
'create entries': 'créer des entrées',
|
||||
'edit any entry': 'modifier n’importe quelle entrée',
|
||||
'edit only their own': 'modifier uniquement les leurs',
|
||||
@@ -3142,7 +3115,6 @@ export const messages = {
|
||||
"A key can do what its creator's role can do, today. If the person's role changes, their keys change with it.":
|
||||
'Une clé peut faire ce que le rôle de son créateur peut faire, aujourd’hui. Si le rôle de la personne change, ses clés changent avec lui.',
|
||||
'A summary of your role, {0}. Read-only.': 'Un résumé de votre rôle, {0}. Lecture seule.',
|
||||
'Assigned by {0}': 'Attribué par {0}',
|
||||
'Change role': 'Changer le rôle',
|
||||
'Change role for {0}': 'Changer le rôle de {0}',
|
||||
'Changed the role of {0}': 'A changé le rôle de {0}',
|
||||
@@ -3150,8 +3122,6 @@ export const messages = {
|
||||
'Choose who becomes the owner. Only members can be chosen.':
|
||||
'Choisissez qui devient propriétaire. Seuls les membres peuvent être choisis.',
|
||||
Continue: 'Continuer',
|
||||
"Couldn't change the role. Try again in a moment.":
|
||||
'Impossible de changer le rôle. Réessayez dans un instant.',
|
||||
"Couldn't transfer ownership. Try again in a moment.":
|
||||
'Impossible de transférer la propriété. Réessayez dans un instant.',
|
||||
'Failed to change the role of {0}': 'Échec du changement de rôle de {0}',
|
||||
@@ -3165,7 +3135,6 @@ export const messages = {
|
||||
'Moved the members of the role {0}': 'A déplacé les membres du rôle {0}',
|
||||
'Only the owner can apply updates, delete a site and change activity retention.':
|
||||
'Seul le propriétaire peut appliquer les mises à jour, supprimer un site et modifier la conservation de l’activité.',
|
||||
'Ownership changed meanwhile': 'La propriété a changé entre-temps',
|
||||
'Ownership transferred': 'Propriété transférée',
|
||||
'Ownership transferred from {0} to {1}': 'Propriété transférée de {0} à {1}',
|
||||
'Role changed': 'Rôle modifié',
|
||||
@@ -3185,7 +3154,6 @@ export const messages = {
|
||||
"The owner can't be changed or removed. Transfer ownership first.":
|
||||
'Le propriétaire ne peut être ni modifié ni retiré. Transférez d’abord la propriété.',
|
||||
'Their API keys follow the new role.': 'Ses clés d’API suivent le nouveau rôle.',
|
||||
'Their role changed meanwhile': 'Son rôle a changé entre-temps',
|
||||
'There is one owner. Transfer ownership to change who it is.':
|
||||
'Il n’y a qu’un propriétaire. Transférez la propriété pour changer de propriétaire.',
|
||||
'They get an email with a link to set a password.':
|
||||
@@ -3211,9 +3179,127 @@ export const messages = {
|
||||
'{0} becomes the owner.': '{0} devient propriétaire.',
|
||||
'{0} is now the owner. You are an Admin.': '{0} est maintenant propriétaire. Vous êtes Admin.',
|
||||
'{0} members have this role.': '{0} membres ont ce rôle.',
|
||||
'{0} now has the {1} role.': '{0} a maintenant le rôle {1}.',
|
||||
'{0}: {1} → {2}': '{0} : {1} → {2}',
|
||||
"{0}'s role changed from {1} to {2}": 'Le rôle de {0} est passé de {1} à {2}',
|
||||
'{0} is now {1}, and their API keys follow the new role.':
|
||||
'{0} a maintenant le rôle {1}, et ses clés API suivent le nouveau rôle.',
|
||||
"Couldn't save the role": "Impossible d'enregistrer le rôle",
|
||||
'Your changes are still here. Try again in a moment.':
|
||||
'Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
"Couldn't change the role": 'Impossible de modifier le rôle',
|
||||
'{0} no longer exists': "{0} n'existe plus",
|
||||
'It was deleted somewhere else. Choose another role to move the members to.':
|
||||
'Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.',
|
||||
'Role still has members': 'Le rôle a encore des membres',
|
||||
'{0} now has 1 member. Move them to another role first.':
|
||||
"{0} a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle.",
|
||||
'{0} now has {1} members. Move them to another role first.':
|
||||
"{0} a maintenant {1} membres. Déplacez-les d'abord vers un autre rôle.",
|
||||
"Couldn't move the members": 'Impossible de déplacer les membres',
|
||||
'Enter a valid email address.': 'Saisissez une adresse e-mail valide.',
|
||||
"Couldn't send the invite": "Impossible d'envoyer l'invitation",
|
||||
'Moved {0} members from {1} to {2}': '{0} membres déplacés de {1} vers {2}',
|
||||
'Moved 1 member from {0} to {1}': '1 membre déplacé de {0} vers {1}',
|
||||
'Moved {0} members from {1} to another role': '{0} membres déplacés de {1} vers un autre rôle',
|
||||
'Moved 1 member from {0} to another role': '1 membre déplacé de {0} vers un autre rôle',
|
||||
'Failed to move {0} members from {1} to {2}':
|
||||
'Échec du déplacement de {0} membres de {1} vers {2}',
|
||||
'Failed to move 1 member from {0} to {1}': 'Échec du déplacement de 1 membre de {0} vers {1}',
|
||||
'Failed to move {0} members from {1} to another role':
|
||||
'Échec du déplacement de {0} membres de {1} vers un autre rôle',
|
||||
'Failed to move 1 member from {0} to another role':
|
||||
'Échec du déplacement de 1 membre de {0} vers un autre rôle',
|
||||
'Removed {0}': '{0} retiré',
|
||||
'Failed to remove {0}': 'Échec du retrait de {0}',
|
||||
'Move 1 member and delete role': 'Déplacer 1 membre et supprimer le rôle',
|
||||
'Showing {0} of {1}': 'Affichage de {0} sur {1}',
|
||||
Reload: 'Recharger',
|
||||
'Role changed elsewhere': 'Rôle modifié ailleurs',
|
||||
'{0} edited {1}, so nothing was saved. Reload and try again.':
|
||||
"{0} a modifié {1}, donc rien n'a été enregistré. Rechargez et réessayez.",
|
||||
'That role no longer exists': "Ce rôle n'existe plus",
|
||||
'Role not changed': 'Rôle non modifié',
|
||||
"{0}'s role changed in the meantime, so nothing was saved. Reload and try again.":
|
||||
"Le rôle de {0} a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez.",
|
||||
'Ownership not transferred': 'Propriété non transférée',
|
||||
"{0}'s role changed in the meantime, so nothing was transferred. Check it and try again.":
|
||||
"Le rôle de {0} a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez.",
|
||||
'User not removed': 'Utilisateur non retiré',
|
||||
"{0}'s role changed in the meantime, so nothing was removed. Check it and try again.":
|
||||
"Le rôle de {0} a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez.",
|
||||
'How the saved role differs from your edits':
|
||||
'Comment le rôle enregistré diffère de vos modifications',
|
||||
Setting: 'Réglage',
|
||||
Yours: 'Vous',
|
||||
'Use the saved role': 'Utiliser le rôle enregistré',
|
||||
'Keep my edits': 'Garder mes modifications',
|
||||
'Role reloaded': 'Rôle rechargé',
|
||||
'Your {0} changes are still here. Review them, then save.':
|
||||
'Vos {0} modifications sont toujours là. Relisez-les, puis enregistrez.',
|
||||
'Read: Own': 'Lecture : Les siennes',
|
||||
"Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule.":
|
||||
"Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle.",
|
||||
'Role changed in the meantime': 'Rôle modifié entre-temps',
|
||||
'{0} is now {1}. Your choice is kept. Check it and try again.':
|
||||
'{0} a maintenant le rôle {1}. Votre choix est conservé. Vérifiez-le et réessayez.',
|
||||
'{0} is now {1}. What you typed is kept. Check the change, then try again.':
|
||||
'{0} a maintenant le rôle {1}. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez.',
|
||||
'Reload member': 'Recharger le membre',
|
||||
'Create a content type. Needs the permission "Create and edit content types".':
|
||||
'Crée un type de contenu. Nécessite la permission « Créer et modifier des types de contenu ».',
|
||||
'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change. Needs the permission "Create and edit content types".':
|
||||
'Remplace le nom, l’icône et les champs d’un type. La clé ne change jamais. Les clés et types des champs enregistrés ne peuvent pas changer. Nécessite la permission « Créer et modifier des types de contenu ».',
|
||||
'Delete a content type. Only possible when it has no entries and no relation field points at it. Needs the permission "Delete content types".':
|
||||
'Supprime un type de contenu. Possible seulement s’il n’a aucune entrée et si aucun champ de relation ne pointe vers lui. Nécessite la permission « Supprimer des types de contenu ».',
|
||||
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user. Needs the permission "View content types".':
|
||||
'Liste les types intégrés (articles, auteurs, sites, médias, utilisateurs) et leurs champs. Les clés sont article, author, site, media et user. Nécessite la permission « Voir les types de contenu ».',
|
||||
'Get one built-in type. Keys are article, author, site, media and user. Needs the permission "View content types".':
|
||||
'Renvoie un type intégré. Les clés sont article, author, site, media et user. Nécessite la permission « Voir les types de contenu ».',
|
||||
'Get the automatic duplicate purge settings. Needs the permission "View settings".':
|
||||
'Récupère les paramètres de la purge automatique des doublons. Nécessite la permission « Voir les paramètres ».',
|
||||
'Turn the automatic purge on or off, or change its interval. Needs the permission "Edit general settings".':
|
||||
'Active ou désactive la purge automatique, ou modifie son intervalle. Nécessite la permission « Modifier les paramètres généraux ».',
|
||||
'Preview what a purge would merge. Nothing changes. Needs the permission "Delete files".':
|
||||
'Prévisualise ce qu’une purge fusionnerait. Rien n’est modifié. Nécessite la permission « Supprimer des fichiers ».',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged. Needs the permission "Delete files".':
|
||||
'Fusionne chaque groupe de copies identiques. Envoyez l’empreinte de l’aperçu pour refuser une bibliothèque modifiée. failed_groups compte les groupes qui n’ont pas pu être purgés ; les autres l’ont été. Nécessite la permission « Supprimer des fichiers ».',
|
||||
'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.':
|
||||
'Définissez votre propre type de contenu, comme des événements ou des recettes. Une personne dont le rôle peut créer et modifier des types de contenu définit un type composé de 1 à 50 champs ordonnés ; le type a alors ses propres routes d’entrées.',
|
||||
"Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role.":
|
||||
'Lisez et écrivez le contenu d’un type personnalisé. Chaque route exige l’autorisation de contenu correspondante du rôle du créateur de la clé.',
|
||||
'Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user\'s role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator\'s role lacks it.':
|
||||
'Envoyez `Authorization: Bearer rcms_…`. Les requêtes agissent comme la personne qui a créé la clé, avec son rôle tel qu’il est actuellement. Une clé n’a jamais plus de droits que son créateur. Une route qui exige une permission répond 403 « missing_permission » à une clé dont le rôle du créateur ne la possède pas.',
|
||||
'`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.':
|
||||
'`missing_permission` est le corps littéral de la réponse, pas une phrase. Les valeurs SEO par défaut d’un site demandent la permission de gérer les sites. Les champs SEO d’un article demandent seulement le droit de modifier l’article.',
|
||||
'Needs permission': 'Autorisation requise',
|
||||
'{0} saved changes to {1} at {2}.': '{0} a enregistré des modifications sur {1} à {2}.',
|
||||
'Your 1 change is still here. Review it, then save.':
|
||||
'Votre 1 modification est toujours là. Relisez-la, puis enregistrez.',
|
||||
'Remove user': 'Retirer l’utilisateur',
|
||||
"You can't remove yourself. Ask another owner or admin.":
|
||||
'Vous ne pouvez pas vous retirer vous-même. Demandez à un autre propriétaire ou administrateur.',
|
||||
'Remove {0}?': 'Retirer {0} ?',
|
||||
'They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay.':
|
||||
'Cette personne perd l’accès immédiatement, et les clés API qu’elle a créées cessent de fonctionner. Ses articles, entrées et médias restent.',
|
||||
"Couldn't remove the user": 'Impossible de retirer l’utilisateur',
|
||||
'User removed': 'Utilisateur retiré',
|
||||
'{0} no longer has access.': '{0} n’a plus accès.',
|
||||
'Online articles and the drafts you created.':
|
||||
'Les articles en ligne et les brouillons que vous avez créés.',
|
||||
'Drafts by others are hidden': 'Les brouillons des autres sont masqués',
|
||||
'Your role reads online articles and the drafts you created. The counts follow the same rule.':
|
||||
'Votre rôle lit les articles en ligne et les brouillons que vous avez créés. Les compteurs suivent la même règle.',
|
||||
'You have no drafts': 'Vous n’avez aucun brouillon',
|
||||
"Drafts you create appear here. Your role can't see drafts by other people.":
|
||||
'Les brouillons que vous créez apparaissent ici. Votre rôle ne peut pas voir les brouillons des autres personnes.',
|
||||
'Your role shows only entries you created.':
|
||||
'Votre rôle n’affiche que les entrées que vous avez créées.',
|
||||
"Entries by others aren't shown": 'Les entrées des autres ne sont pas affichées',
|
||||
'Your role reads only entries you created. Ask an owner or admin if you need to see more.':
|
||||
'Votre rôle ne lit que les entrées que vous avez créées. Demandez à un propriétaire ou administrateur si vous avez besoin d’en voir plus.',
|
||||
"You haven't created {0} yet": 'Vous n’avez pas encore créé d’entrée dans {0}',
|
||||
"Your role shows only entries you created, so other people's {0} aren't listed here. Use New entry to add one.":
|
||||
'Votre rôle n’affiche que les entrées que vous avez créées, donc les entrées des autres personnes dans {0} ne sont pas listées ici. Utilisez Nouvelle entrée pour en ajouter une.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { messages } from './messages'
|
||||
|
||||
const GENDERED_PRONOUN = /\b(he|she|his|her|him|hers)\b/i
|
||||
|
||||
describe('Neutral wording', () => {
|
||||
it('has no gendered pronoun in any English string', () => {
|
||||
const offenders = Object.keys(messages).filter((english) => GENDERED_PRONOUN.test(english))
|
||||
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,6 @@ describe('Roles screens copy', () => {
|
||||
})
|
||||
|
||||
const USERS_ROLES_KEYS = [
|
||||
'Assigned by {0}',
|
||||
'Your role',
|
||||
'See what I can do',
|
||||
'What you can do',
|
||||
@@ -90,10 +89,140 @@ describe('Users, Profile, API keys and Activity copy for roles', () => {
|
||||
it.each(USERS_ROLES_KEYS)('"%s" has a French entry', (key) => {
|
||||
expect((messages as Record<string, string>)[key]).toMatch(/\S/)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders "Assigned by {0}" in both languages', () => {
|
||||
expect(t('Assigned by {0}', ['Marta Alves'])).toBe('Assigned by Marta Alves')
|
||||
const FINAL_COPY: [string, (string | number)[], string][] = [
|
||||
[
|
||||
'{0} is now {1}, and their API keys follow the new role.',
|
||||
['Ines Duarte', 'Content lead'],
|
||||
'Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle.',
|
||||
],
|
||||
["Couldn't save the role", [], "Impossible d'enregistrer le rôle"],
|
||||
[
|
||||
'Your changes are still here. Try again in a moment.',
|
||||
[],
|
||||
'Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
],
|
||||
['Try again', [], 'Réessayer'],
|
||||
["Couldn't change the role", [], 'Impossible de modifier le rôle'],
|
||||
[
|
||||
'Nothing was changed. Try again in a moment.',
|
||||
[],
|
||||
"Rien n'a été modifié. Réessayez dans un instant.",
|
||||
],
|
||||
[
|
||||
"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.',
|
||||
],
|
||||
['{0} no longer exists', ['Writer'], "Writer n'existe plus"],
|
||||
[
|
||||
'It was deleted somewhere else. Choose another role to move the members to.',
|
||||
[],
|
||||
'Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.',
|
||||
],
|
||||
['Role still has members', [], 'Le rôle a encore des membres'],
|
||||
[
|
||||
'{0} now has 1 member. Move them to another role first.',
|
||||
['Intern'],
|
||||
"Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle.",
|
||||
],
|
||||
[
|
||||
'{0} now has {1} members. Move them to another role first.',
|
||||
['Intern', 3],
|
||||
"Intern a maintenant 3 membres. Déplacez-les d'abord vers un autre rôle.",
|
||||
],
|
||||
["Couldn't move the members", [], 'Impossible de déplacer les membres'],
|
||||
['Enter a valid email address.', [], 'Saisissez une adresse e-mail valide.'],
|
||||
['This address already has an account.', [], 'Cette adresse a déjà un compte.'],
|
||||
["Couldn't send the invite", [], "Impossible d'envoyer l'invitation"],
|
||||
['Try again in a moment.', [], 'Réessayez dans un instant.'],
|
||||
['Role changed elsewhere', [], 'Rôle modifié ailleurs'],
|
||||
[
|
||||
'{0} edited {1}, so nothing was saved. Reload and try again.',
|
||||
['Jonas Weber', 'Contributor'],
|
||||
"Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez.",
|
||||
],
|
||||
['Reload', [], 'Recharger'],
|
||||
['That role no longer exists', [], "Ce rôle n'existe plus"],
|
||||
['Role not changed', [], 'Rôle non modifié'],
|
||||
[
|
||||
"{0}'s role changed in the meantime, so nothing was saved. Reload and try again.",
|
||||
['Ines Duarte'],
|
||||
"Le rôle de Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez.",
|
||||
],
|
||||
['Ownership not transferred', [], 'Propriété non transférée'],
|
||||
[
|
||||
"{0}'s role changed in the meantime, so nothing was transferred. Check it and try again.",
|
||||
['Jonas Weber'],
|
||||
"Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez.",
|
||||
],
|
||||
['User not removed', [], 'Utilisateur non retiré'],
|
||||
[
|
||||
"{0}'s role changed in the meantime, so nothing was removed. Check it and try again.",
|
||||
['Ines Duarte'],
|
||||
"Le rôle de Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez.",
|
||||
],
|
||||
['Role changed in the meantime', [], 'Rôle modifié entre-temps'],
|
||||
[
|
||||
'{0} is now {1}. Your choice is kept. Check it and try again.',
|
||||
['Ines Duarte', 'Admin'],
|
||||
'Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez.',
|
||||
],
|
||||
[
|
||||
'{0} is now {1}. What you typed is kept. Check the change, then try again.',
|
||||
['Jonas Weber', 'Writer'],
|
||||
'Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez.',
|
||||
],
|
||||
['Reload member', [], 'Recharger le membre'],
|
||||
['Role reloaded', [], 'Rôle rechargé'],
|
||||
[
|
||||
'Your {0} changes are still here. Review them, then save.',
|
||||
[2],
|
||||
'Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez.',
|
||||
],
|
||||
['Use the saved role', [], 'Utiliser le rôle enregistré'],
|
||||
['Keep my edits', [], 'Garder mes modifications'],
|
||||
['Loading…', [], 'Chargement…'],
|
||||
['Locked', [], 'Verrouillé'],
|
||||
]
|
||||
|
||||
describe('Final copy: toasts and errors, EN and FR', () => {
|
||||
it.each(FINAL_COPY)('"%s" reads in English and in French', (key, values, french) => {
|
||||
expect(t(key as keyof typeof messages, values)).toContain(
|
||||
key.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)])),
|
||||
)
|
||||
setLocale('fr')
|
||||
expect(t('Assigned by {0}', ['Marta Alves'])).toBe('Attribué par Marta Alves')
|
||||
expect(t(key as keyof typeof messages, values).replace(/\u2019/g, "'")).toBe(french)
|
||||
})
|
||||
})
|
||||
|
||||
const PROPOSED_COPY = [
|
||||
'Remove user',
|
||||
'Remove {0}?',
|
||||
'They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay.',
|
||||
"Couldn't remove the user",
|
||||
'User removed',
|
||||
'{0} no longer has access.',
|
||||
"You can't remove yourself. Ask another owner or admin.",
|
||||
'Online articles and the drafts you created.',
|
||||
'Drafts by others are hidden',
|
||||
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
|
||||
'You have no drafts',
|
||||
"Drafts you create appear here. Your role can't see drafts by other people.",
|
||||
'Your role shows only entries you created.',
|
||||
"Entries by others aren't shown",
|
||||
'Your role reads only entries you created. Ask an owner or admin if you need to see more.',
|
||||
"You haven't created {0} yet",
|
||||
"Your role shows only entries you created, so other people's {0} aren't listed here. Use New entry to add one.",
|
||||
'{0} saved changes to {1} at {2}.',
|
||||
'Your 1 change is still here. Review it, then save.',
|
||||
'read online articles and their own drafts',
|
||||
'read only entries they created',
|
||||
] as const
|
||||
|
||||
describe('Proposed copy that the design does not give', () => {
|
||||
it.each(PROPOSED_COPY)('"%s" has a French entry', (key) => {
|
||||
expect((messages as Record<string, string>)[key]).toMatch(/\S/)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { ACTIVITY_CATEGORIES, categoryIcon, categoryLabel } from '@/lib/activity'
|
||||
|
||||
describe('activity categories', () => {
|
||||
@@ -9,3 +10,49 @@ describe('activity categories', () => {
|
||||
expect(categoryIcon('roles')).toMatch(/^i-lucide-/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activity category names', () => {
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
it('lists the categories in the design order', () => {
|
||||
expect([...ACTIVITY_CATEGORIES]).toEqual([
|
||||
'articles',
|
||||
'media',
|
||||
'users',
|
||||
'roles',
|
||||
'api_keys',
|
||||
'auth',
|
||||
'system',
|
||||
])
|
||||
})
|
||||
|
||||
it('names them in English', () => {
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
|
||||
'Articles',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Roles',
|
||||
'API keys',
|
||||
'Authentication',
|
||||
'System',
|
||||
])
|
||||
})
|
||||
|
||||
it('names them in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
|
||||
'Articles',
|
||||
'Médias',
|
||||
'Utilisateurs et invitations',
|
||||
'Rôles',
|
||||
'Clés d’API',
|
||||
'Authentification',
|
||||
'Système',
|
||||
])
|
||||
})
|
||||
|
||||
it('never writes "Users & invites"', () => {
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel).join()).not.toContain('&')
|
||||
})
|
||||
})
|
||||
@@ -291,6 +291,7 @@ describe('The catalogue is complete in both languages', () => {
|
||||
'role.reassign',
|
||||
'user.change_role',
|
||||
'user.transfer_ownership',
|
||||
'user.remove',
|
||||
].sort(),
|
||||
)
|
||||
})
|
||||
@@ -380,3 +381,188 @@ describe('role and ownership sentences', () => {
|
||||
expect(sentenceText(item({ action: 'role.unknown' }))).toBe('role.unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: the six role events in EN and FR', () => {
|
||||
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
|
||||
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
|
||||
const roleChange = {
|
||||
field: 'role',
|
||||
kind: 'text' as const,
|
||||
before: 'Writer',
|
||||
after: 'Content lead',
|
||||
}
|
||||
|
||||
function event(overrides: Partial<ActivityItem>) {
|
||||
return item({ category: 'roles', target: roleTarget('Intern'), ...overrides })
|
||||
}
|
||||
|
||||
function both(source: ActivityItem) {
|
||||
setLocale('en')
|
||||
const english = sentenceText(source)
|
||||
setLocale('fr')
|
||||
const french = sentenceText(source)
|
||||
return [english, french]
|
||||
}
|
||||
|
||||
it.each([
|
||||
['role.create', 'Contributor', 'Role Contributor created', 'Rôle Contributor créé'],
|
||||
['role.edit', 'Contributor', 'Role Contributor edited', 'Rôle Contributor modifié'],
|
||||
['role.delete', 'Intern', 'Role Intern deleted', 'Rôle Intern supprimé'],
|
||||
])('success %s', (action, name, english, french) => {
|
||||
expect(both(event({ action, target: roleTarget(name) }))).toEqual([english, french])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[3, 'Moved 3 members from Intern to Writer', '3 membres déplacés de Intern vers Writer'],
|
||||
[1, 'Moved 1 member from Intern to Writer', '1 membre déplacé de Intern vers Writer'],
|
||||
])('success role.reassign with moved %s', (moved, english, french) => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
params: { from: 4, to: 3, moved, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([english, french])
|
||||
setLocale('en')
|
||||
expect(composeSentence(source).find((segment) => segment.strong)?.text).toBe('Intern')
|
||||
})
|
||||
|
||||
it('success user.change_role', () => {
|
||||
const source = event({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
target: userTarget('Ines Duarte'),
|
||||
changes: [roleChange],
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
"Ines Duarte's role changed from Writer to Content lead",
|
||||
"Le rôle d'Ines Duarte est passé de Writer à Content lead",
|
||||
])
|
||||
})
|
||||
|
||||
it('success user.transfer_ownership', () => {
|
||||
const source = event({
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
target: userTarget('Jonas Weber'),
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
'Ownership transferred from Marta Alves to Jonas Weber',
|
||||
'Propriété transférée de Marta Alves à Jonas Weber',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'role.create',
|
||||
'Contributor',
|
||||
'Failed to create the role Contributor',
|
||||
'Échec de la création du rôle Contributor',
|
||||
],
|
||||
[
|
||||
'role.edit',
|
||||
'Contributor',
|
||||
'Failed to edit the role Contributor',
|
||||
'Échec de la modification du rôle Contributor',
|
||||
],
|
||||
[
|
||||
'role.delete',
|
||||
'Intern',
|
||||
'Failed to delete the role Intern',
|
||||
'Échec de la suppression du rôle Intern',
|
||||
],
|
||||
])('failed %s', (action, name, english, french) => {
|
||||
expect(both(event({ action, outcome: 'failed', target: roleTarget(name) }))).toEqual([
|
||||
english,
|
||||
french,
|
||||
])
|
||||
})
|
||||
|
||||
it('failed role.reassign with the count and the destination', () => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
outcome: 'failed',
|
||||
params: { moved: 3, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
'Failed to move 3 members from Intern to Writer',
|
||||
'Échec du déplacement de 3 membres de Intern vers Writer',
|
||||
])
|
||||
})
|
||||
|
||||
it('failed role.reassign for one member', () => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
outcome: 'failed',
|
||||
params: { moved: 1, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)[0]).toBe('Failed to move 1 member from Intern to Writer')
|
||||
})
|
||||
|
||||
it('failed user.change_role and user.transfer_ownership', () => {
|
||||
expect(
|
||||
both(
|
||||
event({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
outcome: 'failed',
|
||||
target: userTarget('Ines Duarte'),
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
'Failed to change the role of Ines Duarte',
|
||||
"Échec du changement de rôle d'Ines Duarte",
|
||||
])
|
||||
expect(
|
||||
both(
|
||||
event({
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
outcome: 'failed',
|
||||
target: userTarget('Jonas Weber'),
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
'Failed to transfer ownership to Jonas Weber',
|
||||
'Échec du transfert de la propriété à Jonas Weber',
|
||||
])
|
||||
})
|
||||
|
||||
it('writes user.remove with the success and the failed sentence', () => {
|
||||
const target = userTarget('Ines Duarte')
|
||||
const removed = event({ action: 'user.remove', category: 'users', target })
|
||||
|
||||
expect(both(removed)).toEqual(['Removed Ines Duarte', 'Ines Duarte retiré'])
|
||||
expect(both({ ...removed, outcome: 'failed' })).toEqual([
|
||||
'Failed to remove Ines Duarte',
|
||||
"Échec du retrait d'Ines Duarte",
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', { moved: 3 }, 'Moved 3 members from Intern to another role'],
|
||||
['success', { moved: 1 }, 'Moved 1 member from Intern to another role'],
|
||||
['success', { to_name: 'Writer' }, 'Moved the members of the role Intern'],
|
||||
['failed', {}, 'Failed to move the members of the role Intern'],
|
||||
] as const)('role.reassign %s without all params: %j', (outcome, params, sentence) => {
|
||||
const text = sentenceText(event({ action: 'role.reassign', outcome, params }))
|
||||
|
||||
expect(text).toBe(sentence)
|
||||
expect(text).not.toContain('undefined')
|
||||
})
|
||||
|
||||
it('ignores an empty destination name', () => {
|
||||
const text = sentenceText(
|
||||
event({ action: 'role.reassign', params: { moved: 2, to_name: ' ' } }),
|
||||
)
|
||||
|
||||
expect(text).toBe('Moved 2 members from Intern to another role')
|
||||
})
|
||||
|
||||
it('renders an unknown action as its name', () => {
|
||||
expect(sentenceText(event({ action: 'role.shuffle' }))).toBe('role.shuffle')
|
||||
})
|
||||
})
|
||||
@@ -251,6 +251,45 @@ describe('custom content types', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('roles sync: permission-gated routes', () => {
|
||||
const GATED = [
|
||||
'POST /v1/content-type/',
|
||||
'PUT /v1/content-type/{key}',
|
||||
'DELETE /v1/content-type/{key}',
|
||||
'GET /v1/builtin-type/',
|
||||
'GET /v1/builtin-type/{key}',
|
||||
'GET /v1/media/purge-settings',
|
||||
'PATCH /v1/media/purge-settings',
|
||||
'GET /v1/media/purge-preview',
|
||||
'POST /v1/media/purge',
|
||||
]
|
||||
|
||||
it('matches "permission" on the nine routes through the access chip and the description', () => {
|
||||
const found = routes(search('permission').endpoints)
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(found).toContain(route)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches "missing_permission" on the error code of the gated routes', () => {
|
||||
const found = search('missing_permission').endpoints
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(routes(found)).toContain(route)
|
||||
}
|
||||
expect(found.find((hit) => routes([hit])[0] === GATED[0])!.via!.kind).toBe('error')
|
||||
})
|
||||
|
||||
it('no longer matches "owner or admin" on the nine routes', () => {
|
||||
const found = routes(search('owner or admin').endpoints)
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(found).not.toContain(route)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('lists the five suggested rows in order', () => {
|
||||
expect(routes(suggestedHits(index))).toEqual([
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { elideDe } from '@/lib/frenchElision'
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('elideDe', () => {
|
||||
it('elides before a vowel in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(elideDe('Le rôle de Ines Duarte a changé', 'Ines Duarte')).toBe(
|
||||
"Le rôle d'Ines Duarte a changé",
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps de before a consonant', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(elideDe('Le rôle de Jonas Weber a changé', 'Jonas Weber')).toBe(
|
||||
'Le rôle de Jonas Weber a changé',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves English untouched', () => {
|
||||
expect(elideDe("Ines Duarte's role", 'Ines Duarte')).toBe("Ines Duarte's role")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
entryNounOf,
|
||||
inverseNoun,
|
||||
inverseSide,
|
||||
kindFromInverseSide,
|
||||
@@ -87,3 +88,14 @@ describe('the preview', () => {
|
||||
expect(relationSample('many_to_many').ownerChips).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the entry noun of a type name', () => {
|
||||
it.each([
|
||||
['Events', 'an event', 'events'],
|
||||
['Recipes', 'a recipe', 'recipes'],
|
||||
['Press mentions', 'a press mention', 'press mentions'],
|
||||
['Categories', 'a category', 'categories'],
|
||||
])('turns %s into %s', (name, singular, plural) => {
|
||||
expect(entryNounOf(name)).toEqual({ singular, plural })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { roleDiffRows } from '@/lib/roleDiff'
|
||||
import type { RoleForm } from '@/lib/roleForm'
|
||||
|
||||
const TYPES = [
|
||||
{ key: 'article', name: 'Articles' },
|
||||
{ key: 'events', name: 'Events' },
|
||||
]
|
||||
|
||||
const CONTEXT = {
|
||||
types: TYPES,
|
||||
toggles: new Set(['activity.export']),
|
||||
order: ['activity.view', 'activity.export'],
|
||||
}
|
||||
|
||||
function form(grants: RoleForm['grants'], name = 'Contributor', description = ''): RoleForm {
|
||||
return { name, description, grants }
|
||||
}
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('roleDiffRows', () => {
|
||||
it('lists only the settings where the saved role and the edits disagree', () => {
|
||||
const saved = form({ 'content.read': 'all', 'content.update:events': 'own' })
|
||||
const yours = form({
|
||||
'content.read': 'all',
|
||||
'content.update:events': 'all',
|
||||
'content.delete:events': 'own',
|
||||
})
|
||||
|
||||
expect(
|
||||
roleDiffRows(saved, yours, CONTEXT).map(({ setting, saved, yours }) => [
|
||||
setting,
|
||||
saved,
|
||||
yours,
|
||||
]),
|
||||
).toEqual([
|
||||
['Events · Update', 'Own', 'All'],
|
||||
['Events · Delete', 'None', 'Own'],
|
||||
])
|
||||
})
|
||||
|
||||
it('shows nothing when an override equals the inherited value', () => {
|
||||
const saved = form({ 'content.read': 'all' })
|
||||
const yours = form({ 'content.read': 'all', 'content.read:events': 'all' })
|
||||
|
||||
expect(roleDiffRows(saved, yours, CONTEXT)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the Create switch as On and Off', () => {
|
||||
const rows = roleDiffRows(form({}), form({ 'content.create:events': 'all' }), CONTEXT)
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ id: 'content.create:events', setting: 'Events · Create', saved: 'Off', yours: 'On' },
|
||||
])
|
||||
})
|
||||
|
||||
it('names general permissions and keeps the catalogue order', () => {
|
||||
const rows = roleDiffRows(
|
||||
form({ 'activity.export': 'all' }),
|
||||
form({ 'activity.view': 'own', 'activity.export': 'none' }),
|
||||
CONTEXT,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
|
||||
['See activity', 'None', 'Own'],
|
||||
['Export activity', 'On', 'Off'],
|
||||
])
|
||||
})
|
||||
|
||||
it('lists a changed name or description first', () => {
|
||||
const rows = roleDiffRows(
|
||||
form({}, 'Contributor', 'Old'),
|
||||
form({}, 'Writer', 'New'),
|
||||
CONTEXT,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
|
||||
['Name', 'Contributor', 'Writer'],
|
||||
['Description', 'Old', 'New'],
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to one row per action when there are no types', () => {
|
||||
const rows = roleDiffRows(form({}), form({ 'content.read': 'all' }), {
|
||||
...CONTEXT,
|
||||
types: [],
|
||||
})
|
||||
|
||||
expect(rows.map((row) => row.setting)).toEqual(['All content types · Read'])
|
||||
})
|
||||
|
||||
it('translates the scopes', () => {
|
||||
setLocale('fr')
|
||||
|
||||
const rows = roleDiffRows(form({}), form({ 'content.update:events': 'own' }), CONTEXT)
|
||||
|
||||
expect(rows[0]).toMatchObject({ saved: 'Aucune', yours: 'Les siennes' })
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formFromRole,
|
||||
grantsToPayload,
|
||||
isCustomRow,
|
||||
reapplyEdits,
|
||||
resetRow,
|
||||
scopeAt,
|
||||
setScope,
|
||||
@@ -191,3 +192,46 @@ describe('differences', () => {
|
||||
).toEqual(['content.create', 'content.update', 'content.update:case-studies'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reapplyEdits', () => {
|
||||
const initial = {
|
||||
name: 'Contributor',
|
||||
description: '',
|
||||
grants: { 'content.read': 'all' } as const,
|
||||
}
|
||||
|
||||
it('puts the edited cells back on top of the new saved role', () => {
|
||||
const base = {
|
||||
name: 'Contributor',
|
||||
description: 'Saved',
|
||||
grants: { 'content.read': 'own' } as const,
|
||||
}
|
||||
const edited = {
|
||||
name: 'Contributor',
|
||||
description: '',
|
||||
grants: { 'content.read': 'all', 'content.delete:events': 'own' } as const,
|
||||
}
|
||||
|
||||
const merged = reapplyEdits(base, initial, edited)
|
||||
|
||||
expect(merged).toEqual({
|
||||
name: 'Contributor',
|
||||
description: 'Saved',
|
||||
grants: { 'content.read': 'own', 'content.delete:events': 'own' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an edited name and removes a cell the person reset', () => {
|
||||
const withOverride = {
|
||||
...initial,
|
||||
grants: { ...initial.grants, 'content.update:events': 'own' } as const,
|
||||
}
|
||||
const base = { name: 'Contributor', description: '', grants: { ...withOverride.grants } }
|
||||
const edited = { ...initial, name: 'Writer', grants: { 'content.read': 'all' } as const }
|
||||
|
||||
const merged = reapplyEdits(base, withOverride, edited)
|
||||
|
||||
expect(merged.name).toBe('Writer')
|
||||
expect(merged.grants).toEqual({ 'content.read': 'all' })
|
||||
})
|
||||
})
|
||||
@@ -49,7 +49,7 @@ describe('buildRolePreview content', () => {
|
||||
const preview = buildRolePreview({ grants, types: TYPES })
|
||||
|
||||
expect(preview.content.map((line) => line.label)).toEqual([
|
||||
'Articles, Case studies',
|
||||
'Articles and Case studies',
|
||||
'Events',
|
||||
])
|
||||
expect(preview.content[1]!.sentence).toContain('edit any entry')
|
||||
@@ -132,3 +132,67 @@ describe('buildRolePreview Changed tagging and members', () => {
|
||||
expect(buildRolePreview({ grants: {}, types: TYPES, members: 0 }).members).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRolePreview Read = Own', () => {
|
||||
const readOwn = {
|
||||
'content.read': 'own',
|
||||
'content.create': 'all',
|
||||
'content.update': 'own',
|
||||
} as const
|
||||
|
||||
it('writes one line per kind of type when Read is Own', () => {
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(preview.content.map((line) => [line.label, line.sentence])).toEqual([
|
||||
[
|
||||
'Articles',
|
||||
"Can read online articles and their own drafts, create entries and edit only their own. Can't delete and publish.",
|
||||
],
|
||||
[
|
||||
'Events and Case studies',
|
||||
"Can read only entries they created, create entries and edit only their own. Can't delete and publish.",
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the custom type sentence for a custom type override', () => {
|
||||
const grants = setScope({ 'content.read': 'all' }, 'content.read', 'own', 'events')
|
||||
|
||||
const preview = buildRolePreview({ grants, types: TYPES })
|
||||
|
||||
const events = preview.content.find((line) => line.label === 'Events')
|
||||
expect(events?.sentence).toContain('read only entries they created')
|
||||
})
|
||||
|
||||
it('uses the articles sentence alone when only Articles exists', () => {
|
||||
const preview = buildRolePreview({
|
||||
grants: { ...readOwn },
|
||||
types: [{ key: 'article', name: 'Articles' }],
|
||||
})
|
||||
|
||||
expect(preview.content).toHaveLength(1)
|
||||
expect(preview.content[0]).toMatchObject({ label: 'All content types' })
|
||||
expect(preview.content[0]!.sentence).toContain('read online articles and their own drafts')
|
||||
})
|
||||
|
||||
it('never says read only their own', () => {
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(JSON.stringify(preview)).not.toContain('read only their own')
|
||||
})
|
||||
|
||||
it('keeps the Read All wording', () => {
|
||||
const preview = buildRolePreview({ grants: applyPreset({}, 'write_own'), types: TYPES })
|
||||
|
||||
expect(preview.content[0]!.sentence).toContain('Can read any entry')
|
||||
})
|
||||
|
||||
it('translates both sentences', () => {
|
||||
setLocale('fr')
|
||||
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(preview.content[0]!.sentence).toContain('lire les articles en ligne')
|
||||
expect(preview.content[1]!.sentence).toContain('créées par la personne')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { userMenuItems, type UserMenuContext } from '@/lib/userMenu'
|
||||
import type { AcceptedUser } from '@/lib/users'
|
||||
import { roleRefFor } from '@/test/access'
|
||||
|
||||
function accepted(id: number, role: string): AcceptedUser {
|
||||
return {
|
||||
kind: 'user',
|
||||
id,
|
||||
username: `user${id}`,
|
||||
display_name: `User ${id}`,
|
||||
email: `user${id}@renew.example`,
|
||||
role: roleRefFor(role),
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
function context(overrides: Partial<UserMenuContext> = {}): UserMenuContext {
|
||||
return {
|
||||
sessionUserId: 1,
|
||||
canChangeRole: true,
|
||||
canTransfer: false,
|
||||
canViewRoles: true,
|
||||
canRemove: true,
|
||||
canCover: () => true,
|
||||
changeRole: vi.fn(),
|
||||
transfer: vi.fn(),
|
||||
removeUser: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
function remove(user: AcceptedUser, overrides: Partial<UserMenuContext> = {}) {
|
||||
return userMenuItems(user, context(overrides)).find((item) => item.key === 'remove-user')
|
||||
}
|
||||
|
||||
describe('Remove user in the row menu', () => {
|
||||
it('is enabled below Change role for an accepted user the person can cover', () => {
|
||||
const items = userMenuItems(accepted(2, 'editor'), context())
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['change-role', 'remove-user'])
|
||||
expect(items[1]).toMatchObject({
|
||||
label: 'Remove user',
|
||||
icon: 'i-lucide-user-x',
|
||||
destructive: true,
|
||||
unavailable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('is the only item for a person who can remove but not change roles', () => {
|
||||
const items = userMenuItems(accepted(2, 'editor'), context({ canChangeRole: false }))
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['remove-user'])
|
||||
})
|
||||
|
||||
it('is absent without the permission', () => {
|
||||
expect(remove(accepted(2, 'editor'), { canRemove: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is disabled on the owner row with the owner reason', () => {
|
||||
expect(remove(accepted(3, 'owner'))).toMatchObject({
|
||||
unavailable: true,
|
||||
hint: "The owner can't be changed or removed. Transfer ownership first.",
|
||||
})
|
||||
})
|
||||
|
||||
it('is disabled on the row marked you', () => {
|
||||
const item = remove(accepted(1, 'admin'))
|
||||
|
||||
expect(item?.unavailable).toBe(true)
|
||||
expect(item?.hint).toBe("You can't remove yourself. Ask another owner or admin.")
|
||||
})
|
||||
|
||||
it('is disabled with the escalation reason when the role cannot be covered', () => {
|
||||
const item = remove(accepted(2, 'admin'), { canCover: () => false })
|
||||
|
||||
expect(item?.unavailable).toBe(true)
|
||||
expect(item?.hint).toBe("Your role can't assign a role with more power than yours.")
|
||||
})
|
||||
|
||||
it('selects through the context', () => {
|
||||
const removeUser = vi.fn()
|
||||
const user = accepted(2, 'editor')
|
||||
|
||||
remove(user, { removeUser })!.onSelect(null)
|
||||
|
||||
expect(removeUser).toHaveBeenCalledWith(user, null)
|
||||
})
|
||||
|
||||
it('is absent for a user without a role', () => {
|
||||
const user = { ...accepted(2, 'editor'), role: null }
|
||||
|
||||
expect(remove(user)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(remove(accepted(2, 'editor'))?.label).toBe('Retirer l’utilisateur')
|
||||
expect(remove(accepted(1, 'admin'))?.hint).toBe(
|
||||
'Vous ne pouvez pas vous retirer vous-même. Demandez à un autre propriétaire ou administrateur.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,10 @@ export const ACTIVITY_CATEGORIES = [
|
||||
'articles',
|
||||
'media',
|
||||
'users',
|
||||
'roles',
|
||||
'api_keys',
|
||||
'auth',
|
||||
'system',
|
||||
'roles',
|
||||
] as const
|
||||
|
||||
export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
import { formatNumber, locale, t } from '@/i18n'
|
||||
import type { MessageKey } from '@/i18n/messages'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { sizeLabel } from '@/lib/activityRetention'
|
||||
@@ -19,6 +19,8 @@ interface Template {
|
||||
const TARGET_SLOT = '\u0000'
|
||||
const DROPPED_SLOT = new RegExp(`[\\s\\u00a0]*:?[\\s\\u00a0]*${TARGET_SLOT}`)
|
||||
const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
|
||||
const FRENCH_ELIDING_ACTIONS = ['user.change_role', 'user.remove']
|
||||
const FRENCH_VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
|
||||
|
||||
const CATALOGUE: Record<string, Template> = {
|
||||
'article.create': {
|
||||
@@ -123,8 +125,12 @@ const CATALOGUE: Record<string, Template> = {
|
||||
failed: 'Failed to delete the role {0}',
|
||||
},
|
||||
'role.reassign': {
|
||||
success: 'Moved the members of the role {0}',
|
||||
failed: 'Failed to move the members of the role {0}',
|
||||
success: 'Moved {0} members from {1} to {2}',
|
||||
failed: 'Failed to move {0} members from {1} to {2}',
|
||||
},
|
||||
'user.remove': {
|
||||
success: 'Removed {0}',
|
||||
failed: 'Failed to remove {0}',
|
||||
},
|
||||
'user.change_role': {
|
||||
success: 'Changed the role of {0}',
|
||||
@@ -226,9 +232,70 @@ function roleChangeOf(item: ActivityItem) {
|
||||
return item.changes.find((change) => change.field === 'role' && change.kind === 'text')
|
||||
}
|
||||
|
||||
interface SentenceParts {
|
||||
template: MessageKey
|
||||
values: string[]
|
||||
}
|
||||
|
||||
function destinationName(params: Record<string, unknown>) {
|
||||
const name = params.to_name
|
||||
return typeof name === 'string' && name.trim() !== '' ? name : null
|
||||
}
|
||||
|
||||
function movedCount(params: Record<string, unknown>) {
|
||||
const moved = params.moved
|
||||
return typeof moved === 'number' && Number.isFinite(moved) && moved >= 1 ? moved : null
|
||||
}
|
||||
|
||||
function reassignParts(item: ActivityItem): SentenceParts {
|
||||
const failed = item.outcome === 'failed'
|
||||
const moved = movedCount(item.params)
|
||||
const destination = destinationName(item.params)
|
||||
if (moved === null) {
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move the members of the role {0}'
|
||||
: 'Moved the members of the role {0}',
|
||||
values: [TARGET_SLOT],
|
||||
}
|
||||
}
|
||||
const count = formatNumber(moved)
|
||||
if (moved === 1) {
|
||||
if (destination === null) {
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move 1 member from {0} to another role'
|
||||
: 'Moved 1 member from {0} to another role',
|
||||
values: [TARGET_SLOT],
|
||||
}
|
||||
}
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move 1 member from {0} to {1}'
|
||||
: 'Moved 1 member from {0} to {1}',
|
||||
values: [TARGET_SLOT, destination],
|
||||
}
|
||||
}
|
||||
if (destination === null) {
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move {0} members from {1} to another role'
|
||||
: 'Moved {0} members from {1} to another role',
|
||||
values: [count, TARGET_SLOT],
|
||||
}
|
||||
}
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move {0} members from {1} to {2}'
|
||||
: 'Moved {0} members from {1} to {2}',
|
||||
values: [count, TARGET_SLOT, destination],
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||
const template = CATALOGUE[item.action]
|
||||
if (!template) return null
|
||||
if (item.action === 'role.reassign') return reassignParts(item).template
|
||||
if (item.action === 'user.change_role' && item.outcome === 'success' && roleChangeOf(item)) {
|
||||
return "{0}'s role changed from {1} to {2}"
|
||||
}
|
||||
@@ -248,6 +315,9 @@ function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||
}
|
||||
|
||||
function slotValues(item: ActivityItem, template: MessageKey) {
|
||||
if (item.action === 'role.reassign') {
|
||||
return reassignParts(item).values
|
||||
}
|
||||
if (PURGE_ACTIONS.includes(item.action) && item.outcome === 'success') {
|
||||
return [purgeSummary(item.params)]
|
||||
}
|
||||
@@ -276,6 +346,13 @@ function usesTargetSlot(values: string[]) {
|
||||
return values.includes(TARGET_SLOT)
|
||||
}
|
||||
|
||||
function elideFrench(item: ActivityItem, name: string, rendered: string) {
|
||||
if (locale.value !== 'fr' || !FRENCH_ELIDING_ACTIONS.includes(item.action)) return rendered
|
||||
return FRENCH_VOWEL_START.test(name)
|
||||
? rendered.replace(`de ${TARGET_SLOT}`, `d'${TARGET_SLOT}`)
|
||||
: rendered
|
||||
}
|
||||
|
||||
export function composeSentence(item: ActivityItem): SentenceSegment[] {
|
||||
const template = resolveTemplate(item)
|
||||
if (template === null) return [{ text: item.action, strong: false }]
|
||||
@@ -291,7 +368,7 @@ export function composeSentence(item: ActivityItem): SentenceSegment[] {
|
||||
|
||||
const values = slotValues(item, template)
|
||||
const name = targetName(item)
|
||||
const rendered = t(template, values)
|
||||
const rendered = elideFrench(item, name, t(template, values))
|
||||
if (!usesTargetSlot(values)) return [{ text: rendered, strong: false }]
|
||||
if (!name) return [{ text: rendered.replace(DROPPED_SLOT, ''), strong: false }]
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const KNOWN_ERROR_CODES = [
|
||||
'invalid_role_name',
|
||||
'invalid_reassign',
|
||||
'role_not_found',
|
||||
'target_role_not_found',
|
||||
'user_role_changed',
|
||||
'session_required',
|
||||
'invalid_role',
|
||||
|
||||
@@ -6,5 +6,7 @@ export interface LinkableUser {
|
||||
|
||||
export interface DirectoryNotice {
|
||||
title?: string
|
||||
tone?: 'error' | 'warning'
|
||||
description: string
|
||||
action?: { label: string; onClick: () => void }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { locale } from '@/i18n'
|
||||
|
||||
const VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
|
||||
|
||||
export function elideDe(text: string, name: string) {
|
||||
return locale.value === 'fr' && VOWEL_START.test(name)
|
||||
? text.replace(`de ${name}`, `d'${name}`)
|
||||
: text
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCop
|
||||
case 'invalid_role_name':
|
||||
case 'invalid_reassign':
|
||||
case 'role_not_found':
|
||||
case 'target_role_not_found':
|
||||
case 'session_required':
|
||||
case 'invalid_role':
|
||||
case 'invalid_transfer':
|
||||
|
||||
@@ -27,6 +27,15 @@ export function singularOf(name: string) {
|
||||
return name.length > 1 ? name.replace(FINAL_PLURAL, '') : name
|
||||
}
|
||||
|
||||
const PLURAL_IES = /ies$/i
|
||||
const VOWEL_START = /^[aeiou]/
|
||||
|
||||
export function entryNounOf(name: string) {
|
||||
const plural = name.trim().toLowerCase()
|
||||
const singular = PLURAL_IES.test(plural) ? plural.replace(PLURAL_IES, 'y') : singularOf(plural)
|
||||
return { singular: `${VOWEL_START.test(singular) ? 'an' : 'a'} ${singular}`, plural }
|
||||
}
|
||||
|
||||
export function relationNames(
|
||||
ownerName: string,
|
||||
relatedName: string,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { t } from '@/i18n'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import {
|
||||
CONTENT_ACTIONS,
|
||||
contentKey,
|
||||
scopeAt,
|
||||
type ContentAction,
|
||||
type RoleForm,
|
||||
} from '@/lib/roleForm'
|
||||
import { parseGrantKey, type Scope } from '@/lib/permissions'
|
||||
|
||||
export interface RoleDiffRow {
|
||||
id: string
|
||||
setting: string
|
||||
saved: string
|
||||
yours: string
|
||||
}
|
||||
|
||||
export interface RoleDiffContext {
|
||||
types: { key: string; name: string }[]
|
||||
toggles: ReadonlySet<string>
|
||||
order: readonly string[]
|
||||
}
|
||||
|
||||
const NAME_ROW = 'name'
|
||||
const DESCRIPTION_ROW = 'description'
|
||||
|
||||
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')
|
||||
default:
|
||||
return t('Publish')
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(scope: Scope, toggle: boolean) {
|
||||
if (toggle) {
|
||||
return scope === 'none' ? t('Off') : t('On')
|
||||
}
|
||||
return scope === 'none' ? t('None') : scope === 'own' ? t('Own') : t('All')
|
||||
}
|
||||
|
||||
function contentRows(saved: RoleForm, yours: RoleForm, context: RoleDiffContext): RoleDiffRow[] {
|
||||
const rows: RoleDiffRow[] = []
|
||||
const types = context.types.length > 0 ? context.types : [{ key: undefined, name: '' }]
|
||||
for (const type of types) {
|
||||
for (const action of CONTENT_ACTIONS) {
|
||||
const key = contentKey(action)
|
||||
const before = scopeAt(saved.grants, key, type.key)
|
||||
const after = scopeAt(yours.grants, key, type.key)
|
||||
if (before === after) {
|
||||
continue
|
||||
}
|
||||
const toggle = action === 'create'
|
||||
const scope = type.key === undefined ? t('All content types') : type.name
|
||||
rows.push({
|
||||
id: `${key}:${type.key ?? ''}`,
|
||||
setting: `${scope} · ${actionLabel(action)}`,
|
||||
saved: scopeLabel(before, toggle),
|
||||
yours: scopeLabel(after, toggle),
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function generalKeys(saved: RoleForm, yours: RoleForm, order: readonly string[]) {
|
||||
const keys = new Set(
|
||||
[...Object.keys(saved.grants), ...Object.keys(yours.grants)]
|
||||
.map((key) => parseGrantKey(key).base)
|
||||
.filter((base) => !base.startsWith('content.')),
|
||||
)
|
||||
const rank = (key: string) => {
|
||||
const index = order.indexOf(key)
|
||||
return index === -1 ? order.length : index
|
||||
}
|
||||
return [...keys].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
}
|
||||
|
||||
function generalRows(saved: RoleForm, yours: RoleForm, context: RoleDiffContext): RoleDiffRow[] {
|
||||
return generalKeys(saved, yours, context.order).flatMap((key) => {
|
||||
const before = scopeAt(saved.grants, key)
|
||||
const after = scopeAt(yours.grants, key)
|
||||
if (before === after) {
|
||||
return []
|
||||
}
|
||||
const toggle = context.toggles.has(key)
|
||||
return [
|
||||
{
|
||||
id: key,
|
||||
setting: permissionName(key) ?? key,
|
||||
saved: scopeLabel(before, toggle),
|
||||
yours: scopeLabel(after, toggle),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function textRows(saved: RoleForm, yours: RoleForm): RoleDiffRow[] {
|
||||
const rows: RoleDiffRow[] = []
|
||||
if (saved.name !== yours.name) {
|
||||
rows.push({ id: NAME_ROW, setting: t('Name'), saved: saved.name, yours: yours.name })
|
||||
}
|
||||
if (saved.description !== yours.description) {
|
||||
rows.push({
|
||||
id: DESCRIPTION_ROW,
|
||||
setting: t('Description'),
|
||||
saved: saved.description,
|
||||
yours: yours.description,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function roleDiffRows(
|
||||
saved: RoleForm,
|
||||
yours: RoleForm,
|
||||
context: RoleDiffContext,
|
||||
): RoleDiffRow[] {
|
||||
return [
|
||||
...textRows(saved, yours),
|
||||
...contentRows(saved, yours, context),
|
||||
...generalRows(saved, yours, context),
|
||||
]
|
||||
}
|
||||
@@ -156,6 +156,28 @@ export function unsavedCount(current: RoleForm, initial: RoleForm): number {
|
||||
return count
|
||||
}
|
||||
|
||||
export function reapplyEdits(base: RoleForm, initial: RoleForm, edited: RoleForm): RoleForm {
|
||||
const grants = { ...base.grants }
|
||||
const keys = new Set([...Object.keys(initial.grants), ...Object.keys(edited.grants)])
|
||||
for (const key of keys) {
|
||||
if (rawEntry(initial.grants, key) === rawEntry(edited.grants, key)) {
|
||||
continue
|
||||
}
|
||||
const value = edited.grants[key]
|
||||
if (value === undefined) {
|
||||
delete grants[key]
|
||||
} else {
|
||||
grants[key] = value
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: edited.name !== initial.name ? edited.name : base.name,
|
||||
description:
|
||||
edited.description !== initial.description ? edited.description : base.description,
|
||||
grants,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCellChanged(
|
||||
current: GrantMap,
|
||||
initial: GrantMap,
|
||||
|
||||
+38
-10
@@ -145,8 +145,10 @@ const AREAS: Area[] = [
|
||||
|
||||
const OWNER_ONLY_AREA = 'system'
|
||||
|
||||
type TypeKind = 'article' | 'custom'
|
||||
|
||||
const GRANTED_CONTENT: Record<string, Record<'all' | 'own', () => string>> = {
|
||||
read: { all: () => t('read any entry'), own: () => t('read only their own entries') },
|
||||
read: { all: () => t('read any entry'), own: () => t('read only entries they created') },
|
||||
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') },
|
||||
@@ -169,7 +171,7 @@ export function joinList(items: string[]): string {
|
||||
return t('{0} and {1}', [head, items[items.length - 1]])
|
||||
}
|
||||
|
||||
function contentSentence(scopes: Scope[]): string {
|
||||
function contentSentence(scopes: Scope[], kind: TypeKind): string {
|
||||
const granted: string[] = []
|
||||
const refused: string[] = []
|
||||
CONTENT_ACTIONS.forEach((action, index) => {
|
||||
@@ -178,7 +180,11 @@ function contentSentence(scopes: Scope[]): string {
|
||||
refused.push(REFUSED_CONTENT[action]!())
|
||||
return
|
||||
}
|
||||
granted.push(GRANTED_CONTENT[action]![scope]())
|
||||
granted.push(
|
||||
action === 'read' && scope === 'own' && kind === 'article'
|
||||
? t('read online articles and their own drafts')
|
||||
: GRANTED_CONTENT[action]![scope](),
|
||||
)
|
||||
})
|
||||
if (granted.length === 0) {
|
||||
return t('No access to entries.')
|
||||
@@ -187,6 +193,10 @@ function contentSentence(scopes: Scope[]): string {
|
||||
return refused.length === 0 ? can : `${can} ${t("Can't {0}.", [joinList(refused)])}`
|
||||
}
|
||||
|
||||
function kindOf(type: PreviewType): TypeKind {
|
||||
return type.key === ARTICLE_TYPE_KEY ? 'article' : 'custom'
|
||||
}
|
||||
|
||||
function contentLines(
|
||||
grants: GrantMap,
|
||||
types: PreviewType[],
|
||||
@@ -194,24 +204,42 @@ function contentLines(
|
||||
const hasOverrides = Object.keys(grants).some(
|
||||
(key) => key.startsWith('content.') && parseGrantKey(key).typeKey !== undefined,
|
||||
)
|
||||
if (!hasOverrides || types.length === 0) {
|
||||
const readsOwn = types.some((type) => scopeAt(grants, contentKey('read'), type.key) === 'own')
|
||||
if ((!hasOverrides && !readsOwn) || types.length === 0) {
|
||||
const scopes = CONTENT_ACTIONS.map((action) => scopeAt(grants, contentKey(action)))
|
||||
return [{ id: 'all', label: t('All content types'), sentence: contentSentence(scopes) }]
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: t('All content types'),
|
||||
sentence: contentSentence(scopes, 'custom'),
|
||||
},
|
||||
]
|
||||
}
|
||||
const groups = new Map<string, { names: string[]; scopes: Scope[] }>()
|
||||
const groups = new Map<string, { names: string[]; scopes: Scope[]; kind: TypeKind }>()
|
||||
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 }
|
||||
const kind = kindOf(type)
|
||||
const signature = `${scopes.join('|')}${scopes[0] === 'own' ? `|${kind}` : ''}`
|
||||
const group = groups.get(signature) ?? { names: [], scopes, kind }
|
||||
group.names.push(type.name)
|
||||
groups.set(signature, group)
|
||||
}
|
||||
const [only] = [...groups.values()]
|
||||
if (!hasOverrides && groups.size === 1) {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: t('All content types'),
|
||||
sentence: contentSentence(only!.scopes, only!.kind),
|
||||
},
|
||||
]
|
||||
}
|
||||
return [...groups.entries()].map(([signature, group]) => ({
|
||||
id: signature,
|
||||
label: group.names.join(', '),
|
||||
sentence: contentSentence(group.scopes),
|
||||
label: joinList(group.names),
|
||||
sentence: contentSentence(group.scopes, group.kind),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,24 @@ export interface UserMenuContext {
|
||||
canChangeRole: boolean
|
||||
canTransfer: boolean
|
||||
canViewRoles: boolean
|
||||
canRemove: boolean
|
||||
canCover: (user: AcceptedUser) => boolean
|
||||
changeRole: (user: AcceptedUser, trigger: HTMLElement | null) => void
|
||||
transfer: (trigger: HTMLElement | null) => void
|
||||
removeUser: (user: AcceptedUser, trigger: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
function removeHint(user: AcceptedUser, context: UserMenuContext): string | undefined {
|
||||
if (user.role?.locked) {
|
||||
return t("The owner can't be changed or removed. Transfer ownership first.")
|
||||
}
|
||||
if (user.id === context.sessionUserId) {
|
||||
return t("You can't remove yourself. Ask another owner or admin.")
|
||||
}
|
||||
if (!context.canCover(user)) {
|
||||
return t("Your role can't assign a role with more power than yours.")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function changeRoleHint(user: AcceptedUser, context: UserMenuContext): string | undefined {
|
||||
@@ -45,6 +61,18 @@ export function userMenuItems(user: AcceptedUser, context: UserMenuContext): Dir
|
||||
onSelect: (trigger) => context.transfer(trigger),
|
||||
})
|
||||
}
|
||||
if (context.canRemove && user.role) {
|
||||
const hint = removeHint(user, context)
|
||||
items.push({
|
||||
key: 'remove-user',
|
||||
label: t('Remove user'),
|
||||
icon: 'i-lucide-user-x',
|
||||
destructive: true,
|
||||
unavailable: hint !== undefined,
|
||||
hint,
|
||||
onSelect: (trigger) => context.removeUser(user, trigger),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '@/i18n'
|
||||
import { ApiError, type ApiErrorCode } from '@/lib/apiError'
|
||||
import { refusalCopy } from '@/lib/permissionCopy'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
@@ -31,16 +32,29 @@ function resolveText(text: ToastText) {
|
||||
return typeof text === 'function' ? text() : text
|
||||
}
|
||||
|
||||
type ToastColor = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
const TOAST_ICONS: Record<ToastColor, string> = {
|
||||
success: 'i-lucide-circle-check',
|
||||
error: 'i-lucide-circle-x',
|
||||
info: 'i-lucide-info',
|
||||
warning: 'i-lucide-triangle-alert',
|
||||
}
|
||||
|
||||
const STICKY_TOAST_MS = 0
|
||||
|
||||
export function userToast(
|
||||
id: string,
|
||||
color: 'success' | 'error',
|
||||
color: ToastColor,
|
||||
title: ToastText,
|
||||
description: ToastText,
|
||||
duration?: number,
|
||||
) {
|
||||
return () => ({
|
||||
id,
|
||||
color,
|
||||
icon: color === 'success' ? 'i-lucide-circle-check' : 'i-lucide-circle-x',
|
||||
duration,
|
||||
icon: TOAST_ICONS[color],
|
||||
get title() {
|
||||
return resolveText(title)
|
||||
},
|
||||
@@ -50,6 +64,27 @@ export function userToast(
|
||||
})
|
||||
}
|
||||
|
||||
export function staleToast(
|
||||
id: string,
|
||||
title: ToastText,
|
||||
description: ToastText,
|
||||
reload: () => void,
|
||||
actionLabel: ToastText = () => t('Reload'),
|
||||
) {
|
||||
const base = userToast(id, 'warning', title, description, STICKY_TOAST_MS)
|
||||
return () => ({
|
||||
...base(),
|
||||
actions: [
|
||||
{
|
||||
label: resolveText(actionLabel),
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: reload,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function refusalToast(id: string, refusal: DirectoryNotice) {
|
||||
return userToast(id, 'error', refusal.title ?? '', refusal.description)
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ onMounted(loadKeys)
|
||||
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
:mobile-title="t('API keys')"
|
||||
back-to="/integration"
|
||||
sticky-footer
|
||||
|
||||
@@ -9,6 +9,8 @@ import ArticleCardList from '@/components/ArticleCardList.vue'
|
||||
import ArticleListNotice from '@/components/ArticleListNotice.vue'
|
||||
import ArticleListToolbar from '@/components/ArticleListToolbar.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import OwnScopeEmpty from '@/components/OwnScopeEmpty.vue'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import { useArticleList } from '@/composables/useArticleList'
|
||||
@@ -24,11 +26,13 @@ const DEFAULT_SKELETON_ROWS = 6
|
||||
const router = useRouter()
|
||||
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
const writerLine = computed(() =>
|
||||
access.canCreate.value &&
|
||||
access.updateScope.value === 'all' &&
|
||||
access.publishScope.value === 'none'
|
||||
? t('You can write and edit all articles. Publishing is done by an editor.')
|
||||
: t("Everything you've written, online and in draft."),
|
||||
access.readsOwnOnly.value
|
||||
? t('Online articles and the drafts you created.')
|
||||
: access.canCreate.value &&
|
||||
access.updateScope.value === 'all' &&
|
||||
access.publishScope.value === 'none'
|
||||
? t('You can write and edit all articles. Publishing is done by an editor.')
|
||||
: t("Everything you've written, online and in draft."),
|
||||
)
|
||||
|
||||
const list = useArticleList({
|
||||
@@ -154,6 +158,17 @@ onMounted(() => {
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-if="access.readsOwnOnly.value"
|
||||
icon="i-lucide-eye-off"
|
||||
:title="t('Drafts by others are hidden')"
|
||||
:description="
|
||||
t(
|
||||
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ArticleListToolbar
|
||||
v-model:search="searchInput"
|
||||
:status="desired.status"
|
||||
@@ -177,8 +192,20 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<template v-if="hasLoaded || loadFailed || showSkeleton">
|
||||
<OwnScopeEmpty
|
||||
v-if="emptyVariant === 'no-drafts' && access.readsOwnOnly.value"
|
||||
icon="i-lucide-file-text"
|
||||
:title="t('You have no drafts')"
|
||||
:description="
|
||||
t(
|
||||
'Drafts you create appear here. Your role can\'t see drafts by other people.',
|
||||
)
|
||||
"
|
||||
:action="access.canCreate.value ? t('New article') : undefined"
|
||||
@action="openNewArticleModal"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="emptyVariant"
|
||||
v-else-if="emptyVariant"
|
||||
:variant="emptyVariant"
|
||||
:query="emptyVariant === 'no-site' ? siteName : shown.q"
|
||||
@action="onEmptyAction"
|
||||
|
||||
@@ -64,6 +64,12 @@ const sections = computed(() => [
|
||||
{ id: 'notes', label: t('Notes') },
|
||||
])
|
||||
|
||||
const authKeyCopy = computed(() =>
|
||||
t(
|
||||
'Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user\'s role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator\'s role lacks it.',
|
||||
),
|
||||
)
|
||||
|
||||
const authItems = computed(() => {
|
||||
const examples = authExamples(origin.value)
|
||||
return [
|
||||
@@ -487,12 +493,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
/>
|
||||
</div>
|
||||
<span class="text-pretty text-toned"
|
||||
><CodeText
|
||||
:text="
|
||||
t(
|
||||
'Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user\'s role (owner or admin). A key never has more rights than its creator.',
|
||||
)
|
||||
"
|
||||
><CodeText :text="authKeyCopy"
|
||||
/></span>
|
||||
<CodeSnippet v-model:active="authTab" :tabs="authItems" />
|
||||
<span class="text-pretty text-muted"
|
||||
|
||||
@@ -265,8 +265,8 @@ onBeforeRouteLeave(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell render-while-checking>
|
||||
<div class="flex max-w-164 flex-col gap-5">
|
||||
<DashboardShell compact-padding render-while-checking>
|
||||
<div class="flex max-w-164 flex-col gap-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Profile') }}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 RoleDifferencesDialog from '@/components/RoleDifferencesDialog.vue'
|
||||
import RoleNotices from '@/components/RoleNotices.vue'
|
||||
import RolePreview from '@/components/RolePreview.vue'
|
||||
import RoleSaveBar from '@/components/RoleSaveBar.vue'
|
||||
@@ -92,7 +93,19 @@ onMounted(editor.load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<DashboardShell compact-padding>
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="[
|
||||
{ label: t('Settings') },
|
||||
{ label: t('Roles'), to: '/settings/roles' },
|
||||
{ label: title },
|
||||
]"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="editor.loadState.value === 'loading'"
|
||||
class="flex flex-col gap-3"
|
||||
@@ -177,14 +190,14 @@ onMounted(editor.load)
|
||||
:reason="editor.readOnlyReason.value"
|
||||
:conflict="editor.conflict.value"
|
||||
:refusal="editor.refusal.value"
|
||||
:save-failed="editor.saveFailed.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"
|
||||
@review="editor.openReview"
|
||||
@retry-save="editor.save"
|
||||
/>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
@@ -218,7 +231,7 @@ onMounted(editor.load)
|
||||
</UFormField>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<section class="flex flex-col gap-3 border border-default bg-default p-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h2 class="text-base font-semibold text-highlighted">
|
||||
{{ t('Content permissions') }}
|
||||
@@ -246,9 +259,27 @@ onMounted(editor.load)
|
||||
@change="editor.changeCell"
|
||||
@reset="editor.resetType"
|
||||
/>
|
||||
<UAlert
|
||||
v-if="editor.readsOwn.value"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-info"
|
||||
:close="false"
|
||||
:title="t('Read: Own')"
|
||||
:description="
|
||||
t(
|
||||
'Articles show online articles plus the person\'s own drafts. Custom content types show only entries the person created. Status counts follow the same rule.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<section class="flex flex-col gap-3 border border-default bg-default p-4">
|
||||
<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">
|
||||
@@ -318,6 +349,17 @@ onMounted(editor.load)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RoleDifferencesDialog
|
||||
v-if="editor.conflict.value"
|
||||
:open="editor.reviewing.value"
|
||||
:description="editor.conflictSummary.value"
|
||||
:rows="editor.differenceRows.value"
|
||||
:pending="editor.keeping.value"
|
||||
@update:open="(value: boolean) => !value && editor.closeReview()"
|
||||
@use-saved="editor.reloadRole"
|
||||
@keep-edits="editor.keepEdits"
|
||||
/>
|
||||
|
||||
<RolePreview
|
||||
v-if="!isDesktop"
|
||||
v-model:open="sheetOpen"
|
||||
|
||||
@@ -10,6 +10,7 @@ import RolesList from '@/components/RolesList.vue'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import type { LoadState } from '@/composables/useRoleEditor'
|
||||
import { useRoles } from '@/composables/useRoles'
|
||||
import { staleToast, userToast } from '@/lib/userRefusal'
|
||||
import { canCover } from '@/lib/grantLocks'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
@@ -83,6 +84,48 @@ function onDeleted(role: RoleItem) {
|
||||
}))
|
||||
}
|
||||
|
||||
const INFO_TOAST_MS = 6000
|
||||
|
||||
function onRoleGone(role: RoleItem) {
|
||||
roles.value = roles.value.filter((item) => item.id !== role.id)
|
||||
toast.add(
|
||||
userToast(
|
||||
`role-gone-${role.id}`,
|
||||
'info',
|
||||
() => t('That role no longer exists'),
|
||||
() => t('It was deleted somewhere else, so we removed it from the list.'),
|
||||
INFO_TOAST_MS,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function onRoleConflict(role: RoleItem) {
|
||||
let editor = t('Someone')
|
||||
try {
|
||||
const fresh = await listRoles()
|
||||
roles.value = fresh
|
||||
editor = fresh.find((item) => item.id === role.id)?.updated_by?.name ?? editor
|
||||
} catch {
|
||||
editor = t('Someone')
|
||||
}
|
||||
const id = `role-conflict-${role.id}`
|
||||
toast.add(
|
||||
staleToast(
|
||||
id,
|
||||
() => t('Role changed elsewhere'),
|
||||
() =>
|
||||
t('{0} edited {1}, so nothing was saved. Reload and try again.', [
|
||||
editor,
|
||||
role.name,
|
||||
]),
|
||||
() => {
|
||||
toast.remove(id)
|
||||
void load()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function onDialogOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
deleting.value = null
|
||||
@@ -97,7 +140,7 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<DashboardShell compact-padding>
|
||||
<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">
|
||||
@@ -205,7 +248,9 @@ onMounted(load)
|
||||
:can-assign="canAssign"
|
||||
@update:open="onDialogOpenChange"
|
||||
@deleted="onDeleted"
|
||||
@gone="load"
|
||||
@gone="onRoleGone"
|
||||
@conflict="onRoleConflict"
|
||||
@refreshed="roles = $event"
|
||||
@duplicate="onDuplicate"
|
||||
/>
|
||||
</DashboardShell>
|
||||
|
||||
@@ -5,11 +5,18 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import ChangeRoleDialog from '@/components/ChangeRoleDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import InviteUserModal from '@/components/InviteUserModal.vue'
|
||||
import RemoveUserDialog from '@/components/RemoveUserDialog.vue'
|
||||
import UserCardList from '@/components/UserCardList.vue'
|
||||
import TransferOwnershipDialog from '@/components/TransferOwnershipDialog.vue'
|
||||
import UsersTable from '@/components/UsersTable.vue'
|
||||
import { useInviteRows } from '@/composables/useInviteRows'
|
||||
import { useRoles } from '@/composables/useRoles'
|
||||
import { useUsers } from '@/composables/useUsers'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { elideDe } from '@/lib/frenchElision'
|
||||
import { canCover } from '@/lib/grantLocks'
|
||||
import { staleToast } from '@/lib/userRefusal'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
import type { AcceptedUser, UserListItem } from '@/lib/users'
|
||||
import type { UserMenuContext } from '@/lib/userMenu'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
@@ -23,6 +30,8 @@ type LoadState = 'loading' | 'ready' | 'error'
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const { listUsers } = useUsers()
|
||||
const { listRoles } = useRoles()
|
||||
const toast = useLocalizedToast()
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const loadState = ref<LoadState>('loading')
|
||||
@@ -34,6 +43,9 @@ const { busyKeys, onInvited, confirmCancel, renew } = useInviteRows(items, highl
|
||||
const changeTarget = ref<AcceptedUser | null>(null)
|
||||
const changeOpen = ref(false)
|
||||
const transferOpen = ref(false)
|
||||
const removeTarget = ref<AcceptedUser | null>(null)
|
||||
const removeOpen = ref(false)
|
||||
const rolesById = ref<Map<number, RoleItem>>(new Map())
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let opener: HTMLElement | null = null
|
||||
|
||||
@@ -42,7 +54,8 @@ const canInvite = computed(() => permissions.can('users.invite'))
|
||||
const menu = computed<UserMenuContext | undefined>(() => {
|
||||
const canChangeRole = permissions.can('users.change_role')
|
||||
const canTransfer = permissions.can('system.transfer_ownership')
|
||||
if (!canChangeRole && !canTransfer) {
|
||||
const canRemove = permissions.can('users.remove')
|
||||
if (!canChangeRole && !canTransfer && !canRemove) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
@@ -50,8 +63,11 @@ const menu = computed<UserMenuContext | undefined>(() => {
|
||||
canChangeRole,
|
||||
canTransfer,
|
||||
canViewRoles: permissions.can('roles.view'),
|
||||
canRemove,
|
||||
canCover: canCoverRole,
|
||||
changeRole: openChangeRole,
|
||||
transfer: openTransfer,
|
||||
removeUser: openRemove,
|
||||
}
|
||||
})
|
||||
const showList = computed(
|
||||
@@ -61,6 +77,22 @@ const showOnlyYouHint = computed(
|
||||
() => loadState.value === 'ready' && items.value.length === 1 && canInvite.value,
|
||||
)
|
||||
|
||||
function canCoverRole(user: AcceptedUser) {
|
||||
const role = user.role ? rolesById.value.get(user.role.id) : undefined
|
||||
return role === undefined || canCover(role, permissions.scopeOf)
|
||||
}
|
||||
|
||||
async function loadRolesQuietly() {
|
||||
if (!permissions.can('users.remove') || permissions.role?.built_in) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
rolesById.value = new Map((await listRoles()).map((role) => [role.id, role]))
|
||||
} catch {
|
||||
rolesById.value = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
@@ -107,16 +139,24 @@ function openChangeRole(user: AcceptedUser, trigger: HTMLElement | null) {
|
||||
changeOpen.value = true
|
||||
}
|
||||
|
||||
function openRemove(user: AcceptedUser, trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
removeTarget.value = user
|
||||
removeOpen.value = true
|
||||
}
|
||||
|
||||
function openTransfer(trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
transferOpen.value = true
|
||||
}
|
||||
|
||||
function onOverlayOpenChange(target: 'change' | 'transfer', isOpen: boolean) {
|
||||
function onOverlayOpenChange(target: 'change' | 'transfer' | 'remove', isOpen: boolean) {
|
||||
if (target === 'change') {
|
||||
changeOpen.value = isOpen
|
||||
} else {
|
||||
} else if (target === 'transfer') {
|
||||
transferOpen.value = isOpen
|
||||
} else {
|
||||
removeOpen.value = isOpen
|
||||
}
|
||||
if (!isOpen) {
|
||||
restoreFocus()
|
||||
@@ -129,6 +169,34 @@ function onRoleChanged(updated: AcceptedUser) {
|
||||
)
|
||||
}
|
||||
|
||||
function onRemoved(removed: AcceptedUser) {
|
||||
items.value = items.value.filter((item) => !(item.kind === 'user' && item.id === removed.id))
|
||||
}
|
||||
|
||||
function onRemoveRoleChanged(member: AcceptedUser) {
|
||||
const id = `user-remove-stale-${member.id}`
|
||||
const name = member.display_name || member.username
|
||||
void reloadQuietly()
|
||||
toast.add(
|
||||
staleToast(
|
||||
id,
|
||||
() => t('User not removed'),
|
||||
() =>
|
||||
elideDe(
|
||||
t(
|
||||
"{0}'s role changed in the meantime, so nothing was removed. Check it and try again.",
|
||||
[name],
|
||||
),
|
||||
name,
|
||||
),
|
||||
() => {
|
||||
toast.remove(id)
|
||||
void reloadQuietly()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function openInviteModal() {
|
||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
inviteModalOpen.value = true
|
||||
@@ -147,12 +215,15 @@ function onInviteModalOpenChange(isOpen: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
onMounted(async () => {
|
||||
await loadUsers()
|
||||
await loadRolesQuietly()
|
||||
})
|
||||
onBeforeUnmount(clearSkeletonTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<DashboardShell compact-padding>
|
||||
<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">
|
||||
@@ -258,6 +329,17 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
:user="changeTarget"
|
||||
@update:open="onOverlayOpenChange('change', $event)"
|
||||
@changed="onRoleChanged"
|
||||
@refreshed="onRoleChanged"
|
||||
@stale="reloadQuietly"
|
||||
/>
|
||||
|
||||
<RemoveUserDialog
|
||||
v-if="removeTarget && removeOpen"
|
||||
:open="removeOpen"
|
||||
:user="removeTarget"
|
||||
@update:open="onOverlayOpenChange('remove', $event)"
|
||||
@removed="onRemoved"
|
||||
@role-changed="onRemoveRoleChanged"
|
||||
@stale="reloadQuietly"
|
||||
/>
|
||||
|
||||
|
||||
@@ -163,6 +163,18 @@ describe('Filter bar: each control sends its query parameter and resets the page
|
||||
})
|
||||
})
|
||||
|
||||
it('chooses Category "Roles"', async () => {
|
||||
await onPage3()
|
||||
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'roles')
|
||||
await flushPromises()
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
category: 'roles',
|
||||
})
|
||||
expect(chipTexts()).toEqual(['Category: Roles'])
|
||||
})
|
||||
|
||||
it('chooses Outcome "Succeeded", then "All"', async () => {
|
||||
await onPage3()
|
||||
await clickIn('Outcome', 'Succeeded')
|
||||
|
||||
@@ -168,3 +168,98 @@ describe('The French interface', () => {
|
||||
expect(textOf(document.body.querySelector('h1'))).toBe('Mon activité')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The role events in the list', () => {
|
||||
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
|
||||
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
|
||||
const roleChange = {
|
||||
field: 'role',
|
||||
kind: 'text' as const,
|
||||
before: 'Writer',
|
||||
after: 'Content lead',
|
||||
}
|
||||
|
||||
function roleEvents(outcome: 'success' | 'failed') {
|
||||
return [
|
||||
makeItem({
|
||||
id: 1,
|
||||
action: 'role.create',
|
||||
category: 'roles',
|
||||
outcome,
|
||||
target: roleTarget('Contributor'),
|
||||
}),
|
||||
makeItem({
|
||||
id: 2,
|
||||
action: 'role.edit',
|
||||
category: 'roles',
|
||||
outcome,
|
||||
target: roleTarget('Contributor'),
|
||||
}),
|
||||
makeItem({
|
||||
id: 3,
|
||||
action: 'role.delete',
|
||||
category: 'roles',
|
||||
outcome,
|
||||
target: roleTarget('Intern'),
|
||||
}),
|
||||
makeItem({
|
||||
id: 4,
|
||||
action: 'role.reassign',
|
||||
category: 'roles',
|
||||
outcome,
|
||||
target: roleTarget('Intern'),
|
||||
params: { from: 5, to: 3, moved: 3, to_name: 'Writer' },
|
||||
}),
|
||||
makeItem({
|
||||
id: 5,
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
outcome,
|
||||
target: userTarget('Ines Duarte'),
|
||||
changes: [roleChange],
|
||||
}),
|
||||
makeItem({
|
||||
id: 6,
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
outcome,
|
||||
target: userTarget('Jonas Weber'),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function sentences() {
|
||||
return Array.from(document.body.querySelectorAll('[data-testid="activity-sentence"]')).map(
|
||||
textOf,
|
||||
)
|
||||
}
|
||||
|
||||
it('lists the six events with their sentences', async () => {
|
||||
await mountActivity({ list: () => pageOf(roleEvents('success')) })
|
||||
|
||||
expect(sentences()).toEqual([
|
||||
'Role Contributor created',
|
||||
'Role Contributor edited',
|
||||
'Role Intern deleted',
|
||||
'Moved 3 members from Intern to Writer',
|
||||
"Ines Duarte's role changed from Writer to Content lead",
|
||||
'Ownership transferred from Marta Alves to Jonas Weber',
|
||||
])
|
||||
})
|
||||
|
||||
it('lists the six failed events with the Failed badge', async () => {
|
||||
await mountActivity({ list: () => pageOf(roleEvents('failed')) })
|
||||
|
||||
expect(sentences()).toEqual([
|
||||
'Failed to create the role Contributor',
|
||||
'Failed to edit the role Contributor',
|
||||
'Failed to delete the role Intern',
|
||||
'Failed to move 3 members from Intern to Writer',
|
||||
'Failed to change the role of Ines Duarte',
|
||||
'Failed to transfer ownership to Jonas Weber',
|
||||
])
|
||||
for (const row of rows()) {
|
||||
expect(textOf(row)).toContain('Failed')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { customAccess } from '@/test/access'
|
||||
import {
|
||||
articleOf,
|
||||
createMetaBackend,
|
||||
mountMeta,
|
||||
textOf,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
const READ_OWN = 'content.read=own, content.create, content.update=own'
|
||||
const READ_ALL = 'content.read=all, content.create, content.update=own'
|
||||
|
||||
const BOARD_ARTICLES = [
|
||||
articleOf(1, 'How we price a bathroom renovation', { is_online: true }),
|
||||
articleOf(2, 'Retrofit grants in 2027', { is_online: true }),
|
||||
articleOf(3, 'Winter checklist for timber windows', { is_online: true }),
|
||||
articleOf(4, 'Choosing lime plaster', { is_online: false }),
|
||||
articleOf(5, 'Site safety checklist', { is_online: false }),
|
||||
]
|
||||
|
||||
function backendFor(scope: string, articles = BOARD_ARTICLES): MetaBackend {
|
||||
const backend = createMetaBackend()
|
||||
backend.me.access = customAccess(scope, 'Junior writer')
|
||||
backend.articles = articles
|
||||
return backend
|
||||
}
|
||||
|
||||
function tabs() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).map((tab) =>
|
||||
textOf(tab).replace(/\s+/g, ' '),
|
||||
)
|
||||
}
|
||||
|
||||
function page() {
|
||||
return textOf(document.body)
|
||||
}
|
||||
|
||||
describe('Articles list: Read = Own with drafts of their own', () => {
|
||||
it('reads the header, the banner and the tabs from the counts of the answer', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendFor(READ_OWN) })
|
||||
|
||||
expect(page()).toContain('Online articles and the drafts you created.')
|
||||
expect(page()).toContain('Drafts by others are hidden')
|
||||
expect(page()).toContain(
|
||||
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
|
||||
)
|
||||
expect(tabs()).toEqual(expect.arrayContaining(['All 5', 'Online 3', 'Drafts 2']))
|
||||
})
|
||||
|
||||
it('keeps New article enabled when the role can create', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendFor(READ_OWN) })
|
||||
|
||||
const button = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(candidate) => textOf(candidate) === 'New article',
|
||||
)
|
||||
expect(button).toBeDefined()
|
||||
expect(button!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('takes the tabs exactly from the API whatever the scope', async () => {
|
||||
const backend = backendFor(READ_OWN)
|
||||
backend.responders['GET /v1/article/'] = () => ({
|
||||
items: BOARD_ARTICLES.slice(0, 2),
|
||||
total: 2,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 2, online: 2, draft: 0, suggestions: 0 },
|
||||
})
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(tabs()).toEqual(expect.arrayContaining(['All 2', 'Online 2', 'Drafts 0']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Articles list: no drafts of their own', () => {
|
||||
it('shows the own-drafts empty state on the Draft tab with the counts 3, 3, 0', async () => {
|
||||
await mountMeta('articles', '/article?status=draft', {
|
||||
backend: backendFor(READ_OWN, BOARD_ARTICLES.slice(0, 3)),
|
||||
})
|
||||
|
||||
expect(page()).toContain('You have no drafts')
|
||||
expect(page()).toContain(
|
||||
"Drafts you create appear here. Your role can't see drafts by other people.",
|
||||
)
|
||||
expect(tabs()).toEqual(expect.arrayContaining(['All 3', 'Online 3', 'Drafts 0']))
|
||||
const action = Array.from(
|
||||
document.body.querySelectorAll('main button, #dashboard-content button'),
|
||||
).find((candidate) => textOf(candidate) === 'New article')
|
||||
expect(action).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides the New article action when the role cannot create', async () => {
|
||||
await mountMeta('articles', '/article?status=draft', {
|
||||
backend: backendFor('content.read=own', BOARD_ARTICLES.slice(0, 3)),
|
||||
})
|
||||
|
||||
expect(page()).toContain('You have no drafts')
|
||||
expect(page()).not.toContain('New article')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Articles list: Read = All is unchanged', () => {
|
||||
it('shows no banner and the existing header', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendFor(READ_ALL) })
|
||||
|
||||
expect(page()).not.toContain('Drafts by others are hidden')
|
||||
expect(page()).not.toContain('Online articles and the drafts you created.')
|
||||
})
|
||||
|
||||
it('keeps the existing empty state of the Draft tab', async () => {
|
||||
await mountMeta('articles', '/article?status=draft', {
|
||||
backend: backendFor(READ_ALL, BOARD_ARTICLES.slice(0, 3)),
|
||||
})
|
||||
|
||||
expect(page()).toContain('No drafts')
|
||||
expect(page()).not.toContain('You have no drafts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Articles list: Read = Own in French', () => {
|
||||
it('reads the banner and the empty state in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountMeta('articles', '/article?status=draft', {
|
||||
backend: backendFor(READ_OWN, BOARD_ARTICLES.slice(0, 3)),
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(page()).toContain('Les brouillons des autres sont masqués')
|
||||
expect(page()).toContain('Vous n’avez aucun brouillon')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { customAccess } from '@/test/access'
|
||||
import {
|
||||
customTypes,
|
||||
eventEntries,
|
||||
installSelectPolyfills,
|
||||
listPage,
|
||||
lookupNames,
|
||||
mountList,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './contentTestKit'
|
||||
import { failWithBody, mountEditor, main as editorMain } from './entryEditorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(installSelectPolyfills)
|
||||
|
||||
afterEach(async () => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const READ_OWN = 'content.read:events=own, content.create:events=all'
|
||||
const READ_ALL = 'content.read:events=all, content.create:events=all'
|
||||
|
||||
function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
function accessWith(spec: string) {
|
||||
return customAccess(spec, 'Junior writer')
|
||||
}
|
||||
|
||||
function newEntryButtons() {
|
||||
return [...main().querySelectorAll('button')].filter(
|
||||
(candidate) => text(candidate) === 'New entry',
|
||||
)
|
||||
}
|
||||
|
||||
const three = (_key: string, query: URLSearchParams) => listPage(eventEntries.slice(0, 3), 3, query)
|
||||
const none = (_key: string, query: URLSearchParams) => listPage([], 0, query)
|
||||
|
||||
describe('Entry list: Read = Own with entries of their own', () => {
|
||||
it('reads the header, the banner and the count line from the total', async () => {
|
||||
await mountList('/content/events', {
|
||||
access: accessWith(READ_OWN),
|
||||
list: three,
|
||||
lookup: lookupNames,
|
||||
})
|
||||
|
||||
expect(text(main())).toContain('Events')
|
||||
expect(text(main())).toContain('Your role shows only entries you created.')
|
||||
expect(text(main())).toContain("Entries by others aren't shown")
|
||||
expect(text(main())).toContain(
|
||||
'Your role reads only entries you created. Ask an owner or admin if you need to see more.',
|
||||
)
|
||||
expect(text(main())).toContain('3 entries')
|
||||
expect(main().querySelector('nav[aria-label*="pagination" i]')).toBeNull()
|
||||
expect(newEntryButtons().length).toBeGreaterThan(0)
|
||||
expect(newEntryButtons().every((button) => !button.disabled)).toBe(true)
|
||||
})
|
||||
|
||||
it('shows neither the banner nor the own-scope header under Read = All', async () => {
|
||||
await mountList('/content/events', {
|
||||
access: accessWith(READ_ALL),
|
||||
list: three,
|
||||
lookup: lookupNames,
|
||||
})
|
||||
|
||||
expect(text(main())).not.toContain("Entries by others aren't shown")
|
||||
expect(text(main())).not.toContain('Your role shows only entries you created.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Entry list: Read = Own with no entries of their own', () => {
|
||||
it('says what the person has not created, without hinting at other entries', async () => {
|
||||
await mountList('/content/events', { access: accessWith(READ_OWN), list: none })
|
||||
|
||||
expect(text(main())).toContain("You haven't created an event yet")
|
||||
expect(text(main())).toContain(
|
||||
"Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one.",
|
||||
)
|
||||
expect(text(main())).not.toContain('No entries yet')
|
||||
expect(newEntryButtons().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('hides the action when the role cannot create', async () => {
|
||||
await mountList('/content/events', {
|
||||
access: accessWith('content.read:events=own'),
|
||||
list: none,
|
||||
})
|
||||
|
||||
expect(text(main())).toContain("You haven't created an event yet")
|
||||
expect(newEntryButtons()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the existing empty state under Read = All', async () => {
|
||||
await mountList('/content/events', { access: accessWith(READ_ALL), list: none })
|
||||
|
||||
expect(text(main())).toContain('No entries yet')
|
||||
expect(text(main())).not.toContain("You haven't created an event yet")
|
||||
})
|
||||
|
||||
it('reads in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountList('/content/events', { access: accessWith(READ_OWN), list: none })
|
||||
|
||||
expect(text(main())).toContain('Vous n’avez pas encore créé d’entrée dans Events')
|
||||
expect(text(main())).toContain('Les entrées des autres ne sont pas affichées')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Entry list: the singular noun comes from the type name', () => {
|
||||
it.each([
|
||||
[
|
||||
'Recipes',
|
||||
"You haven't created a recipe yet",
|
||||
"other people's recipes aren't listed here",
|
||||
],
|
||||
[
|
||||
'Press mentions',
|
||||
"You haven't created a press mention yet",
|
||||
"other people's press mentions aren't listed here",
|
||||
],
|
||||
[
|
||||
'Categories',
|
||||
"You haven't created a category yet",
|
||||
"other people's categories aren't listed here",
|
||||
],
|
||||
])('%s', async (name, title, fragment) => {
|
||||
await mountList('/content/events', {
|
||||
access: accessWith(READ_OWN),
|
||||
list: none,
|
||||
type: () => ({ ...customTypes[0]!, name }),
|
||||
})
|
||||
|
||||
expect(text(main())).toContain(title)
|
||||
expect(text(main())).toContain(fragment)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Entry list: a direct link to a hidden entry', () => {
|
||||
it('shows the existing Entry not found page and nothing about the entry', async () => {
|
||||
await mountEditor('/content/events/1', {
|
||||
access: accessWith(READ_OWN),
|
||||
entry: failWithBody(404, 'entry_not_found'),
|
||||
})
|
||||
|
||||
expect(text(editorMain())).toContain('Entry not found')
|
||||
expect(text(editorMain())).not.toContain('created by')
|
||||
})
|
||||
})
|
||||
@@ -228,3 +228,167 @@ describe('A missing target', () => {
|
||||
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load more: the next page is loading', () => {
|
||||
const TOTAL = 62
|
||||
const PAGE = 25
|
||||
const SKELETON_ROWS = 3
|
||||
const pageNames = (page: number) =>
|
||||
Array.from(
|
||||
{ length: Math.max(0, Math.min(PAGE, TOTAL - (page - 1) * PAGE)) },
|
||||
(_, index) => `Rue Verte hall ${(page - 1) * PAGE + index + 1}`,
|
||||
)
|
||||
const answerPage = (page: number) => ({
|
||||
items: pageNames(page).map((name, index) => ({
|
||||
id: 100 + (page - 1) * PAGE + index,
|
||||
data: { name, city: '', details: '' },
|
||||
relations: {},
|
||||
})),
|
||||
total: TOTAL,
|
||||
page,
|
||||
size: PAGE,
|
||||
})
|
||||
const moreSkeletons = () =>
|
||||
document.body.querySelectorAll('[data-testid="relation-more-skeleton"]')
|
||||
const footer = () => text(document.body.querySelector('[data-testid="relation-footer"]'))
|
||||
const loadMore = () => button('Load more') ?? button('Loading…')
|
||||
|
||||
function pending(release: { resolve?: (value: unknown) => void } = {}) {
|
||||
return (_key: string, query: URLSearchParams) => {
|
||||
const page = Number(query.get('page'))
|
||||
if (page === 1) {
|
||||
return answerPage(1)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
release.resolve = () => resolve(answerPage(page))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('shows the footer count with Load more before the press', async () => {
|
||||
await mountRelation('/content/events/42', {
|
||||
search: (_key, query) => answerPage(Number(query.get('page'))),
|
||||
})
|
||||
await openList('venue', vi, 'rue')
|
||||
|
||||
expect(footer()).toContain('Showing 25 of 62')
|
||||
expect(button('Load more')).toBeDefined()
|
||||
expect(moreSkeletons()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sends page 2, shows skeleton rows after the last result and a disabled Loading… button', async () => {
|
||||
await mountRelation('/content/events/42', { search: pending() })
|
||||
await openList('venue', vi, 'rue')
|
||||
|
||||
button('Load more')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?q=rue&page=2&size=25')
|
||||
expect(moreSkeletons()).toHaveLength(SKELETON_ROWS)
|
||||
const loading = button('Loading…')!
|
||||
expect(loading.getAttribute('aria-disabled') ?? String(loading.disabled)).toBe('true')
|
||||
expect(button('Load more')).toBeUndefined()
|
||||
expect(footer()).toContain('Showing 25 of 62')
|
||||
expect(optionLabels()).toHaveLength(25)
|
||||
})
|
||||
|
||||
it('sends nothing on a second press while loading', async () => {
|
||||
await mountRelation('/content/events/42', { search: pending() })
|
||||
await openList('venue', vi, 'rue')
|
||||
button('Load more')!.click()
|
||||
await flushPromises()
|
||||
const before = searches().length
|
||||
|
||||
button('Loading…')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(searches()).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('replaces the skeleton with the new results and moves focus to the first new row', async () => {
|
||||
const release: { resolve?: (value: unknown) => void } = {}
|
||||
await mountRelation('/content/events/42', { search: pending(release) })
|
||||
await openList('venue', vi, 'rue')
|
||||
button('Load more')!.click()
|
||||
await flushPromises()
|
||||
|
||||
release.resolve!(null)
|
||||
await flushPromises()
|
||||
|
||||
expect(moreSkeletons()).toHaveLength(0)
|
||||
expect(optionLabels()).toHaveLength(50)
|
||||
expect(footer()).toContain('Showing 50 of 62')
|
||||
expect(button('Load more')).toBeDefined()
|
||||
expect(text(document.activeElement)).toContain('Rue Verte hall 26')
|
||||
})
|
||||
|
||||
it('ends with Showing 62 of 62 and no button on the last page', async () => {
|
||||
await mountRelation('/content/events/42', {
|
||||
search: (_key, query) => answerPage(Number(query.get('page'))),
|
||||
})
|
||||
await openList('venue', vi, 'rue')
|
||||
button('Load more')!.click()
|
||||
await flushPromises()
|
||||
button('Load more')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(footer()).toContain('Showing 62 of 62')
|
||||
expect(loadMore()).toBeUndefined()
|
||||
expect(optionLabels()).toHaveLength(62)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', () => new ApiError(500)],
|
||||
['a network failure', () => new NetworkError()],
|
||||
])(
|
||||
'restores Load more, keeps the results and refocuses the button when the page answers %s',
|
||||
async (_failure, error) => {
|
||||
await mountRelation('/content/events/42', {
|
||||
search: (_key, query) => {
|
||||
if (Number(query.get('page')) === 1) {
|
||||
return answerPage(1)
|
||||
}
|
||||
throw error()
|
||||
},
|
||||
})
|
||||
await openList('venue', vi, 'rue')
|
||||
button('Load more')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(moreSkeletons()).toHaveLength(0)
|
||||
expect(optionLabels()).toHaveLength(25)
|
||||
expect(button('Load more')).toBeDefined()
|
||||
expect(document.activeElement).toBe(button('Load more'))
|
||||
},
|
||||
)
|
||||
|
||||
it('ignores the pending page when a new term is typed', async () => {
|
||||
const release: { resolve?: (value: unknown) => void } = {}
|
||||
await mountRelation('/content/events/42', {
|
||||
search: (_key, query) => {
|
||||
const page = Number(query.get('page'))
|
||||
if (query.get('q') === 'port') {
|
||||
return { items: [], total: 0, page: 1, size: PAGE }
|
||||
}
|
||||
if (page === 1) {
|
||||
return answerPage(1)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
release.resolve = () => resolve(answerPage(2))
|
||||
})
|
||||
},
|
||||
})
|
||||
await openList('venue', vi, 'rue')
|
||||
button('Load more')!.click()
|
||||
await flushPromises()
|
||||
|
||||
await typeSearch('venue', 'port')
|
||||
await settleSearch(vi)
|
||||
release.resolve!(null)
|
||||
await flushPromises()
|
||||
|
||||
expect(moreSkeletons()).toHaveLength(0)
|
||||
expect(optionLabels()).toEqual([])
|
||||
expect(footer()).not.toContain('Showing 50')
|
||||
})
|
||||
})
|
||||
@@ -223,20 +223,20 @@ describe('Default, every card collapsed', () => {
|
||||
it.each([
|
||||
['GET /v1/content-type/', 'Verified user'],
|
||||
['GET /v1/content-type/{key}', 'Verified user'],
|
||||
['POST /v1/content-type/', 'Owner or admin'],
|
||||
['PUT /v1/content-type/{key}', 'Owner or admin'],
|
||||
['DELETE /v1/content-type/{key}', 'Owner or admin'],
|
||||
['POST /v1/content-type/', 'Needs permission'],
|
||||
['PUT /v1/content-type/{key}', 'Needs permission'],
|
||||
['DELETE /v1/content-type/{key}', 'Needs permission'],
|
||||
['GET /v1/content-type/{key}/entry', 'Verified user'],
|
||||
['POST /v1/content-type/{key}/entry', 'Verified user'],
|
||||
['GET /v1/content-type/{key}/entry/{id}', 'Verified user'],
|
||||
['PUT /v1/content-type/{key}/entry/{id}', 'Verified user'],
|
||||
['DELETE /v1/content-type/{key}/entry/{id}', 'Verified user'],
|
||||
['GET /v1/builtin-type/', 'Owner or admin'],
|
||||
['GET /v1/builtin-type/{key}', 'Owner or admin'],
|
||||
['GET /v1/media/purge-settings', 'Owner or admin'],
|
||||
['PATCH /v1/media/purge-settings', 'Owner or admin'],
|
||||
['GET /v1/media/purge-preview', 'Owner or admin'],
|
||||
['POST /v1/media/purge', 'Owner or admin'],
|
||||
['GET /v1/builtin-type/', 'Needs permission'],
|
||||
['GET /v1/builtin-type/{key}', 'Needs permission'],
|
||||
['GET /v1/media/purge-settings', 'Needs permission'],
|
||||
['PATCH /v1/media/purge-settings', 'Needs permission'],
|
||||
['GET /v1/media/purge-preview', 'Needs permission'],
|
||||
['POST /v1/media/purge', 'Needs permission'],
|
||||
['GET /v1/site/', 'Verified user'],
|
||||
['GET /v1/author/', 'Verified user'],
|
||||
])('shows %s with the chip %s and API key OK', async (route, access) => {
|
||||
@@ -252,10 +252,10 @@ describe('Default, every card collapsed', () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(section(wrapper, 'ctypes').text()).toContain(
|
||||
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
|
||||
'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
|
||||
)
|
||||
expect(section(wrapper, 'entries').text()).toContain(
|
||||
'Read and write the content of a custom type. Any verified user can write entries.',
|
||||
"Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role.",
|
||||
)
|
||||
const builtin = section(wrapper, 'builtin').text()
|
||||
expect(builtin).toContain('Built-in types are read-only')
|
||||
@@ -462,7 +462,7 @@ describe('Authentication', () => {
|
||||
expect(auth.text()).toContain('GET /v1/article/')
|
||||
expect(auth.text()).toContain('GET /v1/article/{id}')
|
||||
expect(auth.text()).toContain(
|
||||
"Send Authorization: Bearer rcms_…. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.",
|
||||
'Send Authorization: Bearer rcms_…. Requests act as the user who created the key, with that user\'s role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator\'s role lacks it.',
|
||||
)
|
||||
expect(auth.text()).toContain(
|
||||
'Keys start with rcms_ and are shown in full once, when you create them.',
|
||||
@@ -696,14 +696,17 @@ describe('Errors table rows of the custom content types round', () => {
|
||||
|
||||
const order = [
|
||||
'This action needs a real session.',
|
||||
'missing_permission',
|
||||
'You must verify your account to access this resource.',
|
||||
'missing_permission',
|
||||
].map((message) => text.indexOf(message))
|
||||
expect(order.every((position) => position > -1)).toBe(true)
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b))
|
||||
expect(forbidden.text()).toContain(
|
||||
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
|
||||
'missing_permission is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.',
|
||||
)
|
||||
expect(codes(forbidden)).toContain('missing_permission')
|
||||
expect(forbidden.text()).not.toContain('Only owners and admins can do this.')
|
||||
expect(forbidden.text()).not.toContain('Only the owner can do this.')
|
||||
})
|
||||
|
||||
it('adds the new codes to the 404, 409 and 422 rows and explains code style', async () => {
|
||||
@@ -1129,8 +1132,9 @@ describe('Authentication copy of the custom content types round', () => {
|
||||
)
|
||||
const auth = section(wrapper, 'auth').text()
|
||||
expect(auth).toContain(
|
||||
"Requests act as the user who created the key, with that user's role (owner or admin).",
|
||||
"Requests act as the user who created the key, with that user's role as it is now.",
|
||||
)
|
||||
expect(auth).not.toContain('(owner or admin)')
|
||||
expect(auth).toContain(
|
||||
'Routes that need a permission answer 403 "missing_permission" to a key when the role of its creator does not hold it.',
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSessionStore, type Session } from '@/stores/session'
|
||||
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { accessFor, customAccess } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -942,25 +943,67 @@ function roleRequests(request: ReturnType<typeof vi.fn>) {
|
||||
return request.mock.calls.filter(([path]) => String(path).startsWith('/v1/role'))
|
||||
}
|
||||
|
||||
function sessionWith(access: Access): Session {
|
||||
return { ...MARTA, access }
|
||||
}
|
||||
|
||||
function roleCard() {
|
||||
return [...document.body.querySelectorAll('h2')]
|
||||
.find((heading) => heading.textContent?.trim() === 'Your role')!
|
||||
.closest('section')!
|
||||
}
|
||||
|
||||
function roleBadges() {
|
||||
return [...roleCard().querySelectorAll('span[data-slot="base"]')]
|
||||
.map((badge) => badge.textContent?.trim())
|
||||
.filter((text) => text)
|
||||
}
|
||||
|
||||
describe('Profile: your role', () => {
|
||||
it('shows the role card with the badge, the assigner and the keys sentence', async () => {
|
||||
it.each([
|
||||
['a custom role', contributor(MARTA_ASSIGNER), ['Contributor', 'Custom']],
|
||||
[
|
||||
'the locked Owner',
|
||||
sessionWith({
|
||||
role: { id: 1, name: 'Owner', built_in: true, locked: true },
|
||||
permissions: customAccess('content.read=all').permissions,
|
||||
}),
|
||||
['owner', 'Locked', 'Built-in'],
|
||||
],
|
||||
[
|
||||
'a built-in Writer',
|
||||
sessionWith({
|
||||
role: { id: 3, name: 'Writer', built_in: true, locked: false },
|
||||
permissions: customAccess('content.read=all').permissions,
|
||||
}),
|
||||
['writer', 'Built-in'],
|
||||
],
|
||||
])('shows the badge and the tags for %s in one row', async (_label, session, expected) => {
|
||||
await mountApp({ session })
|
||||
|
||||
expect(roleBadges()).toEqual(expected)
|
||||
expect(roleCard().textContent).toContain(KEYS_SENTENCE)
|
||||
expect(buttonByText(/^See what I can do$/)).toBeDefined()
|
||||
expect(roleCard().querySelector('[class*="i-lucide-list-checks"], svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('hides Locked when not locked and Custom when built in', async () => {
|
||||
await mountApp({ session: contributor(MARTA_ASSIGNER) })
|
||||
|
||||
expect(pageText()).toContain('Your role')
|
||||
expect(pageText()).toContain('Contributor')
|
||||
expect(pageText()).toContain('Assigned by Marta Alves')
|
||||
expect(pageText()).toContain(KEYS_SENTENCE)
|
||||
expect(buttonByText(/^See what I can do$/)).toBeDefined()
|
||||
expect(roleCard().textContent).not.toContain('Locked')
|
||||
expect(roleCard().textContent).not.toContain('Built-in')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an object', MARTA_ASSIGNER],
|
||||
['null', null],
|
||||
['an absent key', undefined],
|
||||
])('renders no Assigned by text for %s', async (_label, assigner) => {
|
||||
])('shows no summary line and no Assigned by text with %s', async (_label, assigner) => {
|
||||
await mountApp({ session: contributor(assigner) })
|
||||
|
||||
expect(pageText()).toContain('Your role')
|
||||
expect(pageText()).not.toContain('Assigned by')
|
||||
expect(pageText()).not.toContain('Creates content and edits only their own.')
|
||||
})
|
||||
|
||||
it('places the card after the Account card', async () => {
|
||||
@@ -970,21 +1013,23 @@ describe('Profile: your role', () => {
|
||||
expect(titles.indexOf('Your role')).toBe(titles.indexOf('Account') + 1)
|
||||
})
|
||||
|
||||
it('follows a refreshed session for the assigner and the role', async () => {
|
||||
it('follows a refreshed session for the badge, the tags and the summary', async () => {
|
||||
await mountApp({ session: contributor(MARTA_ASSIGNER) })
|
||||
expect(roleBadges()).toEqual(['Contributor', 'Custom'])
|
||||
|
||||
useSessionStore().session = {
|
||||
...contributor({ id: 2, name: 'Jonas Weber' }),
|
||||
const writer: Access = {
|
||||
role: { id: 3, name: 'Writer', built_in: true, locked: false },
|
||||
permissions: customAccess('content.read=all').permissions,
|
||||
}
|
||||
await flushPromises()
|
||||
expect(pageText()).toContain('Assigned by Jonas Weber')
|
||||
expect(pageText()).not.toContain('Assigned by Marta Alves')
|
||||
|
||||
const writer = customAccess('content.read=all', 'Writer')
|
||||
useSessionStore().session = { ...MARTA, access: writer }
|
||||
await flushPromises()
|
||||
expect(pageText()).toContain('Writer')
|
||||
expect(pageText()).not.toContain('Assigned by')
|
||||
|
||||
expect(roleBadges()).toEqual(['writer', 'Built-in'])
|
||||
buttonByText(/^See what I can do$/)!.click()
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
|
||||
'A summary of your role, Writer. Read-only.',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the summary dialog from the session grants without reading roles, and Close returns focus', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { setLocale } from '@/i18n'
|
||||
import RoleEditorView from '../RoleEditorView.vue'
|
||||
import { radiosOf } from '@/components/__tests__/roleKit'
|
||||
import {
|
||||
@@ -106,6 +107,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
setLocale('en')
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
@@ -431,9 +433,6 @@ describe('Role editor: Errors on save', () => {
|
||||
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')
|
||||
@@ -489,11 +488,82 @@ describe('Role editor: Errors on save', () => {
|
||||
expect(page()).toContain('Ask another owner or admin.')
|
||||
})
|
||||
|
||||
it('shows a generic toast for another failure', async () => {
|
||||
await failWith(refuse(500, 'boom'))
|
||||
function saveFailedAlert() {
|
||||
return Array.from(document.body.querySelectorAll('[role="alert"]')).find((alert) =>
|
||||
textOf(alert).includes("Couldn't save the role"),
|
||||
)
|
||||
}
|
||||
|
||||
expect(toastTitles()).toHaveLength(1)
|
||||
expect(saveBar()).not.toBeNull()
|
||||
it.each([
|
||||
['500', refuse(500, 'boom')],
|
||||
[
|
||||
'a network failure',
|
||||
() => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
},
|
||||
],
|
||||
])(
|
||||
'shows the save failed alert on %s, keeps the edits and Save on',
|
||||
async (_failure, handler) => {
|
||||
await failWith(handler)
|
||||
|
||||
expect(saveFailedAlert()).toBeDefined()
|
||||
expect(textOf(saveFailedAlert())).toContain(
|
||||
'Your changes are still here. Try again in a moment.',
|
||||
)
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(checked('Events: Delete')).toBe('Own')
|
||||
expect(saveBar()).not.toBeNull()
|
||||
expect(buttonNamed('Save changes')!.disabled).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('sends the same save again from Try again', async () => {
|
||||
let calls = 0
|
||||
const backend = await failWith(() => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
throw new ApiError(500)
|
||||
}
|
||||
return { ...CONTRIBUTOR, version: 3 }
|
||||
})
|
||||
|
||||
const retry = Array.from(saveFailedAlert()!.querySelectorAll('button')).find(
|
||||
(candidate) => textOf(candidate) === 'Try again',
|
||||
)!
|
||||
retry.click()
|
||||
await flushPromises()
|
||||
|
||||
const puts = backend.callsTo('PUT', '/v1/role/4')
|
||||
expect(puts).toHaveLength(2)
|
||||
expect(puts[1]!.body).toEqual(puts[0]!.body)
|
||||
expect(saveFailedAlert()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows the same alert when creating a role fails', async () => {
|
||||
const backend = backendFor(null, { 'POST /v1/role/': refuse(500, 'boom') })
|
||||
await open('/settings/roles/new', backend)
|
||||
await type(nameInput(), 'Reviewer')
|
||||
await press('Save changes')
|
||||
|
||||
expect(backend.callsTo('POST', '/v1/role/')).toHaveLength(1)
|
||||
expect(saveFailedAlert()).toBeDefined()
|
||||
expect(nameInput().value).toBe('Reviewer')
|
||||
expect(buttonNamed('Save changes')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the alert in French', async () => {
|
||||
await failWith(refuse(500, 'boom'))
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
|
||||
const alert = Array.from(document.body.querySelectorAll('[role="alert"]')).find(
|
||||
(candidate) => textOf(candidate).includes("Impossible d'enregistrer le rôle"),
|
||||
)!
|
||||
expect(textOf(alert).replace(/\u2019/g, "'")).toContain(
|
||||
'Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
)
|
||||
expect(textOf(alert)).toContain('Réessayer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -879,3 +949,202 @@ describe('Role editor: unknown reset failure', () => {
|
||||
expect(page()).not.toContain('This role changed while you were editing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: stale save recovery', () => {
|
||||
const SAVED_AFTER_JONAS = {
|
||||
...CONTRIBUTOR,
|
||||
version: 3,
|
||||
updated_at: '2026-10-27T11:02:00Z',
|
||||
updated_by: JONAS,
|
||||
}
|
||||
|
||||
async function staleWithTwoEdits(extra: Record<string, Handler> = {}) {
|
||||
let reads = 0
|
||||
const backend = new Backend({
|
||||
'GET /v1/permission/': () => ({ groups: CATALOGUE }),
|
||||
'GET /v1/content-type': () => TYPES,
|
||||
'GET /v1/role/4': () => (++reads === 1 ? CONTRIBUTOR : SAVED_AFTER_JONAS),
|
||||
'PUT /v1/role/4': refuse(409, 'role_conflict'),
|
||||
...extra,
|
||||
})
|
||||
await open('/settings/roles/4', backend)
|
||||
await choose('Events: Update', 'All')
|
||||
await choose('Events: Delete', 'Own')
|
||||
await press('Save changes')
|
||||
return backend
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector('[role="alertdialog"]')
|
||||
}
|
||||
|
||||
function tableRows() {
|
||||
return Array.from(dialog()!.querySelectorAll('[role="row"]')).map((row) =>
|
||||
Array.from(row.querySelectorAll('[role="cell"], [role="columnheader"]')).map(textOf),
|
||||
)
|
||||
}
|
||||
|
||||
it('lists only the settings where the saved role and the edits disagree', async () => {
|
||||
await staleWithTwoEdits()
|
||||
|
||||
await press('Review differences')
|
||||
|
||||
const text = textOf(dialog()!)
|
||||
expect(text).toContain('How the saved role differs from your edits')
|
||||
expect(text).toContain('Jonas Weber saved changes to Contributor at 11:02.')
|
||||
expect(tableRows()).toEqual([
|
||||
['Setting', 'Saved', 'Yours'],
|
||||
['Events · Update', 'Own', 'All'],
|
||||
['Events · Delete', 'None', 'Own'],
|
||||
])
|
||||
expect(buttonNamed('Use the saved role', dialog()!)).toBeDefined()
|
||||
expect(buttonNamed('Keep my edits', dialog()!)).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the edits on top of the reloaded role, turns Save on and says so in an info toast', async () => {
|
||||
const backend = await staleWithTwoEdits({
|
||||
'PUT /v1/role/4': (() => {
|
||||
let attempts = 0
|
||||
return () => {
|
||||
if (++attempts === 1) {
|
||||
throw new ApiError(409, undefined, 'role_conflict', {
|
||||
code: 'role_conflict' as never,
|
||||
})
|
||||
}
|
||||
return { ...SAVED_AFTER_JONAS, version: 4 }
|
||||
}
|
||||
})(),
|
||||
})
|
||||
await press('Review differences')
|
||||
|
||||
await press('Keep my edits')
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(page()).not.toContain('This role changed while you were editing')
|
||||
expect(checked('Events: Update')).toBe('All')
|
||||
expect(checked('Events: Delete')).toBe('Own')
|
||||
expect(buttonNamed('Save changes')!.disabled).toBe(false)
|
||||
expect(page()).toContain('2 unsaved changes')
|
||||
expect(toastTitles()).toContainEqual({
|
||||
title: 'Role reloaded',
|
||||
description: 'Your 2 changes are still here. Review them, then save.',
|
||||
})
|
||||
expect(toastAdd.mock.calls.at(-1)![0]).toMatchObject({ color: 'info', duration: 6000 })
|
||||
|
||||
await press('Save changes')
|
||||
|
||||
expect(backend.callsTo('PUT', '/v1/role/4').at(-1)!.body).toMatchObject({ version: 3 })
|
||||
})
|
||||
|
||||
it('drops the edits with Use the saved role and shows no toast about edits', async () => {
|
||||
await staleWithTwoEdits()
|
||||
await press('Review differences')
|
||||
|
||||
await press('Use the saved role')
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(checked('Events: Update')).toBe('Own')
|
||||
expect(checked('Events: Delete')).toBe('None')
|
||||
expect(saveBar()).toBeNull()
|
||||
expect(toastTitles().map((toast) => toast.title)).not.toContain('Role reloaded')
|
||||
})
|
||||
|
||||
it('closes the dialog on Escape and keeps the banner and the edits', async () => {
|
||||
await staleWithTwoEdits()
|
||||
await press('Review differences')
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(page()).toContain('This role changed while you were editing')
|
||||
expect(checked('Events: Delete')).toBe('Own')
|
||||
expect(buttonNamed('Save changes')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('brings the banner back with the newest author when the save conflicts again', async () => {
|
||||
const backend = await staleWithTwoEdits()
|
||||
await press('Review differences')
|
||||
await press('Keep my edits')
|
||||
|
||||
await press('Save changes')
|
||||
|
||||
expect(backend.callsTo('PUT', '/v1/role/4')).toHaveLength(2)
|
||||
expect(page()).toContain('This role changed while you were editing')
|
||||
expect(checked('Events: Delete')).toBe('Own')
|
||||
})
|
||||
|
||||
it('reads the dialog and the toast in French', async () => {
|
||||
await staleWithTwoEdits()
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
|
||||
await press('Voir les différences')
|
||||
|
||||
const text = textOf(dialog()!)
|
||||
expect(text).toContain('Comment le rôle enregistré diffère de vos modifications')
|
||||
expect(tableRows()[0]).toEqual(['Réglage', 'Enregistré', 'Vous'])
|
||||
expect(text).toContain('Utiliser le rôle enregistré')
|
||||
|
||||
await press('Garder mes modifications')
|
||||
|
||||
expect(toastTitles()).toContainEqual({
|
||||
title: 'Rôle rechargé',
|
||||
description: 'Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Read = Own note', () => {
|
||||
const NOTE = 'Read: Own'
|
||||
|
||||
function junior(readScope: 'own' | 'all' = 'own') {
|
||||
return role({
|
||||
id: 6,
|
||||
name: 'Junior writer',
|
||||
grants: grants(`content.read=${readScope}, content.create`),
|
||||
})
|
||||
}
|
||||
|
||||
it('shows the info alert with the exact copy when a Read cell is Own', async () => {
|
||||
await open('/settings/roles/6', backendFor(junior()))
|
||||
|
||||
expect(page()).toContain(NOTE)
|
||||
expect(page()).toContain(
|
||||
"Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule.",
|
||||
)
|
||||
})
|
||||
|
||||
it('is absent when no Read cell is Own', async () => {
|
||||
await open('/settings/roles/6', backendFor(junior('all')))
|
||||
|
||||
expect(page()).not.toContain(NOTE)
|
||||
})
|
||||
|
||||
it('appears and disappears live as the cell changes', async () => {
|
||||
await open('/settings/roles/6', backendFor(junior('all')))
|
||||
|
||||
await choose('Events: Read', 'Own')
|
||||
expect(page()).toContain(NOTE)
|
||||
|
||||
await choose('Events: Read', 'All')
|
||||
expect(page()).not.toContain(NOTE)
|
||||
})
|
||||
|
||||
it('renders the same way without a Save bar for a person who can only view roles', async () => {
|
||||
await open('/settings/roles/6', backendFor(junior()), customAccess('roles.view'))
|
||||
|
||||
expect(page()).toContain(NOTE)
|
||||
expect(page()).toContain('Can read online articles and their own drafts')
|
||||
expect(page()).toContain('Can read only entries they created')
|
||||
expect(saveBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('reads in French', async () => {
|
||||
setLocale('fr')
|
||||
await open('/settings/roles/6', backendFor(junior()))
|
||||
|
||||
expect(page()).toContain('Lecture : Les siennes')
|
||||
expect(page()).toContain('Les compteurs de statut suivent la même règle.')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,9 @@ import RolesListView from '../RolesListView.vue'
|
||||
import { Backend, buttonNamed, grants, mountRoleView, role, textOf } from './roleKit'
|
||||
import { customAccess, ownerAccess, adminAccess } from '@/test/access'
|
||||
|
||||
const toastAdd = vi.fn()
|
||||
const toastRemove = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
@@ -13,7 +16,7 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: vi.fn() }) }
|
||||
return { ...actual, useToast: () => ({ add: toastAdd, remove: toastRemove }) }
|
||||
})
|
||||
|
||||
const OWNER = role({
|
||||
@@ -68,6 +71,8 @@ afterEach(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
toastAdd.mockReset()
|
||||
toastRemove.mockReset()
|
||||
})
|
||||
|
||||
function rows() {
|
||||
@@ -339,7 +344,7 @@ describe('Roles list: Delete', () => {
|
||||
expect(rows().map((row) => textOf(row).split(' ')[0])).not.toContain('Intern')
|
||||
})
|
||||
|
||||
it('refreshes the list when the role is already gone', async () => {
|
||||
it('removes the row and toasts that the role no longer exists when it is already gone', async () => {
|
||||
const intern = role({ id: 6, name: 'Intern', members: 0 })
|
||||
let listed = 0
|
||||
const backend = new Backend({
|
||||
@@ -359,7 +364,114 @@ describe('Roles list: Delete', () => {
|
||||
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')
|
||||
const toast = toastAdd.mock.calls[0]![0]
|
||||
expect(toast.color).toBe('info')
|
||||
expect(toast.duration).toBe(6000)
|
||||
expect(toast.title).toBe('That role no longer exists')
|
||||
expect(toast.description).toBe(
|
||||
'It was deleted somewhere else, so we removed it from the list.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles list: Role edited elsewhere (409)', () => {
|
||||
const EDITED = role({
|
||||
...CONTRIBUTOR,
|
||||
version: 3,
|
||||
updated_at: '2026-10-27T11:02:00Z',
|
||||
updated_by: { id: 2, name: 'Jonas Weber' },
|
||||
})
|
||||
|
||||
async function conflictOn(
|
||||
method: 'DELETE' | 'POST',
|
||||
path: string,
|
||||
items: ReturnType<typeof role>[],
|
||||
) {
|
||||
let listed = 0
|
||||
const backend = new Backend({
|
||||
'GET /v1/role/': () => (++listed === 1 ? [OWNER, ADMIN, WRITER, CONTRIBUTOR] : items),
|
||||
[`${method} ${path}`]: () => {
|
||||
throw new ApiError(409, undefined, 'role_conflict', { code: 'role_conflict' })
|
||||
},
|
||||
})
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend,
|
||||
})
|
||||
return backend
|
||||
}
|
||||
|
||||
it('warns with Reload when Move members meets a role edited meanwhile, and Reload refetches the list', async () => {
|
||||
const backend = await conflictOn('POST', '/v1/role/4/reassign', [
|
||||
OWNER,
|
||||
ADMIN,
|
||||
WRITER,
|
||||
EDITED,
|
||||
])
|
||||
|
||||
item(await openMenu('Contributor'), 'Delete').click()
|
||||
await flushPromises()
|
||||
document.body
|
||||
.querySelector<HTMLElement>(
|
||||
'[role="alertdialog"] [role="combobox"], [role="alertdialog"] button[aria-haspopup]',
|
||||
)!
|
||||
.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((option) => textOf(option).startsWith('Writer'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
buttonNamed('Move 4 members and delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const toast = toastAdd.mock.calls[0]![0]
|
||||
expect(toast.color).toBe('warning')
|
||||
expect(toast.duration).toBe(0)
|
||||
expect(toast.title).toBe('Role changed elsewhere')
|
||||
expect(toast.description).toBe(
|
||||
'Jonas Weber edited Contributor, so nothing was saved. Reload and try again.',
|
||||
)
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
const before = backend.callsTo('GET', '/v1/role/').length
|
||||
|
||||
toast.actions[0].onClick()
|
||||
await flushPromises()
|
||||
|
||||
expect(backend.callsTo('GET', '/v1/role/').length).toBe(before + 1)
|
||||
expect(toastRemove).toHaveBeenCalledWith(toast.id)
|
||||
})
|
||||
|
||||
it('warns the same way when Delete meets a role edited meanwhile', 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,
|
||||
{ ...intern, updated_by: { id: 2, name: 'Jonas Weber' } },
|
||||
],
|
||||
'DELETE /v1/role/6': () => {
|
||||
throw new ApiError(409, undefined, 'role_conflict', { code: 'role_conflict' })
|
||||
},
|
||||
})
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend,
|
||||
})
|
||||
|
||||
item(await openMenu('Intern'), 'Delete').click()
|
||||
await flushPromises()
|
||||
buttonNamed('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const toast = toastAdd.mock.calls[0]![0]
|
||||
expect(toast.title).toBe('Role changed elsewhere')
|
||||
expect(toast.description).toContain('Jonas Weber edited Intern')
|
||||
expect(rowOf('Intern')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,9 @@ import IntegrationView from '../IntegrationView.vue'
|
||||
const ORIGIN = 'https://cms.renew.example'
|
||||
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
|
||||
const NOTE =
|
||||
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.'
|
||||
'missing_permission is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.'
|
||||
const RAW_NOTE =
|
||||
'`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.'
|
||||
const GROUP_DESCRIPTION =
|
||||
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs."
|
||||
|
||||
@@ -187,8 +189,9 @@ describe('Search and sharing group', () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
|
||||
expect(forbidden.messages).toHaveLength(3)
|
||||
expect(forbidden.note).toBe(NOTE)
|
||||
expect(forbidden.messages).toHaveLength(2)
|
||||
expect(forbidden.codes).toEqual(['missing_permission'])
|
||||
expect(forbidden.note).toBe(RAW_NOTE)
|
||||
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
|
||||
expect(notes.filter((item) => item === NOTE)).toHaveLength(2)
|
||||
expect(ERROR_ROWS.filter((row) => row.note).map((row) => row.status)).toEqual([
|
||||
@@ -213,7 +216,7 @@ describe('Search and sharing group', () => {
|
||||
expect(group.text()).toContain('RenewCMS n’affiche pas de pages')
|
||||
expect(group.text()).toContain('2 routes')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire.',
|
||||
'Les valeurs SEO par défaut d’un site demandent la permission de gérer les sites.',
|
||||
)
|
||||
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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 { initializeLocale, setLocale } from '@/i18n'
|
||||
import UsersView from '../UsersView.vue'
|
||||
import { Backend, buttonNamed, grants, mountRoleView, role, textOf, type Handler } from './roleKit'
|
||||
import { customAccess, ownerAccess, roleRefFor } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import type { UserListItem } from '@/lib/users'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toastAdd = vi.fn()
|
||||
const toastRemove = vi.fn()
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd, remove: toastRemove }) }
|
||||
})
|
||||
|
||||
function accepted(id: number, name: string, roleName: string): UserListItem {
|
||||
return {
|
||||
kind: 'user',
|
||||
id,
|
||||
username: name.toLowerCase().split(' ')[0]!,
|
||||
display_name: name,
|
||||
email: `${name.toLowerCase().replace(' ', '.')}@renew.example`,
|
||||
role: roleRefFor(roleName),
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const MARTA = accepted(1, 'Marta Alves', 'owner')
|
||||
const JONAS = accepted(2, 'Jonas Weber', 'admin')
|
||||
const INES = accepted(7, 'Ines Duarte', 'editor')
|
||||
const INVITE: UserListItem = {
|
||||
kind: 'invite',
|
||||
id: 9,
|
||||
email: 'pending@renew.example',
|
||||
role: roleRefFor('editor'),
|
||||
status: 'pending',
|
||||
created_at: '2026-09-27T09:00:00Z',
|
||||
}
|
||||
|
||||
const ROLES = [
|
||||
role({ id: 1, name: 'Owner', built_in: true, locked: true }),
|
||||
role({ id: 2, name: 'Admin', built_in: true }),
|
||||
role({ id: 3, name: 'Writer', built_in: true }),
|
||||
]
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function open(extra: Record<string, Handler> = {}, access: Access = ownerAccess()) {
|
||||
const backend = new Backend({
|
||||
'GET /v1/user/': () => [INVITE, INES, JONAS, MARTA],
|
||||
'GET /v1/role/': () => ROLES,
|
||||
...extra,
|
||||
})
|
||||
wrapper = await mountRoleView(UsersView, '/users', { access, backend })
|
||||
return backend
|
||||
}
|
||||
|
||||
function rowOf(name: string) {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="user-row"]')).find(
|
||||
(row) => textOf(row).includes(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 menuItem(items: HTMLElement[], label: string) {
|
||||
return items.find((candidate) => textOf(candidate).startsWith(label))
|
||||
}
|
||||
|
||||
async function chooseRemove(name: string) {
|
||||
menuItem(await openMenu(name), 'Remove user')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function confirmRemoval() {
|
||||
buttonNamed('Remove user', document.body.querySelector('[role="alertdialog"]')!)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function refuse(status: number, code: string): Handler {
|
||||
return () => {
|
||||
throw new ApiError(status, undefined, code, { code: code as never })
|
||||
}
|
||||
}
|
||||
|
||||
function toastContent(index = 0) {
|
||||
const argument = toastAdd.mock.calls[index]![0]
|
||||
return typeof argument === 'function' ? argument() : argument
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd.mockReset()
|
||||
toastRemove.mockReset()
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Users: Remove user menu item', () => {
|
||||
it('is enabled on an accepted member for a person with Remove users', async () => {
|
||||
await open()
|
||||
|
||||
const item = menuItem(await openMenu('Ines Duarte'), 'Remove user')
|
||||
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('is the only item for a person with users.view and users.remove only', async () => {
|
||||
await open({}, customAccess('users.view, users.remove'))
|
||||
|
||||
const items = await openMenu('Ines Duarte')
|
||||
|
||||
expect(items.map((item) => textOf(item))).toEqual(['Remove user'])
|
||||
})
|
||||
|
||||
it('is absent with users.view and users.change_role only', async () => {
|
||||
await open({}, customAccess('users.view, users.change_role, roles.view'))
|
||||
|
||||
const items = await openMenu('Ines Duarte')
|
||||
|
||||
expect(menuItem(items, 'Remove user')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is disabled on the owner row with the owner reason', async () => {
|
||||
await open()
|
||||
|
||||
const item = menuItem(await openMenu('Marta Alves'), 'Remove user')!
|
||||
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(textOf(document.body)).toContain(
|
||||
"The owner can't be changed or removed. Transfer ownership first.",
|
||||
)
|
||||
})
|
||||
|
||||
it('is disabled on the row marked you', async () => {
|
||||
await open({}, customAccess('users.view, users.remove'))
|
||||
|
||||
const item = menuItem(await openMenu('Marta Alves'), 'Remove user')!
|
||||
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('is disabled with the escalation reason when a custom role cannot cover the member role', async () => {
|
||||
const lead = role({
|
||||
id: 5,
|
||||
name: 'Content lead',
|
||||
grants: grants('content.read, content.update, content.publish'),
|
||||
})
|
||||
const covered = role({ id: 3, name: 'Writer', grants: grants('content.read') })
|
||||
const leadUser: UserListItem = {
|
||||
...accepted(8, 'Lea Martin', 'editor'),
|
||||
role: { id: 5, name: 'Content lead', built_in: false, locked: false },
|
||||
}
|
||||
await open(
|
||||
{
|
||||
'GET /v1/user/': () => [MARTA, INES, leadUser],
|
||||
'GET /v1/role/': () => [...ROLES, lead, { ...covered, built_in: false }],
|
||||
},
|
||||
customAccess(
|
||||
'users.view, users.remove, roles.view, content.read, content.update=own',
|
||||
'People manager',
|
||||
),
|
||||
)
|
||||
|
||||
const blocked = menuItem(await openMenu('Lea Martin'), 'Remove user')!
|
||||
expect(blocked.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(textOf(document.body)).toContain(
|
||||
"Your role can't assign a role with more power than yours.",
|
||||
)
|
||||
})
|
||||
|
||||
it('does not appear on a pending invite row', async () => {
|
||||
await open()
|
||||
|
||||
const inviteRow = Array.from(
|
||||
document.body.querySelectorAll('[data-testid="user-row"]'),
|
||||
).find((row) => textOf(row).includes('pending@renew'))!
|
||||
|
||||
expect(inviteRow.querySelector('button[aria-label^="Actions for"]')).toBeNull()
|
||||
expect(textOf(inviteRow)).not.toContain('Remove user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Users: removing a member', () => {
|
||||
it('asks for confirmation, sends the delete with the role of the list item and drops the row', async () => {
|
||||
const backend = await open({ 'DELETE /v1/user/7?expected_role_id=3': () => undefined })
|
||||
|
||||
await chooseRemove('Ines Duarte')
|
||||
expect(textOf(document.body.querySelector('[role="alertdialog"]')!)).toContain(
|
||||
'Remove Ines Duarte?',
|
||||
)
|
||||
await confirmRemoval()
|
||||
|
||||
expect(backend.callsTo('DELETE', '/v1/user/7?expected_role_id=3')).toHaveLength(1)
|
||||
expect(textOf(document.body)).not.toContain('Ines Duarte')
|
||||
expect(toastContent()).toMatchObject({ color: 'success', duration: 6000 })
|
||||
})
|
||||
|
||||
it('shows the User not removed warning with Reload on 409 and Reload re-reads the list', async () => {
|
||||
const backend = await open({
|
||||
'DELETE /v1/user/7?expected_role_id=3': refuse(409, 'user_role_changed'),
|
||||
})
|
||||
|
||||
await chooseRemove('Ines Duarte')
|
||||
await confirmRemoval()
|
||||
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
const toast = toastContent()
|
||||
expect(toast).toMatchObject({
|
||||
color: 'warning',
|
||||
duration: 0,
|
||||
title: 'User not removed',
|
||||
description:
|
||||
"Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again.",
|
||||
})
|
||||
expect(toast.actions[0].label).toBe('Reload')
|
||||
const reads = backend.callsTo('GET', '/v1/user/').length
|
||||
|
||||
toast.actions[0].onClick()
|
||||
await flushPromises()
|
||||
|
||||
expect(backend.callsTo('GET', '/v1/user/').length).toBe(reads + 1)
|
||||
expect(toastRemove).toHaveBeenCalledWith(toast.id)
|
||||
})
|
||||
|
||||
it('reads the warning in French', async () => {
|
||||
setLocale('fr')
|
||||
await open({ 'DELETE /v1/user/7?expected_role_id=3': refuse(409, 'user_role_changed') })
|
||||
|
||||
rowOf('Ines Duarte').querySelector<HTMLElement>('button[aria-label^="Actions"]')!.click()
|
||||
await flushPromises()
|
||||
menuItem(
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')),
|
||||
'Retirer l’utilisateur',
|
||||
)!.click()
|
||||
await flushPromises()
|
||||
buttonNamed(
|
||||
'Retirer l’utilisateur',
|
||||
document.body.querySelector('[role="alertdialog"]')!,
|
||||
)!.click()
|
||||
await flushPromises()
|
||||
|
||||
const toast = toastContent()
|
||||
expect(toast.title).toBe('Utilisateur non retiré')
|
||||
expect(toast.description).toBe(
|
||||
"Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez.",
|
||||
)
|
||||
expect(toast.actions[0].label).toBe('Recharger')
|
||||
})
|
||||
})
|
||||
@@ -226,7 +226,7 @@ describe('What the Users page offers per grant', () => {
|
||||
|
||||
expect(buttonNamed('Invite user')).toBeInstanceOf(HTMLButtonElement)
|
||||
await openMenu('Ines Duarte')
|
||||
expect(menuLabels()).toEqual(['Change role'])
|
||||
expect(menuLabels()).toEqual(['Change role', 'Remove user'])
|
||||
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Your own row and the owner row', () => {
|
||||
await mountPage(adminAccess(), 2)
|
||||
await openMenu('Jonas Weber')
|
||||
|
||||
expect(menuLabels()).toEqual(['Change role'])
|
||||
expect(menuLabels()).toEqual(['Change role', 'Remove user'])
|
||||
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(menuItem('Change role')?.textContent).toContain(OWN_ROW_HINT)
|
||||
})
|
||||
@@ -262,7 +262,7 @@ describe('Your own row and the owner row', () => {
|
||||
await mountPage(adminAccess(), 2)
|
||||
await openMenu('Marta Alves')
|
||||
|
||||
expect(menuLabels()).toEqual(['Change role'])
|
||||
expect(menuLabels()).toEqual(['Change role', 'Remove user'])
|
||||
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(menuItem('Change role')?.textContent).toContain(OWNER_HINT)
|
||||
})
|
||||
@@ -271,7 +271,7 @@ describe('Your own row and the owner row', () => {
|
||||
await mountPage()
|
||||
await openMenu('Marta Alves')
|
||||
|
||||
expect(menuLabels()).toEqual(['Change role', 'Transfer ownership'])
|
||||
expect(menuLabels()).toEqual(['Change role', 'Transfer ownership', 'Remove user'])
|
||||
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(menuItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
@@ -286,7 +286,7 @@ describe('Your own row and the owner row', () => {
|
||||
await mountPage(without(ownerAccess(), 'system.transfer_ownership'))
|
||||
await openMenu('Marta Alves')
|
||||
|
||||
expect(menuLabels()).toEqual(['Change role'])
|
||||
expect(menuLabels()).toEqual(['Change role', 'Remove user'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -308,7 +308,7 @@ describe('The mobile Users menu', () => {
|
||||
|
||||
expect(document.body.querySelector('[data-testid="user-card"]')).not.toBeNull()
|
||||
await openMenu('Marta Alves')
|
||||
expect(sheetLabels()).toEqual(['Change role'])
|
||||
expect(sheetLabels()).toEqual(['Change role', 'Remove user'])
|
||||
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.body.querySelector('[role="menu"]')?.textContent).toContain(OWNER_HINT)
|
||||
})
|
||||
@@ -317,7 +317,7 @@ describe('The mobile Users menu', () => {
|
||||
await mountPage(ownerAccess(), 1, false)
|
||||
|
||||
await openMenu('Marta Alves')
|
||||
expect(sheetLabels()).toEqual(['Change role', 'Transfer ownership'])
|
||||
expect(sheetLabels()).toEqual(['Change role', 'Transfer ownership', 'Remove user'])
|
||||
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(sheetItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
|
||||
await click(sheetItem('Transfer ownership'))
|
||||
@@ -328,10 +328,11 @@ describe('The mobile Users menu', () => {
|
||||
await mountPage(ownerAccess(), 1, false)
|
||||
|
||||
await openMenu('Ines Duarte')
|
||||
expect(sheetLabels()).toEqual(['Change role'])
|
||||
expect(sheetLabels()).toEqual(['Change role', 'Remove user'])
|
||||
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
|
||||
const before = calls(`GET ${ROLE_PATH}`).length
|
||||
await click(sheetItem('Change role'))
|
||||
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(1)
|
||||
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(before + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -383,7 +384,7 @@ describe('Changing the role', () => {
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('re-reads the list and closes on 409 user_role_changed', async () => {
|
||||
it('re-reads the list and keeps the dialog open with the alert on 409 user_role_changed', async () => {
|
||||
answers['PUT /v1/user/4/role'] = () =>
|
||||
Promise.reject(
|
||||
new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' }),
|
||||
@@ -399,7 +400,7 @@ describe('Changing the role', () => {
|
||||
)
|
||||
|
||||
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2)
|
||||
expect(dialog()).toBeNull()
|
||||
expect(dialog()?.textContent).toContain('Role changed in the meantime')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
|
||||
const LIST_PATH = '/v1/user/'
|
||||
const INVITE_PATH = '/v1/user/invite'
|
||||
const INVALID_EMAIL = 'Enter a full email address, like name@example.com.'
|
||||
const FAILED_ALERT = "Couldn't send the invite. Try again in a moment."
|
||||
const INVALID_EMAIL = 'Enter a valid email address.'
|
||||
const FAILED_TITLE = "Couldn't send the invite"
|
||||
const FAILED_DESCRIPTION = 'Try again in a moment.'
|
||||
const VALID_EMAIL = 'lea.martin@renew.example'
|
||||
|
||||
function defaultFixture(): UserListItem[] {
|
||||
@@ -726,7 +727,8 @@ describe('Invite: 409 conflicts are told apart by the error code', () => {
|
||||
await openModal()
|
||||
await submitWith('jonas@renew.example')
|
||||
|
||||
expect(alertText()).toContain(FAILED_ALERT)
|
||||
expect(alertText()).toContain(FAILED_TITLE)
|
||||
expect(alertText()).toContain(FAILED_DESCRIPTION)
|
||||
expect(dialog()?.textContent).not.toContain('This address already has an account.')
|
||||
expect(emailInput().disabled).toBe(false)
|
||||
})
|
||||
@@ -783,7 +785,7 @@ describe('Invite: failed — a 500 or a network failure keeps the address and of
|
||||
},
|
||||
{
|
||||
name: 'a 500 with the API text',
|
||||
failure: () => Promise.reject(new ApiError(500, undefined, FAILED_ALERT)),
|
||||
failure: () => Promise.reject(new ApiError(500, undefined, FAILED_TITLE)),
|
||||
},
|
||||
{
|
||||
name: 'a network failure',
|
||||
@@ -794,7 +796,7 @@ describe('Invite: failed — a 500 or a network failure keeps the address and of
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
|
||||
expect(alertText()).toBe(FAILED_ALERT)
|
||||
expect(alertText()).toBe(`${FAILED_TITLE}${FAILED_DESCRIPTION}`)
|
||||
expect(document.body.querySelectorAll('[role="alert"]')).toHaveLength(1)
|
||||
expect(emailInput().value).toBe(VALID_EMAIL)
|
||||
expect(emailInput().disabled).toBe(false)
|
||||
|
||||
Reference in new issue
Block a user