feat: roles SPA Users, Profile, API keys, Activity
This commit is contained in:
1 parent
3db490a615
commit
34dffe222f
98 files changed
+4957
-434
No files matched your search
@@ -5,6 +5,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import ActivityActor from '@/components/ActivityActor.vue'
|
||||
import ActivityBadge from '@/components/ActivityBadge.vue'
|
||||
import ActivityDiff from '@/components/ActivityDiff.vue'
|
||||
import ActivityRoleDiff from '@/components/ActivityRoleDiff.vue'
|
||||
import ActivityDetailLine from '@/components/ActivityDetailLine.vue'
|
||||
import ActivitySentence from '@/components/ActivitySentence.vue'
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
targetTypeLabel,
|
||||
type ActivityItem,
|
||||
} from '@/lib/activity'
|
||||
import { roleDiffRows, roleMembersLine } from '@/lib/activityRoleDiff'
|
||||
import { fullLocalTime, fullUtcTime } from '@/lib/activityTime'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -43,6 +45,11 @@ watch(
|
||||
)
|
||||
|
||||
const current = computed(() => props.item ?? retained.value)
|
||||
const hasRoleDetails = computed(
|
||||
() =>
|
||||
current.value !== null &&
|
||||
(roleDiffRows(current.value).length > 0 || roleMembersLine(current.value) !== null),
|
||||
)
|
||||
const link = computed(() => targetLink(current.value?.target ?? null))
|
||||
const side = computed(() => (props.isDesktop ? 'right' : 'bottom'))
|
||||
const positionOf = computed(() =>
|
||||
@@ -200,6 +207,17 @@ function onOpenChange(isOpen: boolean) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="hasRoleDetails"
|
||||
data-testid="activity-role-section"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<h3 class="text-base font-semibold text-highlighted">
|
||||
{{ t('What changed') }}
|
||||
</h3>
|
||||
<ActivityRoleDiff :item="current" />
|
||||
</section>
|
||||
|
||||
<section v-if="current.changes.length > 0" class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-semibold text-highlighted">
|
||||
{{ t('Changes') }}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { roleDiffLabel, roleDiffRows, roleMembersLine } from '@/lib/activityRoleDiff'
|
||||
|
||||
const props = defineProps<{ item: ActivityItem }>()
|
||||
|
||||
const rows = computed(() => roleDiffRows(props.item))
|
||||
const members = computed(() => roleMembersLine(props.item))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="rows.length > 0"
|
||||
data-testid="activity-role-diff"
|
||||
class="flex flex-col border border-default"
|
||||
>
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
:key="index"
|
||||
data-testid="activity-role-change"
|
||||
class="flex flex-col gap-0.5 border-t border-muted p-3 first:border-t-0"
|
||||
>
|
||||
<span class="text-2xs text-muted">{{ roleDiffLabel(row.kind) }}</span>
|
||||
<span class="text-highlighted">{{ row.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="members" data-testid="activity-role-members" class="text-muted">{{ members }}</p>
|
||||
</template>
|
||||
@@ -2,7 +2,9 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
|
||||
import RoleBadge from '@/components/RoleBadge.vue'
|
||||
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
|
||||
import type { RoleRef } from '@/lib/permissions'
|
||||
import {
|
||||
creatorLabel,
|
||||
displayPrefix,
|
||||
@@ -11,14 +13,20 @@ import {
|
||||
lastUsedLabel,
|
||||
} from '@/lib/apiKeys'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
keys: ApiKey[]
|
||||
loading: boolean
|
||||
showCreatedBy: boolean
|
||||
callerId?: number
|
||||
callerRole?: RoleRef
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
|
||||
|
||||
function followsCaller(key: ApiKey) {
|
||||
return !props.showCreatedBy || key.user_id === props.callerId
|
||||
}
|
||||
|
||||
function createdText(key: ApiKey, showCreatedBy: boolean) {
|
||||
const date = formatKeyDate(key.created_at, '')
|
||||
return showCreatedBy
|
||||
@@ -62,6 +70,10 @@ function detailsText(key: ApiKey, showCreatedBy: boolean) {
|
||||
>{{ key.name }}</span
|
||||
>
|
||||
<ApiKeyStatusBadge :status="key.status" />
|
||||
<span class="flex shrink-0 items-center gap-1.5 text-xs text-muted">
|
||||
{{ t('Follows role') }}
|
||||
<RoleBadge :role="followsCaller(key) ? callerRole : null" />
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="font-mono text-xs leading-[normal]"
|
||||
|
||||
@@ -3,7 +3,9 @@ import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
|
||||
import RoleBadge from '@/components/RoleBadge.vue'
|
||||
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
|
||||
import type { RoleRef } from '@/lib/permissions'
|
||||
import {
|
||||
creatorLabel,
|
||||
displayPrefix,
|
||||
@@ -16,10 +18,16 @@ const props = defineProps<{
|
||||
keys: ApiKey[]
|
||||
loading: boolean
|
||||
showCreatedBy: boolean
|
||||
callerId?: number
|
||||
callerRole?: RoleRef
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
|
||||
|
||||
function followsCaller(key: ApiKey) {
|
||||
return !props.showCreatedBy || key.user_id === props.callerId
|
||||
}
|
||||
|
||||
type Row = ApiKey | { skeleton: true; id: number }
|
||||
|
||||
const skeletonRows: Row[] = Array.from({ length: API_KEY_SKELETON_ROWS }, (_, index) => ({
|
||||
@@ -65,6 +73,13 @@ const columns = computed(() => [
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'follows_role',
|
||||
get header() {
|
||||
return t('Follows role')
|
||||
},
|
||||
...fixed('w-28'),
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
get header() {
|
||||
@@ -157,6 +172,10 @@ function mutedClass(row: { original: Row }) {
|
||||
creatorLabel(key(row))
|
||||
}}</span>
|
||||
</template>
|
||||
<template #follows_role-cell="{ row }">
|
||||
<USkeleton v-if="isSkeleton(row)" class="h-5 w-16 rounded-none bg-accented" />
|
||||
<RoleBadge v-else :role="followsCaller(key(row)) ? callerRole : null" />
|
||||
</template>
|
||||
<template #created_at-cell="{ row }">
|
||||
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
|
||||
formatKeyDate(key(row).created_at, '')
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, 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 { usePermissionsStore } from '@/stores/permissions'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const props = defineProps<{ user: AcceptedUser }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [user: AcceptedUser]
|
||||
stale: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { listRoles } = useRoles()
|
||||
const { changeUserRole } = useUsers()
|
||||
const permissions = usePermissionsStore()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const roles = ref<RoleItem[]>([])
|
||||
const loading = ref(true)
|
||||
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 confirmDisabled = computed(
|
||||
() => loading.value || selected.value === undefined || selected.value === currentId.value,
|
||||
)
|
||||
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
roles.value = await listRoles()
|
||||
notice.value = null
|
||||
} catch {
|
||||
notice.value = { description: t("Couldn't load roles") }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function announceStale(title: string, description: string) {
|
||||
toast.add(userToast(`user-role-stale-${props.user.id}`, 'error', title, description))
|
||||
}
|
||||
|
||||
async function handleFailure(error: unknown) {
|
||||
const refusal = inlineRefusal(error)
|
||||
if (refusal !== undefined) {
|
||||
notice.value = refusal
|
||||
return
|
||||
}
|
||||
if (isMissingPermission(error)) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (isCode(error, 409, 'user_role_changed')) {
|
||||
emit('stale')
|
||||
announceStale(t('Their role changed meanwhile'), t('The list was refreshed.'))
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (isCode(error, 404, 'user_not_found')) {
|
||||
emit('stale')
|
||||
announceStale(t('That user no longer exists'), t('The list was refreshed.'))
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (isCode(error, 400, 'invalid_role')) {
|
||||
selected.value = currentId.value
|
||||
await loadRoles()
|
||||
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.") }
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
const roleId = selected.value
|
||||
if (pending.value || confirmDisabled.value || roleId === undefined) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
try {
|
||||
const updated = await changeUserRole(props.user.id, roleId)
|
||||
emit('changed', updated)
|
||||
toast.add(
|
||||
userToast(
|
||||
`user-role-changed-${props.user.id}`,
|
||||
'success',
|
||||
() => t('Role changed'),
|
||||
() => t('{0} now has the {1} role.', [name.value, updated.role?.name ?? '']),
|
||||
),
|
||||
)
|
||||
open.value = false
|
||||
} catch (error) {
|
||||
await handleFailure(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRoles)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
v-model:open="open"
|
||||
:title="t('Change role for {0}', [name])"
|
||||
:description="t('Their API keys follow the new role.')"
|
||||
:confirm-label="t('Change role')"
|
||||
:pending-label="t('Changing…')"
|
||||
tone="neutral"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div v-if="loading" aria-busy="true" class="flex flex-col gap-2">
|
||||
<USkeleton v-for="n in 4" :key="n" class="h-14 rounded-none bg-accented" />
|
||||
</div>
|
||||
<RolePicker
|
||||
v-else
|
||||
v-model:selected="selected"
|
||||
:roles="roles"
|
||||
:can-assign="permissions.canAssign"
|
||||
show-locked
|
||||
cards
|
||||
:current-id="currentId"
|
||||
:label="t('Role')"
|
||||
/>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -2,27 +2,46 @@
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import RolePicker from '@/components/RolePicker.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useRoles } from '@/composables/useRoles'
|
||||
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
|
||||
import { inlineRefusal, isCode } from '@/lib/userRefusal'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
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 HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
|
||||
const PENDING_INVITE_MESSAGE = "There's already a pending invite for this address."
|
||||
const DEFAULT_ROLE_NAME = 'Writer'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{ invited: [invite: CreatedInvite] }>()
|
||||
|
||||
const { inviteUser } = useUsers()
|
||||
const { listRoles } = useRoles()
|
||||
const permissions = usePermissionsStore()
|
||||
|
||||
const emailField = ref<{ inputRef?: HTMLInputElement | null } | null>(null)
|
||||
const email = ref('')
|
||||
const touched = ref(false)
|
||||
const focused = ref(false)
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
|
||||
const refusal = ref<DirectoryNotice | null>(null)
|
||||
const roles = ref<RoleItem[]>([])
|
||||
const roleId = ref<number | undefined>(undefined)
|
||||
const rolesLoaded = ref(false)
|
||||
|
||||
const showRolePicker = computed(
|
||||
() => permissions.can('roles.view') && rolesLoaded.value && roles.value.length > 0,
|
||||
)
|
||||
const selectedRole = computed(() => roles.value.find((role) => role.id === roleId.value))
|
||||
const defaultRoleSelected = computed(() => selectedRole.value?.name === DEFAULT_ROLE_NAME)
|
||||
const sendBlocked = computed(() => showRolePicker.value && roleId.value === undefined)
|
||||
|
||||
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
|
||||
const emailError = computed(
|
||||
@@ -42,7 +61,28 @@ const alertMessage = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
function defaultRoleId() {
|
||||
const writer = roles.value.find((role) => role.built_in && role.name === DEFAULT_ROLE_NAME)
|
||||
return writer !== undefined && permissions.canAssign(writer) ? writer.id : undefined
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
rolesLoaded.value = false
|
||||
roleId.value = undefined
|
||||
if (!permissions.can('roles.view')) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
roles.value = await listRoles()
|
||||
rolesLoaded.value = true
|
||||
roleId.value = defaultRoleId()
|
||||
} catch {
|
||||
roles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
refusal.value = null
|
||||
email.value = ''
|
||||
touched.value = false
|
||||
focused.value = false
|
||||
@@ -52,7 +92,7 @@ function resetForm() {
|
||||
}
|
||||
|
||||
function emailInput() {
|
||||
return document.querySelector<HTMLInputElement>(`input[placeholder="${EMAIL_PLACEHOLDER}"]`)
|
||||
return emailField.value?.inputRef ?? null
|
||||
}
|
||||
|
||||
async function focusEmailField() {
|
||||
@@ -70,6 +110,7 @@ watch(
|
||||
}
|
||||
resetForm()
|
||||
focusEmailField()
|
||||
loadRoles()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -83,15 +124,22 @@ function onBlur() {
|
||||
focused.value = false
|
||||
}
|
||||
|
||||
function messageForConflict(error: ApiError) {
|
||||
return error.body === PENDING_INVITE_MESSAGE
|
||||
? t(PENDING_INVITE_MESSAGE)
|
||||
: t(HAS_ACCOUNT_MESSAGE)
|
||||
}
|
||||
|
||||
function showFailure(error: unknown) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
serverError.value = messageForConflict(error)
|
||||
const refused = inlineRefusal(error)
|
||||
if (refused !== undefined) {
|
||||
refusal.value = refused
|
||||
return
|
||||
}
|
||||
if (isCode(error, 400, 'invalid_role')) {
|
||||
loadRoles()
|
||||
return
|
||||
}
|
||||
if (isCode(error, 409, 'invite_pending')) {
|
||||
serverError.value = t("There's already a pending invite for this address.")
|
||||
return
|
||||
}
|
||||
if (isCode(error, 409, 'email_has_account')) {
|
||||
serverError.value = t('This address already has an account.')
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 400) {
|
||||
@@ -114,11 +162,19 @@ async function onSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
if (sendBlocked.value) {
|
||||
return
|
||||
}
|
||||
|
||||
serverError.value = ''
|
||||
formAlert.value = ''
|
||||
refusal.value = null
|
||||
submitting.value = true
|
||||
try {
|
||||
const invite = await inviteUser(email.value)
|
||||
const invite = await inviteUser(
|
||||
email.value,
|
||||
showRolePicker.value ? roleId.value : undefined,
|
||||
)
|
||||
open.value = false
|
||||
emit('invited', invite)
|
||||
} catch (error) {
|
||||
@@ -147,12 +203,8 @@ function onCancel() {
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="t('Invite a user')"
|
||||
:description="
|
||||
t(
|
||||
'We\'ll email them a link to choose a username and password. The link expires after 3 days.',
|
||||
)
|
||||
"
|
||||
:title="t('Invite user')"
|
||||
:description="t('They get an email with a link to set a password.')"
|
||||
:dismissible="!submitting"
|
||||
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
|
||||
@update:open="onOpenChange"
|
||||
@@ -169,6 +221,17 @@ function onCancel() {
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UAlert
|
||||
v-if="refusal"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
:title="refusal.title"
|
||||
:description="refusal.description"
|
||||
/>
|
||||
<UAlert
|
||||
v-if="alertMessage"
|
||||
role="alert"
|
||||
@@ -196,6 +259,7 @@ function onCancel() {
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
ref="emailField"
|
||||
v-model="email"
|
||||
type="email"
|
||||
:placeholder="EMAIL_PLACEHOLDER"
|
||||
@@ -209,6 +273,19 @@ function onCancel() {
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField v-if="showRolePicker" :label="t('Role')" name="role">
|
||||
<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>
|
||||
</UFormField>
|
||||
</UForm>
|
||||
</div>
|
||||
</template>
|
||||
@@ -230,7 +307,7 @@ function onCancel() {
|
||||
:ui="{ base: 'h-11 md:h-8' }"
|
||||
class="sm:w-auto"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || sendBlocked"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitting ? t('Sending…') : t('Send invite') }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@ defineProps<{
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">{{ description }}</p>
|
||||
<p v-if="description" class="text-pretty text-muted">{{ description }}</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -3,33 +3,71 @@ import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const props = defineProps<{
|
||||
roles: RoleItem[]
|
||||
sourceId: number
|
||||
canAssign: (role: RoleItem) => boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
roles: RoleItem[]
|
||||
sourceId?: number
|
||||
canAssign: (role: RoleItem) => boolean
|
||||
showLocked?: boolean
|
||||
currentId?: number
|
||||
cards?: boolean
|
||||
label?: string
|
||||
}>(),
|
||||
{ sourceId: 0, showLocked: false, currentId: undefined, cards: false, label: undefined },
|
||||
)
|
||||
|
||||
const selected = defineModel<number | undefined>('selected', { default: undefined })
|
||||
|
||||
function reasonFor(role: RoleItem): string | undefined {
|
||||
if (role.locked) {
|
||||
return t('There is one owner. Transfer ownership to change who it is.')
|
||||
}
|
||||
return props.canAssign(role)
|
||||
? undefined
|
||||
: t("Your role can't assign a role with more power than yours.")
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
props.roles
|
||||
.filter((role) => !role.locked && role.id !== props.sourceId)
|
||||
.filter((role) => (props.showLocked || !role.locked) && role.id !== props.sourceId)
|
||||
.map((role) => {
|
||||
const allowed = props.canAssign(role)
|
||||
const reason = reasonFor(role)
|
||||
return {
|
||||
label: role.name,
|
||||
value: role.id,
|
||||
description: allowed
|
||||
? role.description
|
||||
: t("Your role can't assign a role with more power than yours."),
|
||||
disabled: !allowed,
|
||||
description: reason ?? role.description,
|
||||
disabled: reason !== undefined,
|
||||
current: role.id === props.currentId,
|
||||
}
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<URadioGroup
|
||||
v-if="cards"
|
||||
v-model="selected"
|
||||
:items="items"
|
||||
variant="card"
|
||||
color="primary"
|
||||
:aria-label="label"
|
||||
:ui="{ item: 'rounded-none', fieldset: 'gap-2' }"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
</URadioGroup>
|
||||
<USelectMenu
|
||||
v-else
|
||||
v-model="selected"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import RolePreviewBody from '@/components/RolePreviewBody.vue'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
import type { RolePreviewModel } from '@/lib/rolePreview'
|
||||
|
||||
defineProps<{
|
||||
model: RolePreviewModel
|
||||
roleName: string
|
||||
mobile: boolean
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-if="mobile"
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('What you can do')"
|
||||
:description="t('A summary of your role, {0}. Read-only.', [roleName])"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<template #body>
|
||||
<p class="text-muted">{{ t('A summary of your role, {0}. Read-only.', [roleName]) }}</p>
|
||||
<RolePreviewBody :model="model" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 flex-1 justify-center"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('Close') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
<UModal
|
||||
v-else
|
||||
v-model:open="open"
|
||||
:title="t('What you can do')"
|
||||
:description="t('A summary of your role, {0}. Read-only.', [roleName])"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-15 sm:max-w-140 sm:translate-y-0',
|
||||
header: 'px-6 pt-5 pb-0 sm:px-6',
|
||||
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',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<RolePreviewBody :model="model" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-8 px-3 text-sm text-highlighted"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('Close') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, 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 { useSessionStore } from '@/stores/session'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
type Step = 'pick' | 'confirm' | 'back'
|
||||
|
||||
const props = defineProps<{ users: UserListItem[] }>()
|
||||
|
||||
const emit = defineEmits<{ transferred: []; stale: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { transferOwnership } = useUsers()
|
||||
const session = useSessionStore()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const step = ref<Step>('pick')
|
||||
const chosenId = ref<number | undefined>(undefined)
|
||||
const typed = ref('')
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const fieldError = ref(false)
|
||||
|
||||
function displayName(user: AcceptedUser) {
|
||||
return user.display_name || user.username
|
||||
}
|
||||
|
||||
const members = computed(() =>
|
||||
props.users.filter(
|
||||
(item): item is AcceptedUser =>
|
||||
item.kind === 'user' && !item.role?.locked && item.id !== session.session?.id,
|
||||
),
|
||||
)
|
||||
const options = computed(() =>
|
||||
members.value.map((user) => ({
|
||||
label: displayName(user),
|
||||
value: user.id,
|
||||
description: user.email,
|
||||
})),
|
||||
)
|
||||
const picking = computed(() => step.value !== 'confirm')
|
||||
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 ?? '')
|
||||
|
||||
const title = computed(() =>
|
||||
picking.value ? t('Transfer ownership') : t('Transfer ownership to {0}', [name.value]),
|
||||
)
|
||||
const description = computed(() =>
|
||||
picking.value
|
||||
? t('Choose who becomes the owner. Only members can be chosen.')
|
||||
: t('This takes effect straight away.'),
|
||||
)
|
||||
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,
|
||||
)
|
||||
const consequences = computed(() => [
|
||||
t('{0} becomes the owner.', [name.value]),
|
||||
t("You become an Admin. You can't take ownership back unless they transfer it."),
|
||||
t('Only the owner can apply updates, delete a site and change activity retention.'),
|
||||
t('Your API keys follow your new role.'),
|
||||
])
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value && step.value === 'back') {
|
||||
step.value = 'pick'
|
||||
return
|
||||
}
|
||||
open.value = value
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (step.value === 'confirm') {
|
||||
step.value = 'back'
|
||||
typed.value = ''
|
||||
notice.value = null
|
||||
fieldError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFailure(error: unknown) {
|
||||
if (isMissingPermission(error)) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (isSessionRequired(error)) {
|
||||
return
|
||||
}
|
||||
if (isCode(error, 400, 'invalid_transfer')) {
|
||||
step.value = 'pick'
|
||||
chosenId.value = undefined
|
||||
typed.value = ''
|
||||
emit('stale')
|
||||
notice.value = {
|
||||
description: t("That person can't become the owner. Choose another member."),
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isCode(error, 400, 'confirmation_mismatch')) {
|
||||
fieldError.value = true
|
||||
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
|
||||
return
|
||||
}
|
||||
notice.value = { description: t("Couldn't transfer ownership. Try again in a moment.") }
|
||||
}
|
||||
|
||||
async function transfer() {
|
||||
const target = chosen.value
|
||||
if (pending.value || target === undefined) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
fieldError.value = false
|
||||
try {
|
||||
const result = await transferOwnership(target.id, typed.value)
|
||||
await session.refresh()
|
||||
emit('transferred')
|
||||
toast.add(
|
||||
userToast(
|
||||
'ownership-transferred',
|
||||
'success',
|
||||
() => t('Ownership transferred'),
|
||||
() => t('{0} is now the owner. You are an Admin.', [result.owner.name]),
|
||||
),
|
||||
)
|
||||
open.value = false
|
||||
} catch (error) {
|
||||
await handleFailure(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (confirmDisabled.value) {
|
||||
return
|
||||
}
|
||||
if (picking.value) {
|
||||
step.value = 'confirm'
|
||||
return
|
||||
}
|
||||
transfer()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:confirm-label="confirmLabel"
|
||||
:pending-label="t('Transferring…')"
|
||||
:cancel-label="cancelLabel"
|
||||
:tone="picking ? 'neutral' : 'danger'"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@update:open="onOpenChange"
|
||||
@cancel="onCancel"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<URadioGroup
|
||||
v-if="picking"
|
||||
v-model="chosenId"
|
||||
:items="options"
|
||||
variant="card"
|
||||
:aria-label="t('Members')"
|
||||
:ui="{ item: 'rounded-none', fieldset: 'gap-2' }"
|
||||
/>
|
||||
<template v-else>
|
||||
<ul class="flex list-disc flex-col gap-1.5 ps-5 text-toned">
|
||||
<li v-for="line in consequences" :key="line">{{ line }}</li>
|
||||
</ul>
|
||||
<UFormField
|
||||
:label="t('Type {0} to confirm', [email])"
|
||||
:help="t('The email must match exactly.')"
|
||||
:error="
|
||||
fieldError ? t('That email does not match. Check it and try again.') : undefined
|
||||
"
|
||||
>
|
||||
<UInput
|
||||
v-model="typed"
|
||||
autocomplete="off"
|
||||
:disabled="pending"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 rounded-none md:h-10' }"
|
||||
/>
|
||||
</UFormField>
|
||||
</template>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -4,6 +4,8 @@ import { t } from '@/i18n'
|
||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||
import RoleBadge from '@/components/RoleBadge.vue'
|
||||
import UserRowAction from '@/components/UserRowAction.vue'
|
||||
import UserRowMenu from '@/components/UserRowMenu.vue'
|
||||
import type { UserMenuContext } from '@/lib/userMenu'
|
||||
import UserEmailCell from '@/components/UserEmailCell.vue'
|
||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||
import {
|
||||
@@ -21,6 +23,7 @@ defineProps<{
|
||||
highlightedKey?: string
|
||||
canManage?: boolean
|
||||
busyKeys?: string[]
|
||||
menu?: UserMenuContext
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -53,11 +56,21 @@ function rowKey(item: UserListItem) {
|
||||
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
|
||||
:class="{ 'bg-elevated': rowKey(item) === highlightedKey }"
|
||||
>
|
||||
<UserEmailCell
|
||||
:email="item.email"
|
||||
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
||||
large
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<UserEmailCell
|
||||
:email="item.email"
|
||||
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
||||
large
|
||||
/>
|
||||
</div>
|
||||
<UserRowMenu
|
||||
v-if="item.kind === 'user' && menu"
|
||||
:user="item"
|
||||
:context="menu"
|
||||
:desktop="false"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="userDisplayName(item)" class="text-toned">{{
|
||||
userDisplayName(item)
|
||||
}}</span>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
|
||||
import type { AcceptedUser } from '@/composables/useUsers'
|
||||
import { userMenuItems, userMenuTitle, type UserMenuContext } from '@/lib/userMenu'
|
||||
|
||||
const props = defineProps<{
|
||||
user: AcceptedUser
|
||||
context: UserMenuContext
|
||||
desktop: boolean
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const items = computed(() => userMenuItems(props.user, props.context))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryRowMenu
|
||||
v-if="items.length > 0"
|
||||
v-model:open="open"
|
||||
:items="items"
|
||||
:title="userMenuTitle(user)"
|
||||
:desktop="desktop"
|
||||
/>
|
||||
</template>
|
||||
@@ -4,6 +4,8 @@ import { t } from '@/i18n'
|
||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||
import RoleBadge from '@/components/RoleBadge.vue'
|
||||
import UserRowAction from '@/components/UserRowAction.vue'
|
||||
import UserRowMenu from '@/components/UserRowMenu.vue'
|
||||
import type { UserMenuContext } from '@/lib/userMenu'
|
||||
import UserEmailCell from '@/components/UserEmailCell.vue'
|
||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||
import {
|
||||
@@ -24,6 +26,7 @@ defineProps<{
|
||||
highlightedKey?: string
|
||||
canManage?: boolean
|
||||
busyKeys?: string[]
|
||||
menu?: UserMenuContext
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -85,8 +88,14 @@ function rowKey(item: UserListItem) {
|
||||
<span><InviteStatusBadge :status="item.status" /></span>
|
||||
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
|
||||
<span class="flex justify-end">
|
||||
<UserRowMenu
|
||||
v-if="item.kind === 'user' && menu"
|
||||
:user="item"
|
||||
:context="menu"
|
||||
desktop
|
||||
/>
|
||||
<UserRowAction
|
||||
v-if="canManage"
|
||||
v-else-if="canManage"
|
||||
:item="item"
|
||||
:busy="busyKeys?.includes(rowKey(item))"
|
||||
@cancel="emit('cancel', item)"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ProfileCard from '@/components/ProfileCard.vue'
|
||||
import RoleBadge from '@/components/RoleBadge.vue'
|
||||
import RoleSummaryDialog from '@/components/RoleSummaryDialog.vue'
|
||||
import { buildHeldRolePreview } from '@/lib/rolePreview'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
|
||||
const permissions = usePermissionsStore()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const summaryOpen = ref(false)
|
||||
|
||||
const model = computed(() => buildHeldRolePreview(permissions.grants))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
icon="i-lucide-list-checks"
|
||||
class="self-start p-0 font-medium text-highlighted"
|
||||
@click="summaryOpen = true"
|
||||
>
|
||||
{{ t('See what I can do') }}
|
||||
</UButton>
|
||||
<p class="text-pretty text-muted">
|
||||
{{ t('Your API keys follow this role. If your role changes, they change too.') }}
|
||||
</p>
|
||||
<RoleSummaryDialog
|
||||
v-model:open="summaryOpen"
|
||||
:model="model"
|
||||
:role-name="permissions.roleName"
|
||||
:mobile="!isDesktop"
|
||||
/>
|
||||
</ProfileCard>
|
||||
</template>
|
||||
@@ -0,0 +1,270 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { AcceptedUser } from '@/composables/useUsers'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { adminAccess, customAccess, ownerAccess, roleRefFor } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import { CONTRIBUTOR, LEAD, WORLD, WRITER } from './userRolesKit'
|
||||
import { radiosOf, renderInApp } from './roleKit'
|
||||
import ChangeRoleDialog from '../ChangeRoleDialog.vue'
|
||||
|
||||
const listRoles = vi.fn()
|
||||
const changeUserRole = vi.fn()
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@/composables/useRoles', () => ({ useRoles: () => ({ listRoles }) }))
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ changeUserRole }) }))
|
||||
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: { id: WRITER.id, name: 'Writer', built_in: true, locked: false },
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
}
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function mountDialog(access: Access = ownerAccess(), user: AcceptedUser = INES) {
|
||||
setActivePinia(createPinia())
|
||||
useSessionStore().session = { id: 1, verified: true, displayName: '', access }
|
||||
wrapper = renderInApp(ChangeRoleDialog, { user, 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
|
||||
}
|
||||
|
||||
function options() {
|
||||
return radiosOf(dialog(), 'Role')
|
||||
}
|
||||
|
||||
function labelNamed(name: string) {
|
||||
return [...dialog().querySelectorAll('label')].find((candidate) =>
|
||||
candidate.querySelector('[data-slot="label"]')?.textContent?.includes(name),
|
||||
)!
|
||||
}
|
||||
|
||||
function optionNamed(name: string) {
|
||||
return labelNamed(name).querySelector<HTMLButtonElement>('[role="radio"]')!
|
||||
}
|
||||
|
||||
async function pick(name: string) {
|
||||
optionNamed(name).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
button('Change role')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return wrapper!.findComponent(ChangeRoleDialog).emitted(name)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
listRoles.mockResolvedValue(WORLD)
|
||||
changeUserRole.mockResolvedValue({
|
||||
...INES,
|
||||
role: { id: CONTRIBUTOR.id, name: 'Contributor', built_in: false, locked: false },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
listRoles.mockReset()
|
||||
changeUserRole.mockReset()
|
||||
})
|
||||
|
||||
describe('The dialog', () => {
|
||||
it('reads the title, the body and the options with their summaries and the Current mark', async () => {
|
||||
await mountDialog()
|
||||
|
||||
const text = dialog().textContent!
|
||||
expect(text).toContain('Change role for Ines Duarte')
|
||||
expect(text).toContain('Their API keys follow the new role.')
|
||||
expect(options().map((radio) => radio.closest('label')?.textContent)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Admin'),
|
||||
expect.stringContaining('Contributor'),
|
||||
expect.stringContaining('Content lead'),
|
||||
]),
|
||||
)
|
||||
expect(text).toContain(CONTRIBUTOR.description)
|
||||
expect(labelNamed('Writer').textContent).toContain('Current')
|
||||
expect(text).toContain('Cancel')
|
||||
})
|
||||
|
||||
it('keeps Change role disabled until an option other than the current one is chosen', async () => {
|
||||
await mountDialog()
|
||||
|
||||
expect(button('Change role')!.disabled).toBe(true)
|
||||
await pick('Writer')
|
||||
expect(button('Change role')!.disabled).toBe(true)
|
||||
await pick('Contributor')
|
||||
expect(button('Change role')!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Options blocked', () => {
|
||||
it.each([
|
||||
['an Admin', adminAccess()],
|
||||
['the Owner', ownerAccess()],
|
||||
])('%s sees Owner disabled with the one-owner reason', async (_who, access) => {
|
||||
await mountDialog(access)
|
||||
|
||||
const owner = optionNamed('Owner')
|
||||
expect(owner.disabled).toBe(true)
|
||||
expect(labelNamed('Owner').textContent).toContain(
|
||||
'There is one owner. Transfer ownership to change who it is.',
|
||||
)
|
||||
})
|
||||
|
||||
const peopleManager = () =>
|
||||
customAccess(
|
||||
'users.view, users.change_role, roles.view, content.read, content.update=own',
|
||||
'People manager',
|
||||
)
|
||||
|
||||
it('disables Content lead for a People manager with the escalation reason and enables Intern', async () => {
|
||||
await mountDialog(peopleManager())
|
||||
|
||||
const lead = optionNamed(LEAD.name)
|
||||
expect(lead.disabled).toBe(true)
|
||||
expect(labelNamed(LEAD.name).textContent).toContain(
|
||||
"Your role can't assign a role with more power than yours.",
|
||||
)
|
||||
expect(optionNamed('Contributor').disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Changing the role', () => {
|
||||
it('sends the chosen role, locks the dialog while in flight, then closes with the answer', async () => {
|
||||
let release!: (value: unknown) => void
|
||||
changeUserRole.mockReturnValue(new Promise((resolve) => (release = resolve)))
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(changeUserRole).toHaveBeenCalledWith(7, CONTRIBUTOR.id)
|
||||
expect(button('Changing…')!.disabled).toBe(true)
|
||||
expect(button('Cancel')!.disabled).toBe(true)
|
||||
|
||||
release({ ...INES, role: { id: 4, name: 'Contributor', built_in: false, locked: false } })
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('changed')?.[0]?.[0]).toMatchObject({ id: 7, role: { name: 'Contributor' } })
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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 stays open with its copy', async (code, title, description) => {
|
||||
changeUserRole.mockRejectedValue(new ApiError(403, undefined, code, { code }))
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(dialog().textContent).toContain(title)
|
||||
expect(dialog().textContent).toContain(description)
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('403 missing_permission closes the dialog and leaves the toast to the API client', async () => {
|
||||
changeUserRole.mockRejectedValue(
|
||||
new ApiError(403, undefined, 'missing_permission', { code: 'missing_permission' }),
|
||||
)
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
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' }),
|
||||
)
|
||||
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('400 invalid_role re-reads the roles and stays open with an inline error', async () => {
|
||||
changeUserRole.mockRejectedValue(
|
||||
new ApiError(400, undefined, 'invalid_role', { code: 'invalid_role' }),
|
||||
)
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(listRoles).toHaveBeenCalledTimes(2)
|
||||
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('500 stays open with an inline error', async () => {
|
||||
changeUserRole.mockRejectedValue(new ApiError(500))
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
expect(button('Change role')!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles are read through useRoles', () => {
|
||||
it('reads the role list once on open', async () => {
|
||||
await mountDialog()
|
||||
|
||||
expect(listRoles).toHaveBeenCalledTimes(1)
|
||||
expect(roleRefFor('admin')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
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 { 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 ESCALATION_REASON = "Your role can't assign a role with more power than yours."
|
||||
|
||||
const inviteUser = vi.fn()
|
||||
const listRoles = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useUsers', () => ({
|
||||
useUsers: () => ({ inviteUser }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useRoles', () => ({
|
||||
useRoles: () => ({ listRoles }),
|
||||
}))
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function emailInput() {
|
||||
@@ -26,7 +38,10 @@ function dispatch(element: HTMLElement, type: string) {
|
||||
element.dispatchEvent(new Event(type, { bubbles: true }))
|
||||
}
|
||||
|
||||
async function mountOpenModal() {
|
||||
async function mountOpenModal(access: Access = ownerAccess()) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { id: 1, verified: true, displayName: '', access }
|
||||
wrapper = mount(InviteUserModal, {
|
||||
attachTo: document.body,
|
||||
props: { open: true },
|
||||
@@ -35,19 +50,56 @@ async function mountOpenModal() {
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function clickSend() {
|
||||
const send = [...document.body.querySelectorAll('button')].find(
|
||||
function sendButton() {
|
||||
return [...document.body.querySelectorAll('button')].find(
|
||||
(button) => button.textContent?.trim() === 'Send invite',
|
||||
)!
|
||||
send.click()
|
||||
}
|
||||
|
||||
function clickSend() {
|
||||
sendButton().click()
|
||||
return flushPromises()
|
||||
}
|
||||
|
||||
async function typeEmail(value: string) {
|
||||
const input = emailInput()
|
||||
input.value = value
|
||||
dispatch(input, 'input')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function picker() {
|
||||
return document.body.querySelector<HTMLElement>('[aria-haspopup="listbox"]')
|
||||
}
|
||||
|
||||
async function openPicker() {
|
||||
picker()!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function option(name: string) {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find((candidate) =>
|
||||
candidate.textContent?.includes(name),
|
||||
)
|
||||
}
|
||||
|
||||
async function choose(name: string) {
|
||||
await openPicker()
|
||||
option(name)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
listRoles.mockResolvedValue(WORLD)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
inviteUser.mockReset()
|
||||
listRoles.mockReset()
|
||||
})
|
||||
|
||||
describe('Invite modal email rule', () => {
|
||||
@@ -71,3 +123,153 @@ describe('Invite modal email rule', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('The role picker in the invite dialog', () => {
|
||||
it('reads the design copy, defaults to Writer with its summary and hint', async () => {
|
||||
await mountOpenModal()
|
||||
|
||||
expect(dialogText()).toContain('Invite user')
|
||||
expect(dialogText()).toContain('They get an email with a link to set a password.')
|
||||
expect(picker()?.textContent).toContain('Writer')
|
||||
expect(dialogText()).toContain(WRITER.description)
|
||||
expect(dialogText()).toContain('Writer is the default. You can change a role later.')
|
||||
})
|
||||
|
||||
it('does not offer Owner', async () => {
|
||||
await mountOpenModal()
|
||||
await openPicker()
|
||||
|
||||
expect(option('Owner')).toBeUndefined()
|
||||
expect(option('Admin')).toBeDefined()
|
||||
})
|
||||
|
||||
it('drops the default hint once another role is chosen and sends its id', async () => {
|
||||
inviteUser.mockResolvedValue({ id: 9, email: 'lea.martin@renew.example' })
|
||||
await mountOpenModal()
|
||||
await choose('Contributor')
|
||||
|
||||
expect(dialogText()).not.toContain('Writer is the default.')
|
||||
await typeEmail('lea.martin@renew.example')
|
||||
await clickSend()
|
||||
|
||||
expect(inviteUser).toHaveBeenCalledWith('lea.martin@renew.example', CONTRIBUTOR.id)
|
||||
})
|
||||
|
||||
it('sends the Writer id by default', async () => {
|
||||
inviteUser.mockResolvedValue({ id: 9, email: 'lea.martin@renew.example' })
|
||||
await mountOpenModal()
|
||||
await typeEmail('lea.martin@renew.example')
|
||||
await clickSend()
|
||||
|
||||
expect(inviteUser).toHaveBeenCalledWith('lea.martin@renew.example', WRITER.id)
|
||||
})
|
||||
|
||||
it('closes on 201 and emits the invite', async () => {
|
||||
const created = { id: 9, kind: 'invite', email: 'lea.martin@renew.example' }
|
||||
inviteUser.mockResolvedValue(created)
|
||||
await mountOpenModal()
|
||||
await typeEmail('lea.martin@renew.example')
|
||||
await clickSend()
|
||||
|
||||
expect(wrapper!.emitted('invited')?.[0]).toEqual([created])
|
||||
expect(wrapper!.emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('The picker follows the no-escalation rule', () => {
|
||||
it.each([
|
||||
[OWNER.name, 'Content lead'],
|
||||
[ADMIN.name, 'Admin'],
|
||||
])('%s can pick %s', async (who, name) => {
|
||||
await mountOpenModal(who === 'Owner' ? ownerAccess() : adminAccess())
|
||||
await openPicker()
|
||||
|
||||
expect(option(name)?.getAttribute('aria-disabled')).not.toBe('true')
|
||||
expect(option(name)?.hasAttribute('data-disabled')).toBe(false)
|
||||
})
|
||||
|
||||
const inviter = () =>
|
||||
customAccess(
|
||||
'users.invite, roles.view, content.read=all, content.create, content.update=own, content.publish=own',
|
||||
'Inviter',
|
||||
)
|
||||
|
||||
it('disables a role above the caller with the reason and enables one they cover', async () => {
|
||||
await mountOpenModal(inviter())
|
||||
await openPicker()
|
||||
|
||||
expect(option('Writer')?.hasAttribute('data-disabled')).toBe(true)
|
||||
expect(option('Writer')?.textContent).toContain(ESCALATION_REASON)
|
||||
expect(option('Intern')?.hasAttribute('data-disabled')).toBe(false)
|
||||
expect(INTERN.name).toBe('Intern')
|
||||
})
|
||||
|
||||
it('shows Choose a role and keeps Send invite disabled when Writer is above the caller', async () => {
|
||||
await mountOpenModal(inviter())
|
||||
|
||||
expect(picker()?.textContent).toContain('Choose a role')
|
||||
expect(sendButton().disabled).toBe(true)
|
||||
await choose('Intern')
|
||||
expect(sendButton().disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inviting without roles.view', () => {
|
||||
it('does not read roles, shows no picker and sends the email only', async () => {
|
||||
inviteUser.mockResolvedValue({ id: 9, email: 'lea.martin@renew.example' })
|
||||
await mountOpenModal(customAccess('users.invite'))
|
||||
|
||||
expect(listRoles).not.toHaveBeenCalled()
|
||||
expect(picker()).toBeNull()
|
||||
await typeEmail('lea.martin@renew.example')
|
||||
await clickSend()
|
||||
|
||||
expect(inviteUser).toHaveBeenCalledWith('lea.martin@renew.example', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite answers that concern roles', () => {
|
||||
async function submitRefused(error: ApiError) {
|
||||
inviteUser.mockRejectedValue(error)
|
||||
await mountOpenModal()
|
||||
await typeEmail('lea.martin@renew.example')
|
||||
await clickSend()
|
||||
}
|
||||
|
||||
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.",
|
||||
],
|
||||
] as const)(
|
||||
'403 %s shows its copy and keeps the dialog and the address',
|
||||
async (code, title, description) => {
|
||||
await submitRefused(new ApiError(403, undefined, code, { code }))
|
||||
|
||||
expect(dialogText()).toContain(title)
|
||||
expect(dialogText()).toContain(description)
|
||||
expect(emailInput().value).toBe('lea.martin@renew.example')
|
||||
expect(wrapper!.emitted('invited')).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
it('429 shows the rate limit alert and keeps the dialog and the address', async () => {
|
||||
await submitRefused(new ApiError(429, undefined, undefined, undefined))
|
||||
|
||||
expect(dialogText()).toContain('Too many invites. Wait a moment, then try again.')
|
||||
expect(emailInput().value).toBe('lea.martin@renew.example')
|
||||
expect(wrapper!.emitted('invited')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('400 invalid_role re-reads the roles and returns the picker to Choose a role', async () => {
|
||||
await submitRefused(new ApiError(400, undefined, 'invalid_role', { code: 'invalid_role' }))
|
||||
|
||||
expect(listRoles).toHaveBeenCalledTimes(2)
|
||||
expect(emailInput().value).toBe('lea.martin@renew.example')
|
||||
expect(picker()?.textContent).toContain('Writer')
|
||||
expect(wrapper!.emitted('invited')).toBeUndefined()
|
||||
expect(LEAD.name).toBe('Content lead')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { renderInApp } from './roleKit'
|
||||
import { WORLD, WRITER } from './userRolesKit'
|
||||
import RolePicker from '../RolePicker.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function mountPicker(props: Record<string, unknown> = {}) {
|
||||
setActivePinia(createPinia())
|
||||
wrapper = renderInApp(RolePicker, {
|
||||
roles: WORLD,
|
||||
canAssign: () => true,
|
||||
label: 'Role',
|
||||
...props,
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function optionNames() {
|
||||
return [...document.body.querySelectorAll('[role="option"]')].map((option) =>
|
||||
option.textContent?.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function cardLabels() {
|
||||
return [...document.body.querySelectorAll('label')].map((label) => label.textContent ?? '')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('menu mode (slice 6 behaviour)', () => {
|
||||
it('hides the locked role and the source role', async () => {
|
||||
await mountPicker({ sourceId: WRITER.id })
|
||||
;(document.body.querySelector('[aria-haspopup="listbox"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
|
||||
const names = optionNames().join(' ')
|
||||
expect(names).not.toContain('Owner')
|
||||
expect(names).not.toContain('Writer')
|
||||
expect(names).toContain('Admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opt-in props', () => {
|
||||
it('lists the locked role disabled with the one-owner reason when showLocked is set', async () => {
|
||||
await mountPicker({ cards: true, showLocked: true })
|
||||
|
||||
const owner = cardLabels().find((text) => text.includes('Owner'))!
|
||||
expect(owner).toContain('There is one owner. Transfer ownership to change who it is.')
|
||||
const radio = [...document.body.querySelectorAll('label')]
|
||||
.find((label) => label.textContent?.includes('Owner'))!
|
||||
.querySelector<HTMLButtonElement>('[role="radio"]')!
|
||||
expect(radio.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('marks the current role', async () => {
|
||||
await mountPicker({ cards: true, showLocked: true, currentId: WRITER.id })
|
||||
|
||||
const marked = cardLabels().filter((text) => text.includes('Current'))
|
||||
expect(marked).toHaveLength(1)
|
||||
expect(marked[0]).toContain('Writer')
|
||||
})
|
||||
|
||||
it('shows a role the caller cannot cover as disabled with the escalation reason', async () => {
|
||||
await mountPicker({
|
||||
cards: true,
|
||||
canAssign: (role: { name: string }) => role.name !== 'Admin',
|
||||
})
|
||||
|
||||
const admin = cardLabels().find((text) => text.includes('Admin'))!
|
||||
expect(admin).toContain("Your role can't assign a role with more power than yours.")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { UserListItem } from '@/composables/useUsers'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ownerAccess, roleRefFor } from '@/test/access'
|
||||
import { renderInApp } from './roleKit'
|
||||
import TransferOwnershipDialog from '../TransferOwnershipDialog.vue'
|
||||
|
||||
const transferOwnership = vi.fn()
|
||||
const refresh = vi.fn()
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ transferOwnership }) }))
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
})
|
||||
|
||||
const JONAS_EMAIL = 'jonas@renewbuild.example'
|
||||
|
||||
function accepted(id: number, name: string, email: string, role: string): UserListItem {
|
||||
return {
|
||||
kind: 'user',
|
||||
id,
|
||||
username: name.toLowerCase().split(' ')[0]!,
|
||||
display_name: name,
|
||||
email,
|
||||
role: roleRefFor(role),
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const USERS: UserListItem[] = [
|
||||
{
|
||||
kind: 'invite',
|
||||
id: 9,
|
||||
email: 'pending@renewbuild.example',
|
||||
status: 'pending',
|
||||
created_at: '2026-09-27T09:00:00Z',
|
||||
},
|
||||
accepted(1, 'Marta Alves', 'marta@renewbuild.example', 'owner'),
|
||||
accepted(2, 'Jonas Weber', JONAS_EMAIL, 'admin'),
|
||||
accepted(3, 'Lea Martin', 'lea@renewbuild.example', 'admin'),
|
||||
]
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function mountDialog() {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { id: 1, verified: true, displayName: '', access: ownerAccess() }
|
||||
session.refresh = refresh
|
||||
wrapper = renderInApp(TransferOwnershipDialog, { users: USERS, 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
|
||||
}
|
||||
|
||||
function radio(name: string) {
|
||||
return [...dialog().querySelectorAll('label')]
|
||||
.find((candidate) => candidate.textContent?.includes(name))
|
||||
?.querySelector<HTMLButtonElement>('[role="radio"]')
|
||||
}
|
||||
|
||||
function field() {
|
||||
return dialog().querySelector('input') as HTMLInputElement
|
||||
}
|
||||
|
||||
async function pickJonas() {
|
||||
radio('Jonas Weber')!.click()
|
||||
await flushPromises()
|
||||
button('Continue')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function type(value: string) {
|
||||
field().value = value
|
||||
field().dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await pickJonas()
|
||||
await type(JONAS_EMAIL)
|
||||
button('Transfer ownership')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return wrapper!.findComponent(TransferOwnershipDialog).emitted(name)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
refresh.mockResolvedValue(undefined)
|
||||
transferOwnership.mockResolvedValue({
|
||||
owner: { id: 2, name: 'Jonas Weber' },
|
||||
former_owner: { id: 1, name: 'Marta Alves', role: roleRefFor('admin') },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
transferOwnership.mockReset()
|
||||
refresh.mockReset()
|
||||
})
|
||||
|
||||
describe('Pick a member', () => {
|
||||
it('reads the copy and offers accepted users other than the owner, never invites', async () => {
|
||||
await mountDialog()
|
||||
|
||||
expect(dialog().textContent).toContain('Transfer ownership')
|
||||
expect(dialog().textContent).toContain(
|
||||
'Choose who becomes the owner. Only members can be chosen.',
|
||||
)
|
||||
expect(radio('Jonas Weber')).toBeDefined()
|
||||
expect(radio('Lea Martin')).toBeDefined()
|
||||
expect(radio('Marta Alves')).toBeUndefined()
|
||||
expect(radio('pending@renewbuild.example')).toBeUndefined()
|
||||
expect(button('Cancel')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps Continue disabled until a member is chosen', async () => {
|
||||
await mountDialog()
|
||||
|
||||
expect(button('Continue')!.disabled).toBe(true)
|
||||
radio('Lea Martin')!.click()
|
||||
await flushPromises()
|
||||
expect(button('Continue')!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Confirm', () => {
|
||||
it('names the member, lists the consequences and asks for the email', async () => {
|
||||
await mountDialog()
|
||||
await pickJonas()
|
||||
|
||||
const text = dialog().textContent!
|
||||
expect(text).toContain('Transfer ownership to Jonas Weber')
|
||||
expect(text).toContain('This takes effect straight away.')
|
||||
expect(text).toContain('Jonas Weber becomes the owner.')
|
||||
expect(text).toContain(
|
||||
"You become an Admin. You can't take ownership back unless they transfer it.",
|
||||
)
|
||||
expect(text).toContain(
|
||||
'Only the owner can apply updates, delete a site and change activity retention.',
|
||||
)
|
||||
expect(text).toContain('Your API keys follow your new role.')
|
||||
expect(text).toContain(`Type ${JONAS_EMAIL} to confirm`)
|
||||
expect(text).toContain('The email must match exactly.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['', true],
|
||||
['jonas@renewbuild', true],
|
||||
['Jonas@renewbuild.example', true],
|
||||
[` ${JONAS_EMAIL}`, true],
|
||||
[`${JONAS_EMAIL} `, true],
|
||||
[JONAS_EMAIL, false],
|
||||
])('gates Transfer ownership on the exact email %j (disabled: %s)', async (typed, disabled) => {
|
||||
await mountDialog()
|
||||
await pickJonas()
|
||||
await type(typed)
|
||||
|
||||
expect(button('Transfer ownership')!.disabled).toBe(disabled)
|
||||
})
|
||||
|
||||
it('goes Back to the pick step with the member still chosen', async () => {
|
||||
await mountDialog()
|
||||
await pickJonas()
|
||||
button('Back')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(dialog().textContent).toContain('Choose who becomes the owner.')
|
||||
expect(radio('Jonas Weber')!.getAttribute('aria-checked')).toBe('true')
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Transferring', () => {
|
||||
it('sends the transfer, locks the dialog in flight, refreshes the session then the list', async () => {
|
||||
let release!: (value: unknown) => void
|
||||
transferOwnership.mockReturnValue(new Promise((resolve) => (release = resolve)))
|
||||
await mountDialog()
|
||||
await pickJonas()
|
||||
await type(JONAS_EMAIL)
|
||||
button('Transfer ownership')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(transferOwnership).toHaveBeenCalledWith(2, JONAS_EMAIL)
|
||||
expect(button('Transferring…')!.disabled).toBe(true)
|
||||
expect(button('Back')!.disabled).toBe(true)
|
||||
|
||||
release({ owner: { id: 2, name: 'Jonas Weber' }, former_owner: { id: 1 } })
|
||||
await flushPromises()
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(emitted('transferred')).toHaveLength(1)
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('400 invalid_transfer returns to the pick step with an inline error and asks for a re-read', async () => {
|
||||
transferOwnership.mockRejectedValue(
|
||||
new ApiError(400, undefined, 'invalid_transfer', { code: 'invalid_transfer' }),
|
||||
)
|
||||
await mountDialog()
|
||||
await submit()
|
||||
|
||||
expect(dialog().textContent).toContain('Choose who becomes the owner.')
|
||||
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('400 confirmation_mismatch shows an error under the field and stays on the step', async () => {
|
||||
transferOwnership.mockRejectedValue(
|
||||
new ApiError(400, undefined, 'confirmation_mismatch', {
|
||||
code: 'confirmation_mismatch',
|
||||
}),
|
||||
)
|
||||
await mountDialog()
|
||||
await submit()
|
||||
|
||||
expect(dialog().textContent).toContain('Transfer ownership to Jonas Weber')
|
||||
expect(field().getAttribute('aria-invalid')).toBe('true')
|
||||
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' }),
|
||||
)
|
||||
await mountDialog()
|
||||
await submit()
|
||||
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('403 session_required shows nothing and changes nothing', async () => {
|
||||
transferOwnership.mockRejectedValue(
|
||||
new ApiError(403, undefined, 'This action needs a real session.', {
|
||||
code: 'session_required',
|
||||
}),
|
||||
)
|
||||
await mountDialog()
|
||||
await submit()
|
||||
|
||||
expect(dialog().querySelector('[role="alert"]')).toBeNull()
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('500 stays open with an inline error', async () => {
|
||||
transferOwnership.mockRejectedValue(new ApiError(500))
|
||||
await mountDialog()
|
||||
await submit()
|
||||
|
||||
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { grantsFrom } from '@/test/access'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
export function roleItem(overrides: Partial<RoleItem> & Pick<RoleItem, 'id' | 'name'>): RoleItem {
|
||||
return {
|
||||
description: '',
|
||||
built_in: false,
|
||||
locked: false,
|
||||
version: 1,
|
||||
grants: [],
|
||||
members: 0,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export const OWNER = roleItem({
|
||||
id: 1,
|
||||
name: 'Owner',
|
||||
built_in: true,
|
||||
locked: true,
|
||||
description: 'Everything.',
|
||||
})
|
||||
export const ADMIN = roleItem({
|
||||
id: 2,
|
||||
name: 'Admin',
|
||||
built_in: true,
|
||||
description: 'Everything except owner-only.',
|
||||
grants: grantsFrom('content.publish, users.view'),
|
||||
})
|
||||
export const WRITER = roleItem({
|
||||
id: 3,
|
||||
name: 'Writer',
|
||||
built_in: true,
|
||||
description: 'Reads everything and writes content.',
|
||||
grants: grantsFrom('content.read, content.update'),
|
||||
})
|
||||
export const CONTRIBUTOR = roleItem({
|
||||
id: 4,
|
||||
name: 'Contributor',
|
||||
description: 'Creates content and edits only their own.',
|
||||
grants: grantsFrom('content.read, content.update=own'),
|
||||
})
|
||||
export const LEAD = roleItem({
|
||||
id: 5,
|
||||
name: 'Content lead',
|
||||
description: 'Edits and publishes all content.',
|
||||
grants: grantsFrom('content.read, content.update, content.publish'),
|
||||
})
|
||||
export const INTERN = roleItem({
|
||||
id: 6,
|
||||
name: 'Intern',
|
||||
description: 'Reads content.',
|
||||
grants: grantsFrom('content.read=own'),
|
||||
})
|
||||
|
||||
export const WORLD = [OWNER, ADMIN, WRITER, CONTRIBUTOR, LEAD, INTERN]
|
||||
@@ -82,6 +82,21 @@ describe('getMe maps the access object', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMe maps access.role_assigned_by', () => {
|
||||
it.each([
|
||||
['an object', { id: 3, name: 'Marta Alves' }, { id: 3, name: 'Marta Alves' }],
|
||||
['null', null, null],
|
||||
['an absent key', undefined, null],
|
||||
])('reads %s', async (_label, raw, expected) => {
|
||||
const body = { ...writerAccess(), ...(raw === undefined ? {} : { role_assigned_by: raw }) }
|
||||
fetchMock.mockResolvedValue(reply(200, { id: 4, is_verified: true, access: body }))
|
||||
|
||||
const me = await useApi().getMe()
|
||||
|
||||
expect(me.access?.roleAssignedBy).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('403 with a code', () => {
|
||||
it('rejects with the code and the missing permission and shows the toast naming it', async () => {
|
||||
signIn()
|
||||
@@ -142,6 +157,32 @@ describe('403 with a code', () => {
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['email_has_account', 'invite_pending'])(
|
||||
'reads the X-Error-Code header %s on a 409',
|
||||
async (code) => {
|
||||
signIn()
|
||||
fetchMock.mockResolvedValue(
|
||||
reply(409, null, { 'X-Error-Code': code }, 'Some server text'),
|
||||
)
|
||||
|
||||
await expect(
|
||||
useApi().request('/v1/user/invite', { method: 'POST' }),
|
||||
).rejects.toMatchObject({ status: 409, code, body: 'Some server text' })
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('leaves the code unset for an unknown X-Error-Code header', async () => {
|
||||
signIn()
|
||||
fetchMock.mockResolvedValue(reply(409, null, { 'X-Error-Code': 'something_new' }, 'Text'))
|
||||
|
||||
const error = await useApi()
|
||||
.request('/v1/user/invite', { method: 'POST' })
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toMatchObject({ status: 409, code: undefined })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['role_locked', 'role_locked'],
|
||||
['own_role', 'own_role'],
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { roleRefFor } from '@/test/access'
|
||||
import { userRole, type UserListItem } from '@/composables/useUsers'
|
||||
import { ApiError } from '@/lib/apiError'
|
||||
import { inlineRefusal, isCode, isMissingPermission, isSessionRequired } from '@/lib/userRefusal'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useUsers, userRole, type UserListItem } from '@/composables/useUsers'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const request = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
vi.mocked(useApi).mockReturnValue({ request } as unknown as ReturnType<typeof useApi>)
|
||||
})
|
||||
|
||||
describe('userRole', () => {
|
||||
it.each([['owner' as const], ['admin' as const]])('passes a user role %s through', (name) => {
|
||||
@@ -35,3 +50,95 @@ describe('userRole', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('useUsers role requests', () => {
|
||||
it('sends role_id with the invite when given and the email only when not', async () => {
|
||||
request.mockResolvedValue({ id: 1 })
|
||||
const { inviteUser } = useUsers()
|
||||
|
||||
await inviteUser('lea.martin@renew.example', 3)
|
||||
await inviteUser('lea.martin@renew.example')
|
||||
|
||||
expect(request.mock.calls[0]).toEqual([
|
||||
'/v1/user/invite',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'lea.martin@renew.example', role_id: 3 }),
|
||||
}),
|
||||
])
|
||||
expect(request.mock.calls[1]![1].body).toBe(
|
||||
JSON.stringify({ email: 'lea.martin@renew.example' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('changes a role with PUT /v1/user/{id}/role and returns the answer', async () => {
|
||||
const answer = { id: 4, kind: 'user', role: roleRefFor('admin') }
|
||||
request.mockResolvedValue(answer)
|
||||
|
||||
const result = await useUsers().changeUserRole(4, 2)
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'/v1/user/4/role',
|
||||
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ role_id: 2 }) }),
|
||||
)
|
||||
expect(result).toBe(answer)
|
||||
})
|
||||
|
||||
it('transfers ownership with POST /v1/site/transfer-ownership and returns the answer', async () => {
|
||||
const answer = { owner: { id: 2, name: 'Jonas' }, former_owner: { id: 1 } }
|
||||
request.mockResolvedValue(answer)
|
||||
|
||||
const result = await useUsers().transferOwnership(2, 'jonas@renewbuild.example')
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'/v1/site/transfer-ownership',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: 2, confirm_email: 'jonas@renewbuild.example' }),
|
||||
}),
|
||||
)
|
||||
expect(result).toBe(answer)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refusal mapping', () => {
|
||||
it.each([
|
||||
['escalation', "You can't grant that"],
|
||||
['role_locked', 'This role is locked'],
|
||||
['own_role', "You can't change your own role"],
|
||||
] as const)('shows %s inline', (code, title) => {
|
||||
const notice = inlineRefusal(new ApiError(403, undefined, code, { code }))
|
||||
|
||||
expect(notice?.title).toBe(title)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'user_role_changed',
|
||||
'invalid_role',
|
||||
'invalid_transfer',
|
||||
'confirmation_mismatch',
|
||||
'user_not_found',
|
||||
] as const)('does not show %s inline', (code) => {
|
||||
expect(inlineRefusal(new ApiError(409, undefined, code, { code }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tells missing_permission and session_required apart by status and code', () => {
|
||||
const missing = new ApiError(403, undefined, 'missing_permission', {
|
||||
code: 'missing_permission',
|
||||
})
|
||||
const session = new ApiError(403, undefined, 'This action needs a real session.', {
|
||||
code: 'session_required',
|
||||
})
|
||||
|
||||
expect(isMissingPermission(missing)).toBe(true)
|
||||
expect(isSessionRequired(session)).toBe(true)
|
||||
expect(
|
||||
isCode(
|
||||
new ApiError(400, undefined, 'invalid_role', { code: 'invalid_role' }),
|
||||
400,
|
||||
'invalid_role',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(isCode(new ApiError(500), 400, 'invalid_role')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ApiError, errorCodeOf } from '@/lib/apiError'
|
||||
import { ApiError, errorCodeOf, knownErrorCode } from '@/lib/apiError'
|
||||
import { missingPermissionCopy } from '@/lib/permissionCopy'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import type { Access, Grant, RoleRef } from '@/lib/permissions'
|
||||
import { parseRoleAssignedBy, type Access, type Grant, type RoleRef } from '@/lib/permissions'
|
||||
|
||||
export { ApiError }
|
||||
|
||||
@@ -193,7 +193,9 @@ export function useApi() {
|
||||
}
|
||||
|
||||
const body = await readErrorBody(response)
|
||||
throw new ApiError(response.status, undefined, body, { code: errorCodeOf(body) })
|
||||
throw new ApiError(response.status, undefined, body, {
|
||||
code: errorCodeOf(body) ?? knownErrorCode(response.headers.get('X-Error-Code')),
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -233,7 +235,7 @@ export function useApi() {
|
||||
username?: string
|
||||
email?: string
|
||||
display_name?: string | null
|
||||
access?: { role: RoleRef; permissions: Grant[] }
|
||||
access?: { role: RoleRef; permissions: Grant[]; role_assigned_by?: unknown }
|
||||
author?: MeAuthor | null
|
||||
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
|
||||
return {
|
||||
@@ -243,7 +245,13 @@ export function useApi() {
|
||||
email: body.email,
|
||||
displayName: body.display_name ?? '',
|
||||
...(body.access
|
||||
? { access: { role: body.access.role, permissions: body.access.permissions } }
|
||||
? {
|
||||
access: {
|
||||
role: body.access.role,
|
||||
permissions: body.access.permissions,
|
||||
roleAssignedBy: parseRoleAssignedBy(body.access.role_assigned_by),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(body.author ? { author: body.author } : {}),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { t } from '@/i18n'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useUsers, userRowKey } from '@/composables/useUsers'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import { inviteWindow, type CreatedInvite, type UserListItem } from '@/lib/users'
|
||||
import { inlineRefusal, refusalToast, userToast } from '@/lib/userRefusal'
|
||||
|
||||
const AUTH_HANDLED_STATUSES = [401, 403]
|
||||
const CANCEL_FAILED_COPY = "Couldn't cancel the invite. Try again in a moment."
|
||||
|
||||
function isHandledByApiClient(err: unknown) {
|
||||
return err instanceof ApiError && AUTH_HANDLED_STATUSES.includes(err.status)
|
||||
}
|
||||
|
||||
export function useInviteRows(items: Ref<UserListItem[]>, highlightedKey: Ref<string>) {
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const { cancelInvite, renewInvite } = useUsers()
|
||||
const busyKeys = ref<string[]>([])
|
||||
|
||||
function removeRow(key: string) {
|
||||
items.value = items.value.filter((item) => userRowKey(item.kind, item.id) !== key)
|
||||
}
|
||||
|
||||
function onInvited(invite: CreatedInvite) {
|
||||
const sameAddress = invite.email.toLowerCase()
|
||||
const newRow: UserListItem = {
|
||||
kind: 'invite',
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
role: invite.role ?? null,
|
||||
status: 'pending',
|
||||
created_at: invite.created_at,
|
||||
}
|
||||
items.value = [
|
||||
newRow,
|
||||
...items.value.filter(
|
||||
(item) => !(item.kind === 'invite' && item.email.toLowerCase() === sameAddress),
|
||||
),
|
||||
]
|
||||
highlightedKey.value = userRowKey('invite', invite.id)
|
||||
toast.add(
|
||||
userToast(
|
||||
`invite-sent-${invite.id}`,
|
||||
'success',
|
||||
() => t('Invite sent'),
|
||||
() => t('{0} has {1} to accept.', [invite.email, inviteWindow(invite)]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function confirmCancel(invite: UserListItem) {
|
||||
const key = userRowKey(invite.kind, invite.id)
|
||||
overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Cancel this invite?')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'The link sent to {0} will stop working. You can invite the address again later.',
|
||||
[invite.email],
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Cancel invite')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Keep invite')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Cancelling…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: async () => {
|
||||
try {
|
||||
await cancelInvite(invite.id)
|
||||
} catch (err) {
|
||||
if (isHandledByApiClient(err)) {
|
||||
return
|
||||
}
|
||||
throw new Error(t(CANCEL_FAILED_COPY))
|
||||
}
|
||||
removeRow(key)
|
||||
toast.add(
|
||||
userToast(
|
||||
`invite-cancelled-${invite.id}`,
|
||||
'success',
|
||||
() => t('Invite cancelled'),
|
||||
() => t('{0} can no longer use the link.', [invite.email]),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
.open()
|
||||
}
|
||||
|
||||
async function renew(invite: UserListItem) {
|
||||
const key = userRowKey(invite.kind, invite.id)
|
||||
if (busyKeys.value.includes(key)) {
|
||||
return
|
||||
}
|
||||
busyKeys.value = [...busyKeys.value, key]
|
||||
try {
|
||||
const renewed = await renewInvite(invite.id)
|
||||
items.value = [
|
||||
{
|
||||
kind: 'invite',
|
||||
id: renewed.id,
|
||||
email: renewed.email,
|
||||
role: renewed.role ?? invite.role ?? null,
|
||||
status: 'pending',
|
||||
created_at: renewed.created_at,
|
||||
},
|
||||
...items.value.filter((item) => userRowKey(item.kind, item.id) !== key),
|
||||
]
|
||||
highlightedKey.value = userRowKey('invite', renewed.id)
|
||||
toast.add(
|
||||
userToast(
|
||||
`invite-renewed-${renewed.id}`,
|
||||
'success',
|
||||
() => t('Invite renewed'),
|
||||
() =>
|
||||
t('{0} has {1} to accept the new link.', [
|
||||
renewed.email,
|
||||
inviteWindow(renewed),
|
||||
]),
|
||||
),
|
||||
)
|
||||
} catch (err) {
|
||||
const refusal = inlineRefusal(err)
|
||||
if (refusal !== undefined) {
|
||||
toast.add(refusalToast(`invite-renew-refused-${invite.id}`, refusal))
|
||||
} else if (!isHandledByApiClient(err)) {
|
||||
toast.add(
|
||||
userToast(
|
||||
`invite-renew-failed-${invite.id}`,
|
||||
'error',
|
||||
() => t("Couldn't renew the invite"),
|
||||
() => t('Try again in a moment.'),
|
||||
),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
busyKeys.value = busyKeys.value.filter((busyKey) => busyKey !== key)
|
||||
}
|
||||
}
|
||||
|
||||
return { busyKeys, onInvited, confirmCancel, renew }
|
||||
}
|
||||
@@ -1,27 +1,9 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { LinkableUser } from '@/lib/directoryTypes'
|
||||
import type { RoleRef } from '@/lib/permissions'
|
||||
import type { AcceptedUser, CreatedInvite, OwnershipTransfer, UserListItem } from '@/lib/users'
|
||||
|
||||
export type UserListItem =
|
||||
| {
|
||||
kind: 'user'
|
||||
id: number
|
||||
username: string
|
||||
display_name: string
|
||||
email: string
|
||||
role: RoleRef | null
|
||||
status: 'accepted'
|
||||
created_at: string
|
||||
}
|
||||
| {
|
||||
kind: 'invite'
|
||||
id: number
|
||||
email: string
|
||||
display_name?: string
|
||||
role?: RoleRef | null
|
||||
status: 'pending' | 'expired'
|
||||
created_at: string
|
||||
}
|
||||
export type { AcceptedUser, CreatedInvite, OwnershipTransfer, UserListItem }
|
||||
|
||||
export const USER_SKELETON_ROWS = 5
|
||||
|
||||
@@ -30,22 +12,14 @@ export function userRowKey(kind: UserListItem['kind'], id: number) {
|
||||
}
|
||||
|
||||
export function userDisplayName(item: UserListItem) {
|
||||
return item.display_name ?? ''
|
||||
return item.kind === 'user' ? item.display_name : ''
|
||||
}
|
||||
|
||||
export function userRole(item: UserListItem): RoleRef | null {
|
||||
return item.role ?? null
|
||||
}
|
||||
|
||||
export interface CreatedInvite {
|
||||
id: number
|
||||
kind: 'invite'
|
||||
email: string
|
||||
role?: RoleRef | null
|
||||
status: 'pending'
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' }
|
||||
|
||||
export function useUsers() {
|
||||
const { request } = useApi()
|
||||
@@ -71,11 +45,27 @@ export function useUsers() {
|
||||
)
|
||||
}
|
||||
|
||||
function inviteUser(email: string): Promise<CreatedInvite> {
|
||||
function inviteUser(email: string, roleId?: number): Promise<CreatedInvite> {
|
||||
return request<CreatedInvite>('/v1/user/invite', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(roleId === undefined ? { email } : { email, role_id: roleId }),
|
||||
})
|
||||
}
|
||||
|
||||
function changeUserRole(id: number, roleId: number): Promise<AcceptedUser> {
|
||||
return request<AcceptedUser>(`/v1/user/${id}/role`, {
|
||||
method: 'PUT',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ role_id: roleId }),
|
||||
})
|
||||
}
|
||||
|
||||
function transferOwnership(userId: number, confirmEmail: string): Promise<OwnershipTransfer> {
|
||||
return request<OwnershipTransfer>('/v1/site/transfer-ownership', {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ user_id: userId, confirm_email: confirmEmail }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,6 +83,8 @@ export function useUsers() {
|
||||
listUsers,
|
||||
listLinkableUsers,
|
||||
inviteUser,
|
||||
changeUserRole,
|
||||
transferOwnership,
|
||||
cancelInvite,
|
||||
renewInvite,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const API_KEYS_MESSAGES = [
|
||||
'Created {0} by {1}',
|
||||
'Last used {0}',
|
||||
'Expires {0}',
|
||||
"Keys act as the user who created them. As an owner you see everyone's keys.",
|
||||
"Keys act as the user who created them. You see everyone's keys.",
|
||||
'Keys act as you, with your role. You only see your own keys.',
|
||||
"Resetting a user's password revokes all of their keys.",
|
||||
'No API keys yet',
|
||||
|
||||
@@ -583,7 +583,8 @@ export const messages = {
|
||||
"We'll email them a link to choose a username and password. The link expires after 3 days.":
|
||||
'Nous lui enverrons un lien pour choisir un nom d’utilisateur et un mot de passe. Ce lien expire après 3 jours.',
|
||||
'Invite sent': 'Invitation envoyée',
|
||||
'{0} has 3 days to accept.': '{0} a 3 jours pour accepter.',
|
||||
'{0} has {1} to accept.': '{0} a {1} pour accepter.',
|
||||
'1 day': '1 jour',
|
||||
'Too many invites. Wait a moment, then try again.':
|
||||
'Trop d’invitations. Patientez, puis réessayez.',
|
||||
"There's already a pending invite for this address.":
|
||||
@@ -604,7 +605,7 @@ export const messages = {
|
||||
'Renew invite': 'Renouveler l’invitation',
|
||||
'Renewing…': 'Renouvellement…',
|
||||
'Invite renewed': 'Invitation renouvelée',
|
||||
'{0} has 3 days to accept the new link.': '{0} a 3 jours pour accepter le nouveau lien.',
|
||||
'{0} has {1} to accept the new link.': '{0} a {1} pour accepter le nouveau lien.',
|
||||
"Couldn't renew the invite": 'Impossible de renouveler l’invitation',
|
||||
'Cancel invite': 'Annuler l’invitation',
|
||||
'Cancel this invite?': 'Annuler cette invitation ?',
|
||||
@@ -861,8 +862,8 @@ export const messages = {
|
||||
'Created {0} by {1}': 'Créée le {0} par {1}',
|
||||
'Last used {0}': 'Dernière utilisation : {0}',
|
||||
'Expires {0}': 'Expire : {0}',
|
||||
"Keys act as the user who created them. As an owner you see everyone's keys.":
|
||||
'Les clés agissent comme l’utilisateur qui les a créées. En tant que propriétaire, vous voyez les clés de tout le monde.',
|
||||
"Keys act as the user who created them. You see everyone's keys.":
|
||||
'Les clés agissent comme l’utilisateur qui les a créées. Vous voyez les clés de tout le monde.',
|
||||
'Keys act as you, with your role. You only see your own keys.':
|
||||
'Les clés agissent en votre nom, avec votre rôle. Vous ne voyez que vos propres clés.',
|
||||
"Resetting a user's password revokes all of their keys.":
|
||||
@@ -3137,6 +3138,82 @@ export const messages = {
|
||||
'Install a new version of RenewCMS.': 'Installer une nouvelle version de RenewCMS.',
|
||||
'How long the activity log is kept.': 'Durée de conservation du journal d’activité.',
|
||||
'Make another member the owner.': 'Faire d’un autre membre le propriétaire.',
|
||||
'1 member has this role.': '1 membre a ce rôle.',
|
||||
"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}',
|
||||
'Changing…': 'Modification…',
|
||||
'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}',
|
||||
'Failed to create the role {0}': 'Échec de la création du rôle {0}',
|
||||
'Failed to delete the role {0}': 'Échec de la suppression du rôle {0}',
|
||||
'Failed to edit the role {0}': 'Échec de la modification du rôle {0}',
|
||||
'Failed to move the members of the role {0}': 'Échec du déplacement des membres du rôle {0}',
|
||||
'Failed to transfer ownership to {0}': 'Échec du transfert de la propriété à {0}',
|
||||
'Follows role': 'Suit le rôle',
|
||||
"Keys follow their creator's role": 'Les clés suivent le rôle de leur créateur',
|
||||
'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é',
|
||||
'Role {0} created': 'Rôle {0} créé',
|
||||
'Role {0} deleted': 'Rôle {0} supprimé',
|
||||
'Role {0} edited': 'Rôle {0} modifié',
|
||||
'See what I can do': 'Voir ce que je peux faire',
|
||||
'That email does not match. Check it and try again.':
|
||||
'Cet e-mail ne correspond pas. Vérifiez-le et réessayez.',
|
||||
"That person can't become the owner. Choose another member.":
|
||||
'Cette personne ne peut pas devenir propriétaire. Choisissez un autre membre.',
|
||||
"That role isn't available anymore. Choose another one.":
|
||||
'Ce rôle n’est plus disponible. Choisissez-en un autre.',
|
||||
'That user no longer exists': 'Cet utilisateur n’existe plus',
|
||||
'The email must match exactly.': 'L’e-mail doit correspondre exactement.',
|
||||
'The list was refreshed.': 'La liste a été actualisée.',
|
||||
"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.':
|
||||
'La personne reçoit un e-mail avec un lien pour définir un mot de passe.',
|
||||
'This takes effect straight away.': 'Cela prend effet immédiatement.',
|
||||
'Transfer ownership to {0}': 'Transférer la propriété à {0}',
|
||||
'Transferring…': 'Transfert…',
|
||||
'Type {0} to confirm': 'Saisissez {0} pour confirmer',
|
||||
'What changed': 'Ce qui a changé',
|
||||
'What you can do': 'Ce que vous pouvez faire',
|
||||
'Writer is the default. You can change a role later.':
|
||||
'Writer est le rôle par défaut. Vous pourrez le changer plus tard.',
|
||||
"You become an Admin. You can't take ownership back unless they transfer it.":
|
||||
'Vous devenez Admin. Vous ne pourrez pas reprendre la propriété sauf si cette personne vous la transfère.',
|
||||
"You can't change your own role. Ask another owner or admin.":
|
||||
'Vous ne pouvez pas changer votre propre rôle. Demandez à un autre propriétaire ou administrateur.',
|
||||
'You need permission to view roles to change a role.':
|
||||
'Il faut pouvoir consulter les rôles pour changer un rôle.',
|
||||
'Your API keys follow this role. If your role changes, they change too.':
|
||||
'Vos clés d’API suivent ce rôle. Si votre rôle change, elles changent aussi.',
|
||||
'Your API keys follow your new role.': 'Vos clés d’API suivent votre nouveau rôle.',
|
||||
'Your role': 'Votre rôle',
|
||||
'{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}',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -61,3 +61,39 @@ describe('Roles screens copy', () => {
|
||||
expect(t('Roles')).toBe('Rôles')
|
||||
})
|
||||
})
|
||||
|
||||
const USERS_ROLES_KEYS = [
|
||||
'Assigned by {0}',
|
||||
'Your role',
|
||||
'See what I can do',
|
||||
'What you can do',
|
||||
'A summary of your role, {0}. Read-only.',
|
||||
'Change role',
|
||||
'Change role for {0}',
|
||||
'Their API keys follow the new role.',
|
||||
'Transfer ownership to {0}',
|
||||
'Type {0} to confirm',
|
||||
'The email must match exactly.',
|
||||
"Keys follow their creator's role",
|
||||
'Follows role',
|
||||
'What changed',
|
||||
'Role {0} created',
|
||||
'Role {0} edited',
|
||||
'Role {0} deleted',
|
||||
"{0}'s role changed from {1} to {2}",
|
||||
'Ownership transferred from {0} to {1}',
|
||||
'They get an email with a link to set a password.',
|
||||
'Writer is the default. You can change a role later.',
|
||||
] as const
|
||||
|
||||
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')
|
||||
setLocale('fr')
|
||||
expect(t('Assigned by {0}', ['Marta Alves'])).toBe('Attribué par Marta Alves')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ACTIVITY_CATEGORIES, categoryIcon, categoryLabel } from '@/lib/activity'
|
||||
|
||||
describe('activity categories', () => {
|
||||
it('offers the users and roles categories with a name and an icon', () => {
|
||||
expect(ACTIVITY_CATEGORIES).toContain('users')
|
||||
expect(ACTIVITY_CATEGORIES).toContain('roles')
|
||||
expect(categoryLabel('roles')).toBe('Roles')
|
||||
expect(categoryIcon('roles')).toMatch(/^i-lucide-/)
|
||||
})
|
||||
})
|
||||
@@ -285,6 +285,12 @@ describe('The catalogue is complete in both languages', () => {
|
||||
'system.restart_requested',
|
||||
'system.activity_retention_changed',
|
||||
'system.activity_cleanup',
|
||||
'role.create',
|
||||
'role.edit',
|
||||
'role.delete',
|
||||
'role.reassign',
|
||||
'user.change_role',
|
||||
'user.transfer_ownership',
|
||||
].sort(),
|
||||
)
|
||||
})
|
||||
@@ -293,3 +299,84 @@ describe('The catalogue is complete in both languages', () => {
|
||||
expect(t('Revoked API key {0}', ['Mobile app'])).toBe('Revoked API key Mobile app')
|
||||
})
|
||||
})
|
||||
|
||||
describe('role and ownership sentences', () => {
|
||||
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
|
||||
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
|
||||
|
||||
it.each([
|
||||
['role.create', 'Contributor', 'Role Contributor created'],
|
||||
['role.edit', 'Contributor', 'Role Contributor edited'],
|
||||
['role.delete', 'Intern', 'Role Intern deleted'],
|
||||
])('writes %s as a sentence', (action, name, sentence) => {
|
||||
const event = item({ action, category: 'roles', target: roleTarget(name) })
|
||||
|
||||
expect(sentenceText(event)).toBe(sentence)
|
||||
const strong = composeSentence(event).find((segment) => segment.strong)
|
||||
expect(strong?.text).toBe(name)
|
||||
})
|
||||
|
||||
it('writes user.change_role with the role before and after', () => {
|
||||
const event = item({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
target: userTarget('Ines Duarte'),
|
||||
changes: [{ field: 'role', kind: 'text', before: 'Writer', after: 'Content lead' }],
|
||||
})
|
||||
|
||||
expect(sentenceText(event)).toBe("Ines Duarte's role changed from Writer to Content lead")
|
||||
})
|
||||
|
||||
it('falls back for user.change_role without a role change', () => {
|
||||
const event = item({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
target: userTarget('Ines Duarte'),
|
||||
})
|
||||
|
||||
expect(sentenceText(event)).toContain('Ines Duarte')
|
||||
})
|
||||
|
||||
it('writes user.transfer_ownership from the actor to the target', () => {
|
||||
const event = item({
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
security: true,
|
||||
target: userTarget('Jonas Weber'),
|
||||
})
|
||||
|
||||
expect(sentenceText(event)).toBe('Ownership transferred from Marta Alves to Jonas Weber')
|
||||
})
|
||||
|
||||
it('writes role.reassign and the failed templates without a crash', () => {
|
||||
const reassign = item({
|
||||
action: 'role.reassign',
|
||||
category: 'roles',
|
||||
target: roleTarget('Contributor'),
|
||||
params: { from: 4, to: 3, moved: 4 },
|
||||
})
|
||||
|
||||
expect(sentenceText(reassign)).toContain('Contributor')
|
||||
for (const action of [
|
||||
'role.create',
|
||||
'role.edit',
|
||||
'role.delete',
|
||||
'role.reassign',
|
||||
'user.change_role',
|
||||
'user.transfer_ownership',
|
||||
]) {
|
||||
const failed = item({
|
||||
action,
|
||||
outcome: 'failed',
|
||||
category: 'roles',
|
||||
target: roleTarget('Contributor'),
|
||||
})
|
||||
expect(sentenceText(failed)).toContain('Contributor')
|
||||
expect(sentenceText(failed)).not.toBe(sentenceText({ ...failed, outcome: 'success' }))
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an unknown action as its own name', () => {
|
||||
expect(sentenceText(item({ action: 'role.unknown' }))).toBe('role.unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseRoleAssignedBy } from '@/lib/permissions'
|
||||
|
||||
describe('parseRoleAssignedBy', () => {
|
||||
it('reads the object', () => {
|
||||
expect(parseRoleAssignedBy({ id: 3, name: 'Marta Alves' })).toEqual({
|
||||
id: 3,
|
||||
name: 'Marta Alves',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[null],
|
||||
[undefined],
|
||||
[{}],
|
||||
[{ id: 3, name: '' }],
|
||||
[{ id: '3', name: 'Marta' }],
|
||||
['x'],
|
||||
])('reads %j as no assigner', (value) => {
|
||||
expect(parseRoleAssignedBy(value)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError } from '@/lib/apiError'
|
||||
import { inlineRefusal, refusalToast, userToast } from '@/lib/userRefusal'
|
||||
import { inviteWindow } from '@/lib/users'
|
||||
|
||||
describe('userToast', () => {
|
||||
it('builds a success toast with the check icon and lazy text', () => {
|
||||
let title = 'first'
|
||||
const build = userToast('id-1', 'success', () => title, 'fixed')
|
||||
|
||||
const toast = build()
|
||||
expect(toast).toMatchObject({
|
||||
id: 'id-1',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'first',
|
||||
description: 'fixed',
|
||||
})
|
||||
title = 'second'
|
||||
expect(toast.title).toBe('second')
|
||||
})
|
||||
|
||||
it('builds an error toast with the cross icon', () => {
|
||||
expect(userToast('id-2', 'error', 'Oops', 'Try again')()).toMatchObject({
|
||||
id: 'id-2',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: 'Oops',
|
||||
description: 'Try again',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('refusalToast', () => {
|
||||
it('shows the refusal copy of an inline refusal as an error toast', () => {
|
||||
const refusal = inlineRefusal(
|
||||
new ApiError(403, undefined, 'own_role', { code: 'own_role' }),
|
||||
)!
|
||||
|
||||
expect(refusalToast('refused-1', refusal)()).toMatchObject({
|
||||
id: 'refused-1',
|
||||
color: 'error',
|
||||
title: refusal.title,
|
||||
description: refusal.description,
|
||||
})
|
||||
expect(refusal.title).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inviteWindow', () => {
|
||||
const created = '2026-10-01T09:00:00Z'
|
||||
|
||||
it.each([
|
||||
['2026-10-02T09:00:00Z', '1 day'],
|
||||
['2026-10-04T09:00:00Z', '3 days'],
|
||||
['2026-10-08T09:00:00Z', '7 days'],
|
||||
['2026-10-01T09:00:00Z', '1 day'],
|
||||
])('words an expiry at %s as %s', (expires, wording) => {
|
||||
expect(inviteWindow({ created_at: created, expires_at: expires })).toBe(wording)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ export const ACTIVITY_CATEGORIES = [
|
||||
'api_keys',
|
||||
'auth',
|
||||
'system',
|
||||
'roles',
|
||||
] as const
|
||||
|
||||
export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number]
|
||||
@@ -213,6 +214,8 @@ export function categoryLabel(category: ActivityCategory) {
|
||||
return t('Authentication')
|
||||
case 'system':
|
||||
return t('System')
|
||||
case 'roles':
|
||||
return t('Roles')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +233,8 @@ export function categoryIcon(category: ActivityCategory) {
|
||||
return 'i-lucide-shield-check'
|
||||
case 'system':
|
||||
return 'i-lucide-server-cog'
|
||||
case 'roles':
|
||||
return 'i-lucide-shield'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +365,8 @@ export function targetTypeLabel(type: string) {
|
||||
return t('API key')
|
||||
case 'release':
|
||||
return t('Release')
|
||||
case 'role':
|
||||
return t('Role')
|
||||
default:
|
||||
return type
|
||||
}
|
||||
@@ -377,6 +384,8 @@ export function targetLink(target: ActivityTarget | null) {
|
||||
return '/users'
|
||||
case 'api_key':
|
||||
return '/integration/api-keys'
|
||||
case 'role':
|
||||
return `/settings/roles/${target.id}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -394,6 +403,12 @@ export function fieldLabel(field: string) {
|
||||
return t('File name')
|
||||
case 'display_name':
|
||||
return t('Display name')
|
||||
case 'name':
|
||||
return t('Name')
|
||||
case 'description':
|
||||
return t('Description')
|
||||
case 'role':
|
||||
return t('Role')
|
||||
default:
|
||||
return field
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import { isScope } from '@/lib/permissions'
|
||||
|
||||
export type RoleDiffKind = 'added' | 'removed' | 'changed'
|
||||
|
||||
export interface RoleDiffRow {
|
||||
kind: RoleDiffKind
|
||||
text: string
|
||||
}
|
||||
|
||||
function entriesOf(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
typeof entry === 'object' && entry !== null,
|
||||
)
|
||||
: []
|
||||
}
|
||||
|
||||
function scopeText(value: unknown) {
|
||||
switch (isScope(value) ? value : undefined) {
|
||||
case 'none':
|
||||
return t('None')
|
||||
case 'own':
|
||||
return t('Own')
|
||||
case 'all':
|
||||
return t('All')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function keyName(entry: Record<string, unknown>) {
|
||||
const key = typeof entry.key === 'string' ? entry.key : ''
|
||||
return permissionName(key) ?? key
|
||||
}
|
||||
|
||||
function grantText(entry: Record<string, unknown>) {
|
||||
const name = keyName(entry)
|
||||
return entry.scope === 'own' ? `${name} (${scopeText('own')})` : name
|
||||
}
|
||||
|
||||
function changedText(entry: Record<string, unknown>) {
|
||||
return t('{0}: {1} → {2}', [keyName(entry), scopeText(entry.from), scopeText(entry.to)])
|
||||
}
|
||||
|
||||
export function roleDiffRows(item: ActivityItem): RoleDiffRow[] {
|
||||
if (item.action !== 'role.edit') {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
...entriesOf(item.params.added).map((entry) => ({
|
||||
kind: 'added' as const,
|
||||
text: grantText(entry),
|
||||
})),
|
||||
...entriesOf(item.params.removed).map((entry) => ({
|
||||
kind: 'removed' as const,
|
||||
text: grantText(entry),
|
||||
})),
|
||||
...entriesOf(item.params.changed).map((entry) => ({
|
||||
kind: 'changed' as const,
|
||||
text: changedText(entry),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export function roleMembersLine(item: ActivityItem): string | null {
|
||||
const members = item.params.members
|
||||
if (item.action !== 'role.delete' || typeof members !== 'number' || members <= 0) {
|
||||
return null
|
||||
}
|
||||
return members === 1
|
||||
? t('1 member has this role.')
|
||||
: t('{0} members have this role.', [members])
|
||||
}
|
||||
|
||||
export function roleDiffLabel(kind: RoleDiffKind) {
|
||||
switch (kind) {
|
||||
case 'added':
|
||||
return t('Added')
|
||||
case 'removed':
|
||||
return t('Removed')
|
||||
case 'changed':
|
||||
return t('Changed')
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,30 @@ const CATALOGUE: Record<string, Template> = {
|
||||
success: 'Accepted the invite for {0}',
|
||||
failed: 'Failed to accept the invite for {0}',
|
||||
},
|
||||
'role.create': {
|
||||
success: 'Role {0} created',
|
||||
failed: 'Failed to create the role {0}',
|
||||
},
|
||||
'role.edit': {
|
||||
success: 'Role {0} edited',
|
||||
failed: 'Failed to edit the role {0}',
|
||||
},
|
||||
'role.delete': {
|
||||
success: 'Role {0} deleted',
|
||||
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}',
|
||||
},
|
||||
'user.change_role': {
|
||||
success: 'Changed the role of {0}',
|
||||
failed: 'Failed to change the role of {0}',
|
||||
},
|
||||
'user.transfer_ownership': {
|
||||
success: 'Ownership transferred from {0} to {1}',
|
||||
failed: 'Failed to transfer ownership to {0}',
|
||||
},
|
||||
'api_key.create': {
|
||||
success: 'Created API key {0}',
|
||||
failed: 'Failed to create API key {0}',
|
||||
@@ -198,9 +222,19 @@ function retentionValues(params: Record<string, unknown>) {
|
||||
return changedSize(params) ? [...ages, ...sizes] : ages
|
||||
}
|
||||
|
||||
function roleChangeOf(item: ActivityItem) {
|
||||
return item.changes.find((change) => change.field === 'role' && change.kind === 'text')
|
||||
}
|
||||
|
||||
function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||
const template = CATALOGUE[item.action]
|
||||
if (!template) return null
|
||||
if (item.action === 'user.change_role' && item.outcome === 'success' && roleChangeOf(item)) {
|
||||
return "{0}'s role changed from {1} to {2}"
|
||||
}
|
||||
if (item.action === 'user.transfer_ownership' && item.outcome === 'failed') {
|
||||
return template.failed ?? template.success
|
||||
}
|
||||
if (item.action === 'system.activity_retention_changed') {
|
||||
return retentionTemplate(item.params)
|
||||
}
|
||||
@@ -223,6 +257,13 @@ function slotValues(item: ActivityItem, template: MessageKey) {
|
||||
if (item.action === 'system.activity_cleanup') {
|
||||
return [formatNumber(count(item.params.removed))]
|
||||
}
|
||||
if (item.action === 'user.transfer_ownership' && item.outcome === 'success') {
|
||||
return [item.actor.name, TARGET_SLOT]
|
||||
}
|
||||
const roleChange = roleChangeOf(item)
|
||||
if (template.includes('{2}') && roleChange?.kind === 'text') {
|
||||
return [TARGET_SLOT, roleChange.before, roleChange.after]
|
||||
}
|
||||
return template.includes('{0}') ? [TARGET_SLOT] : []
|
||||
}
|
||||
|
||||
@@ -232,7 +273,7 @@ function targetName(item: ActivityItem) {
|
||||
}
|
||||
|
||||
function usesTargetSlot(values: string[]) {
|
||||
return values.length === 1 && values[0] === TARGET_SLOT
|
||||
return values.includes(TARGET_SLOT)
|
||||
}
|
||||
|
||||
export function composeSentence(item: ActivityItem): SentenceSegment[] {
|
||||
|
||||
+11
-1
@@ -13,6 +13,12 @@ export const KNOWN_ERROR_CODES = [
|
||||
'role_not_found',
|
||||
'user_role_changed',
|
||||
'session_required',
|
||||
'invalid_role',
|
||||
'invalid_transfer',
|
||||
'confirmation_mismatch',
|
||||
'user_not_found',
|
||||
'email_has_account',
|
||||
'invite_pending',
|
||||
] as const
|
||||
|
||||
export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number]
|
||||
@@ -29,7 +35,11 @@ export function errorCodeOf(body: string | undefined): ApiErrorCode | undefined
|
||||
if (body === SESSION_REQUIRED_MESSAGE) {
|
||||
return 'session_required'
|
||||
}
|
||||
return KNOWN_ERROR_CODES.find((code) => code === body)
|
||||
return knownErrorCode(body)
|
||||
}
|
||||
|
||||
export function knownErrorCode(value: string | null | undefined): ApiErrorCode | undefined {
|
||||
return KNOWN_ERROR_CODES.find((code) => code === value)
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { t } from '@/i18n'
|
||||
import { parseGrantKey, type Scope } from '@/lib/permissions'
|
||||
import { parseGrantKey, SCOPE_RANK, type Scope } from '@/lib/permissions'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
export type CallerScope = (key: string, options?: { typeKey?: string }) => Scope
|
||||
|
||||
const RANK: Record<Scope, number> = { none: 0, own: 1, all: 2 }
|
||||
|
||||
const CONTENT_VERBS: Record<string, () => string> = {
|
||||
'content.read': () => t('read'),
|
||||
'content.update': () => t('update'),
|
||||
@@ -36,6 +34,6 @@ export function canCover(role: Pick<RoleItem, 'grants'>, scopeOf: CallerScope):
|
||||
return role.grants.every((grant) => {
|
||||
const { base, typeKey } = parseGrantKey(grant.key)
|
||||
const held = typeKey === undefined ? scopeOf(base) : scopeOf(base, { typeKey })
|
||||
return RANK[held] >= RANK[grant.scope]
|
||||
return SCOPE_RANK[held] >= SCOPE_RANK[grant.scope]
|
||||
})
|
||||
}
|
||||
@@ -55,6 +55,10 @@ export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCop
|
||||
case 'invalid_reassign':
|
||||
case 'role_not_found':
|
||||
case 'session_required':
|
||||
case 'invalid_role':
|
||||
case 'invalid_transfer':
|
||||
case 'confirmation_mismatch':
|
||||
case 'user_not_found':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
export type Scope = 'none' | 'own' | 'all'
|
||||
|
||||
export const SCOPE_RANK: Record<Scope, number> = { none: 0, own: 1, all: 2 }
|
||||
|
||||
export function isScope(value: unknown): value is Scope {
|
||||
return typeof value === 'string' && Object.keys(SCOPE_RANK).includes(value)
|
||||
}
|
||||
|
||||
export interface Grant {
|
||||
key: string
|
||||
scope: Scope
|
||||
@@ -12,9 +18,23 @@ export interface RoleRef {
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export interface AssignedBy {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Access {
|
||||
role: RoleRef
|
||||
permissions: Grant[]
|
||||
roleAssignedBy?: AssignedBy | null
|
||||
}
|
||||
|
||||
export function parseRoleAssignedBy(value: unknown): AssignedBy | null {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null
|
||||
}
|
||||
const { id, name } = value as { id?: unknown; name?: unknown }
|
||||
return typeof id === 'number' && typeof name === 'string' && name !== '' ? { id, name } : null
|
||||
}
|
||||
|
||||
export interface CanOptions {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from '@/i18n'
|
||||
import { CONTENT_ACTIONS, contentKey, scopeAt, type GrantMap } from '@/lib/roleForm'
|
||||
import { parseGrantKey, type Scope } from '@/lib/permissions'
|
||||
import { parseGrantKey, type Grant, type Scope } from '@/lib/permissions'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
|
||||
export interface PreviewType {
|
||||
key: string
|
||||
@@ -287,3 +288,28 @@ export function buildRolePreview(input: PreviewInput): RolePreviewModel {
|
||||
members: membersLine(input.members),
|
||||
}
|
||||
}
|
||||
|
||||
function grantMapOf(grants: readonly Grant[]): GrantMap {
|
||||
return Object.fromEntries(grants.map((grant) => [grant.key, grant.scope]))
|
||||
}
|
||||
|
||||
function typeKeysOf(grants: readonly Grant[]) {
|
||||
const keys = grants
|
||||
.map((grant) => parseGrantKey(grant.key))
|
||||
.filter(({ base }) => base.startsWith('content.'))
|
||||
.map(({ typeKey }) => typeKey)
|
||||
.filter((key): key is string => key !== undefined)
|
||||
return [...new Set(keys)]
|
||||
}
|
||||
|
||||
export function buildHeldRolePreview(grants: readonly Grant[]): RolePreviewModel {
|
||||
return buildRolePreview({
|
||||
grants: grantMapOf(grants),
|
||||
types: [
|
||||
{ key: ARTICLE_TYPE_KEY, name: t('Articles') },
|
||||
...typeKeysOf(grants)
|
||||
.filter((key) => key !== ARTICLE_TYPE_KEY)
|
||||
.map((key) => ({ key, name: key })),
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { AcceptedUser } from '@/lib/users'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
|
||||
export interface UserMenuContext {
|
||||
sessionUserId?: number
|
||||
canChangeRole: boolean
|
||||
canTransfer: boolean
|
||||
canViewRoles: boolean
|
||||
changeRole: (user: AcceptedUser, trigger: HTMLElement | null) => void
|
||||
transfer: (trigger: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
function changeRoleHint(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 change your own role. Ask another owner or admin.")
|
||||
}
|
||||
if (!context.canViewRoles) {
|
||||
return t('You need permission to view roles to change a role.')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function userMenuItems(user: AcceptedUser, context: UserMenuContext): DirectoryMenuItem[] {
|
||||
const items: DirectoryMenuItem[] = []
|
||||
if (context.canChangeRole) {
|
||||
const hint = changeRoleHint(user, context)
|
||||
items.push({
|
||||
key: 'change-role',
|
||||
label: t('Change role'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
unavailable: hint !== undefined,
|
||||
hint,
|
||||
onSelect: (trigger) => context.changeRole(user, trigger),
|
||||
})
|
||||
}
|
||||
if (context.canTransfer && user.role?.locked) {
|
||||
items.push({
|
||||
key: 'transfer-ownership',
|
||||
label: t('Transfer ownership'),
|
||||
icon: 'i-lucide-crown',
|
||||
onSelect: (trigger) => context.transfer(trigger),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function userMenuTitle(user: AcceptedUser) {
|
||||
return user.display_name || user.email
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ApiError, type ApiErrorCode } from '@/lib/apiError'
|
||||
import { refusalCopy } from '@/lib/permissionCopy'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const INLINE_REFUSALS: ApiErrorCode[] = ['escalation', 'role_locked', 'own_role']
|
||||
|
||||
export function isCode(error: unknown, status: number, code?: ApiErrorCode): error is ApiError {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.status === status &&
|
||||
(code === undefined || error.code === code)
|
||||
)
|
||||
}
|
||||
|
||||
export function inlineRefusal(error: unknown): DirectoryNotice | undefined {
|
||||
if (!(error instanceof ApiError) || error.status !== 403 || error.code === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!INLINE_REFUSALS.includes(error.code)) {
|
||||
return undefined
|
||||
}
|
||||
const copy = refusalCopy(error.code, error.permission)
|
||||
return copy === undefined
|
||||
? undefined
|
||||
: { title: copy.title, description: copy.description ?? '' }
|
||||
}
|
||||
|
||||
type ToastText = string | (() => string)
|
||||
|
||||
function resolveText(text: ToastText) {
|
||||
return typeof text === 'function' ? text() : text
|
||||
}
|
||||
|
||||
export function userToast(
|
||||
id: string,
|
||||
color: 'success' | 'error',
|
||||
title: ToastText,
|
||||
description: ToastText,
|
||||
) {
|
||||
return () => ({
|
||||
id,
|
||||
color,
|
||||
icon: color === 'success' ? 'i-lucide-circle-check' : 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return resolveText(title)
|
||||
},
|
||||
get description() {
|
||||
return resolveText(description)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function refusalToast(id: string, refusal: DirectoryNotice) {
|
||||
return userToast(id, 'error', refusal.title ?? '', refusal.description)
|
||||
}
|
||||
|
||||
export function isMissingPermission(error: unknown) {
|
||||
return isCode(error, 403, 'missing_permission')
|
||||
}
|
||||
|
||||
export function isSessionRequired(error: unknown) {
|
||||
return isCode(error, 403, 'session_required')
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { AssignedBy, RoleRef } from '@/lib/permissions'
|
||||
|
||||
export type UserListItem =
|
||||
| {
|
||||
kind: 'user'
|
||||
id: number
|
||||
username: string
|
||||
display_name: string
|
||||
email: string
|
||||
role: RoleRef | null
|
||||
role_assigned_by: AssignedBy | null
|
||||
status: 'accepted'
|
||||
created_at: string
|
||||
}
|
||||
| {
|
||||
kind: 'invite'
|
||||
id: number
|
||||
email: string
|
||||
role?: RoleRef | null
|
||||
status: 'pending' | 'expired'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreatedInvite {
|
||||
id: number
|
||||
kind: 'invite'
|
||||
email: string
|
||||
role?: RoleRef | null
|
||||
status: 'pending'
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export type AcceptedUser = Extract<UserListItem, { kind: 'user' }>
|
||||
|
||||
export interface OwnershipTransfer {
|
||||
owner: { id: number; name: string }
|
||||
former_owner: { id: number; name: string; role: RoleRef }
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export function inviteWindow(invite: Pick<CreatedInvite, 'created_at' | 'expires_at'>) {
|
||||
const span = Date.parse(invite.expires_at) - Date.parse(invite.created_at)
|
||||
const days = Math.max(1, Math.round(span / DAY_MS))
|
||||
return days === 1 ? t('1 day') : t('{0} days', [days])
|
||||
}
|
||||
@@ -84,3 +84,54 @@ describe('session shape', () => {
|
||||
expect('canManageContent' in session).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roleAssignedBy', () => {
|
||||
it('reads the assigner of the session and falls back to null', () => {
|
||||
const session = useSessionStore()
|
||||
const store = usePermissionsStore()
|
||||
session.session = {
|
||||
id: 7,
|
||||
verified: true,
|
||||
displayName: '',
|
||||
access: {
|
||||
...customAccess('content.read'),
|
||||
roleAssignedBy: { id: 3, name: 'Marta Alves' },
|
||||
},
|
||||
}
|
||||
|
||||
expect(store.roleAssignedBy).toEqual({ id: 3, name: 'Marta Alves' })
|
||||
|
||||
session.session = {
|
||||
id: 7,
|
||||
verified: true,
|
||||
displayName: '',
|
||||
access: customAccess('content.read'),
|
||||
}
|
||||
expect(store.roleAssignedBy).toBeNull()
|
||||
|
||||
session.session = null
|
||||
expect(store.roleAssignedBy).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('canAssign(role)', () => {
|
||||
const role = (spec: string) => ({ grants: customAccess(spec).permissions })
|
||||
|
||||
it.each<[string, string, boolean]>([
|
||||
['content.update=all', 'content.update=all', true],
|
||||
['content.update=all', 'content.update=own', true],
|
||||
['content.update=own', 'content.update=own', true],
|
||||
['content.update=own', 'content.update=all', false],
|
||||
['content.read', 'content.read, content.update', false],
|
||||
['content.read', 'content.read', true],
|
||||
])('with %s, a role granting %s is assignable: %s', (held, granted, expected) => {
|
||||
expect(withGrants(held).canAssign(role(granted))).toBe(expected)
|
||||
})
|
||||
|
||||
it('assigns nothing without a session unless the role grants nothing', () => {
|
||||
const store = withGrants(null)
|
||||
|
||||
expect(store.canAssign(role('content.read'))).toBe(false)
|
||||
expect(store.canAssign({ grants: [] })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -5,16 +5,22 @@ import {
|
||||
allows,
|
||||
effectiveScope,
|
||||
type CanOptions,
|
||||
type AssignedBy,
|
||||
type RoleRef,
|
||||
type Scope,
|
||||
type ScopeOptions,
|
||||
} from '@/lib/permissions'
|
||||
import { canCover } from '@/lib/grantLocks'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
export const usePermissionsStore = defineStore('permissions', () => {
|
||||
const sessionStore = useSessionStore()
|
||||
|
||||
const grants = computed(() => sessionStore.session?.access?.permissions ?? [])
|
||||
const role = computed<RoleRef | undefined>(() => sessionStore.session?.access?.role)
|
||||
const roleAssignedBy = computed<AssignedBy | null>(
|
||||
() => sessionStore.session?.access?.roleAssignedBy ?? null,
|
||||
)
|
||||
const roleName = computed(() => role.value?.name ?? '')
|
||||
|
||||
function can(key: string, options: CanOptions = {}): boolean {
|
||||
@@ -31,5 +37,9 @@ export const usePermissionsStore = defineStore('permissions', () => {
|
||||
return effectiveScope(grants.value, key, options)
|
||||
}
|
||||
|
||||
return { grants, role, roleName, can, scopeOf }
|
||||
function canAssign(target: Pick<RoleItem, 'grants'>): boolean {
|
||||
return canCover(target, scopeOf)
|
||||
}
|
||||
|
||||
return { grants, role, roleAssignedBy, roleName, can, scopeOf, canAssign }
|
||||
})
|
||||
@@ -50,7 +50,7 @@ const anyDialogOpen = computed(
|
||||
)
|
||||
const description = computed(() =>
|
||||
seesEveryKey.value
|
||||
? t("Keys act as the user who created them. As an owner you see everyone's keys.")
|
||||
? t("Keys act as the user who created them. You see everyone's keys.")
|
||||
: t('Keys act as you, with your role. You only see your own keys.'),
|
||||
)
|
||||
const sessionName = computed(
|
||||
@@ -308,6 +308,20 @@ onMounted(loadKeys)
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UAlert
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-info"
|
||||
:close="false"
|
||||
:title="t('Keys follow their creator\'s role')"
|
||||
:description="
|
||||
t(
|
||||
'A key can do what its creator\'s role can do, today. If the person\'s role changes, their keys change with it.',
|
||||
)
|
||||
"
|
||||
:ui="{ root: 'items-start gap-2.5 px-3.5 py-2.5', description: 'text-toned' }"
|
||||
/>
|
||||
|
||||
<UEmpty
|
||||
v-if="isEmpty"
|
||||
variant="naked"
|
||||
@@ -332,6 +346,8 @@ onMounted(loadKeys)
|
||||
:keys="keys"
|
||||
:loading="loadState === 'loading'"
|
||||
:show-created-by="seesEveryKey"
|
||||
:caller-id="session.session?.id"
|
||||
:caller-role="permissions.role"
|
||||
@revoke="confirmRevoke"
|
||||
/>
|
||||
<ApiKeyCardList
|
||||
@@ -339,6 +355,8 @@ onMounted(loadKeys)
|
||||
:keys="keys"
|
||||
:loading="loadState === 'loading'"
|
||||
:show-created-by="seesEveryKey"
|
||||
:caller-id="session.session?.id"
|
||||
:caller-role="permissions.role"
|
||||
@revoke="confirmRevoke"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -9,8 +9,10 @@ import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ProfileCard from '@/components/ProfileCard.vue'
|
||||
import YourRoleCard from '@/components/YourRoleCard.vue'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useSessionStore, type Session } from '@/stores/session'
|
||||
|
||||
const DISPLAY_NAME_MAX = 60
|
||||
@@ -24,12 +26,14 @@ const SKELETON_CARDS = [
|
||||
{ titleWidth: 'w-30', inputs: 1 },
|
||||
{ titleWidth: 'w-22.5', inputs: 2 },
|
||||
{ titleWidth: 'w-28', inputs: 1 },
|
||||
{ titleWidth: 'w-20', inputs: 1 },
|
||||
{ titleWidth: 'w-25', inputs: 1 },
|
||||
]
|
||||
|
||||
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
|
||||
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const { request } = useApi()
|
||||
@@ -399,6 +403,8 @@ onBeforeRouteLeave(async () => {
|
||||
</div>
|
||||
</ProfileCard>
|
||||
|
||||
<YourRoleCard v-if="permissions.role" />
|
||||
|
||||
<AuthorProfileCard />
|
||||
|
||||
<ProfileCard
|
||||
|
||||
+87
-151
@@ -3,14 +3,16 @@ import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ChangeRoleDialog from '@/components/ChangeRoleDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import InviteUserModal from '@/components/InviteUserModal.vue'
|
||||
import UserCardList from '@/components/UserCardList.vue'
|
||||
import TransferOwnershipDialog from '@/components/TransferOwnershipDialog.vue'
|
||||
import UsersTable from '@/components/UsersTable.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useUsers, userRowKey, type CreatedInvite, type UserListItem } from '@/composables/useUsers'
|
||||
import { useInviteRows } from '@/composables/useInviteRows'
|
||||
import { useUsers } from '@/composables/useUsers'
|
||||
import type { AcceptedUser, UserListItem } from '@/lib/users'
|
||||
import type { UserMenuContext } from '@/lib/userMenu'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
@@ -19,14 +21,9 @@ const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
|
||||
type LoadState = 'loading' | 'ready' | 'error'
|
||||
|
||||
const AUTH_HANDLED_STATUSES = [401, 403]
|
||||
const CANCEL_FAILED_COPY = "Couldn't cancel the invite. Try again in a moment."
|
||||
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const { listUsers, cancelInvite, renewInvite } = useUsers()
|
||||
const { listUsers } = useUsers()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
|
||||
const loadState = ref<LoadState>('loading')
|
||||
@@ -34,12 +31,30 @@ const items = ref<UserListItem[]>([])
|
||||
const showSkeleton = ref(false)
|
||||
const inviteModalOpen = ref(false)
|
||||
const highlightedKey = ref('')
|
||||
const busyKeys = ref<string[]>([])
|
||||
const { busyKeys, onInvited, confirmCancel, renew } = useInviteRows(items, highlightedKey)
|
||||
const changeTarget = ref<AcceptedUser | null>(null)
|
||||
const changeOpen = ref(false)
|
||||
const transferOpen = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let opener: HTMLElement | null = null
|
||||
|
||||
const sessionUserId = computed(() => session.session?.id)
|
||||
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) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
sessionUserId: sessionUserId.value,
|
||||
canChangeRole,
|
||||
canTransfer,
|
||||
canViewRoles: permissions.can('roles.view'),
|
||||
changeRole: openChangeRole,
|
||||
transfer: openTransfer,
|
||||
}
|
||||
})
|
||||
const showList = computed(
|
||||
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
|
||||
)
|
||||
@@ -74,6 +89,47 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadQuietly() {
|
||||
try {
|
||||
items.value = await listUsers()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function rememberOpener(trigger: HTMLElement | null) {
|
||||
opener =
|
||||
trigger ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
||||
}
|
||||
|
||||
function openChangeRole(user: AcceptedUser, trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
changeTarget.value = user
|
||||
changeOpen.value = true
|
||||
}
|
||||
|
||||
function openTransfer(trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
transferOpen.value = true
|
||||
}
|
||||
|
||||
function onOverlayOpenChange(target: 'change' | 'transfer', isOpen: boolean) {
|
||||
if (target === 'change') {
|
||||
changeOpen.value = isOpen
|
||||
} else {
|
||||
transferOpen.value = isOpen
|
||||
}
|
||||
if (!isOpen) {
|
||||
restoreFocus()
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleChanged(updated: AcceptedUser) {
|
||||
items.value = items.value.map((item) =>
|
||||
item.kind === 'user' && item.id === updated.id ? updated : item,
|
||||
)
|
||||
}
|
||||
|
||||
function openInviteModal() {
|
||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
inviteModalOpen.value = true
|
||||
@@ -92,146 +148,6 @@ function onInviteModalOpenChange(isOpen: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function onInvited(invite: CreatedInvite) {
|
||||
const sameAddress = invite.email.toLowerCase()
|
||||
const newRow: UserListItem = {
|
||||
kind: 'invite',
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
display_name: '',
|
||||
role: invite.role ?? null,
|
||||
status: 'pending',
|
||||
created_at: invite.created_at,
|
||||
}
|
||||
items.value = [
|
||||
newRow,
|
||||
...items.value.filter(
|
||||
(item) => !(item.kind === 'invite' && item.email.toLowerCase() === sameAddress),
|
||||
),
|
||||
]
|
||||
highlightedKey.value = userRowKey('invite', invite.id)
|
||||
toast.add(() => ({
|
||||
id: `invite-sent-${invite.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Invite sent')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} has 3 days to accept.', [invite.email])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function isHandledByApiClient(err: unknown) {
|
||||
return err instanceof ApiError && AUTH_HANDLED_STATUSES.includes(err.status)
|
||||
}
|
||||
|
||||
function removeRow(key: string) {
|
||||
items.value = items.value.filter((item) => userRowKey(item.kind, item.id) !== key)
|
||||
}
|
||||
|
||||
function confirmCancel(invite: UserListItem) {
|
||||
const key = userRowKey(invite.kind, invite.id)
|
||||
overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Cancel this invite?')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'The link sent to {0} will stop working. You can invite the address again later.',
|
||||
[invite.email],
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Cancel invite')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Keep invite')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Cancelling…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: async () => {
|
||||
try {
|
||||
await cancelInvite(invite.id)
|
||||
} catch (err) {
|
||||
if (isHandledByApiClient(err)) {
|
||||
return
|
||||
}
|
||||
throw new Error(t(CANCEL_FAILED_COPY))
|
||||
}
|
||||
removeRow(key)
|
||||
toast.add(() => ({
|
||||
id: `invite-cancelled-${invite.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Invite cancelled')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} can no longer use the link.', [invite.email])
|
||||
},
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
.open()
|
||||
}
|
||||
|
||||
async function renew(invite: UserListItem) {
|
||||
const key = userRowKey(invite.kind, invite.id)
|
||||
if (busyKeys.value.includes(key)) {
|
||||
return
|
||||
}
|
||||
busyKeys.value = [...busyKeys.value, key]
|
||||
try {
|
||||
const renewed = await renewInvite(invite.id)
|
||||
items.value = [
|
||||
{
|
||||
kind: 'invite',
|
||||
id: renewed.id,
|
||||
email: renewed.email,
|
||||
display_name: invite.display_name,
|
||||
status: 'pending',
|
||||
created_at: renewed.created_at,
|
||||
},
|
||||
...items.value.filter((item) => userRowKey(item.kind, item.id) !== key),
|
||||
]
|
||||
highlightedKey.value = userRowKey('invite', renewed.id)
|
||||
toast.add(() => ({
|
||||
id: `invite-renewed-${renewed.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Invite renewed')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} has 3 days to accept the new link.', [renewed.email])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (!isHandledByApiClient(err)) {
|
||||
toast.add(() => ({
|
||||
id: `invite-renew-failed-${invite.id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return t("Couldn't renew the invite")
|
||||
},
|
||||
get description() {
|
||||
return t('Try again in a moment.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
busyKeys.value = busyKeys.value.filter((busyKey) => busyKey !== key)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
onBeforeUnmount(clearSkeletonTimer)
|
||||
</script>
|
||||
@@ -293,6 +209,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
:highlighted-key="highlightedKey"
|
||||
:can-manage="canInvite"
|
||||
:busy-keys="busyKeys"
|
||||
:menu="menu"
|
||||
@cancel="confirmCancel"
|
||||
@renew="renew"
|
||||
/>
|
||||
@@ -304,6 +221,7 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
:highlighted-key="highlightedKey"
|
||||
:can-manage="canInvite"
|
||||
:busy-keys="busyKeys"
|
||||
:menu="menu"
|
||||
@cancel="confirmCancel"
|
||||
@renew="renew"
|
||||
/>
|
||||
@@ -334,5 +252,23 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
@update:open="onInviteModalOpenChange"
|
||||
@invited="onInvited"
|
||||
/>
|
||||
|
||||
<ChangeRoleDialog
|
||||
v-if="changeTarget && changeOpen"
|
||||
:open="changeOpen"
|
||||
:user="changeTarget"
|
||||
@update:open="onOverlayOpenChange('change', $event)"
|
||||
@changed="onRoleChanged"
|
||||
@stale="reloadQuietly"
|
||||
/>
|
||||
|
||||
<TransferOwnershipDialog
|
||||
v-if="transferOpen"
|
||||
:open="transferOpen"
|
||||
:users="items"
|
||||
@update:open="onOverlayOpenChange('transfer', $event)"
|
||||
@transferred="reloadQuietly"
|
||||
@stale="reloadQuietly"
|
||||
/>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -220,12 +220,14 @@ describe('Details: content', () => {
|
||||
],
|
||||
}),
|
||||
)
|
||||
await vi.waitFor(() => expect(findButton('Show more')).toBeTruthy())
|
||||
await vi.waitFor(() =>
|
||||
expect(findButton('Show more')).toBeInstanceOf(HTMLButtonElement),
|
||||
)
|
||||
|
||||
const changes = Array.from(
|
||||
details()!.querySelectorAll('[data-testid="activity-change"]'),
|
||||
)
|
||||
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
|
||||
expect(changes[0]!.querySelector('.line-clamp-2')).not.toBeNull()
|
||||
expect(changes[1]!.querySelector('button')).toBeNull()
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll('button')).filter(
|
||||
@@ -235,9 +237,9 @@ describe('Details: content', () => {
|
||||
|
||||
await click(findButton('Show more'))
|
||||
expect(changes[0]!.querySelector('.line-clamp-2')).toBeNull()
|
||||
expect(findButton('Show less')).toBeTruthy()
|
||||
expect(findButton('Show less')).toBeInstanceOf(HTMLButtonElement)
|
||||
await click(findButton('Show less'))
|
||||
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
|
||||
expect(changes[0]!.querySelector('.line-clamp-2')).not.toBeNull()
|
||||
} finally {
|
||||
if (scrollHeight)
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', scrollHeight)
|
||||
@@ -516,3 +518,134 @@ describe('Details: counts', () => {
|
||||
expect(textOf(panel())).toContain('Event 1 of 41')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Details: role events', () => {
|
||||
const role = target('role', '4', 'Contributor')
|
||||
const roleEvent = (overrides: Partial<ActivityItem>) =>
|
||||
makeItem({ id: 90, category: 'roles', target: role, ...overrides })
|
||||
|
||||
it('lists Added and Removed with human permission names and the scope suffix', async () => {
|
||||
await openWith(
|
||||
roleEvent({
|
||||
action: 'role.edit',
|
||||
params: {
|
||||
added: [{ key: 'content.delete', scope: 'own' }],
|
||||
removed: [{ key: 'content.publish', scope: 'all' }],
|
||||
changed: [],
|
||||
members: 4,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const text = textOf(details())
|
||||
expect(text).toContain('What changed')
|
||||
expect(text).toContain('Added')
|
||||
expect(text).toContain('Delete entries (Own)')
|
||||
expect(text).toContain('Removed')
|
||||
expect(text).toContain('Publish entries')
|
||||
expect(text).not.toContain('content.delete')
|
||||
expect(text).not.toContain('members have this role')
|
||||
})
|
||||
|
||||
it('shows a changed entry with the human name and the scopes', async () => {
|
||||
await openWith(
|
||||
roleEvent({
|
||||
action: 'role.edit',
|
||||
params: {
|
||||
added: [],
|
||||
removed: [],
|
||||
changed: [{ key: 'content.update', from: 'own', to: 'all' }],
|
||||
members: 0,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const text = textOf(details())
|
||||
expect(text).toContain('Changed')
|
||||
expect(text).toContain('Update entries')
|
||||
expect(text).toContain('Own')
|
||||
expect(text).toContain('All')
|
||||
})
|
||||
|
||||
it('renders name and description changes through the existing text diff rows', async () => {
|
||||
await openWith(
|
||||
roleEvent({
|
||||
action: 'role.edit',
|
||||
params: { added: [], removed: [], changed: [], members: 0 },
|
||||
changes: [
|
||||
{ field: 'name', kind: 'text', before: 'Writer plus', after: 'Contributor' },
|
||||
{ field: 'description', kind: 'text', before: 'Old', after: 'New' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const changes = details()!.querySelectorAll('[data-testid="activity-change"]')
|
||||
expect(changes).toHaveLength(2)
|
||||
expect(textOf(changes[0])).toContain('Name')
|
||||
expect(textOf(changes[1])).toContain('Description')
|
||||
expect(document.body.querySelector('[data-testid="activity-role-section"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the members line only on role.delete', async () => {
|
||||
await openWith(
|
||||
roleEvent({
|
||||
action: 'role.delete',
|
||||
target: target('role', '6', 'Intern', false),
|
||||
params: { members: 4 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(textOf(details())).toContain('4 members have this role.')
|
||||
})
|
||||
|
||||
it('shows the Role field with before and after on user.change_role', async () => {
|
||||
await openWith(
|
||||
makeItem({
|
||||
id: 91,
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
target: target('user', '9', 'Ines Duarte'),
|
||||
changes: [{ field: 'role', kind: 'text', before: 'Writer', after: 'Content lead' }],
|
||||
}),
|
||||
)
|
||||
|
||||
const change = details()!.querySelector('[data-testid="activity-change"]')
|
||||
expect(textOf(change)).toContain('Role')
|
||||
expect(textOf(change)).toContain('Writer')
|
||||
expect(textOf(change)).toContain('Content lead')
|
||||
})
|
||||
|
||||
it('shows the new owner as the target, the security badge and no diff on user.transfer_ownership', async () => {
|
||||
await openWith(
|
||||
makeItem({
|
||||
id: 92,
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
security: true,
|
||||
target: target('user', '2', 'Jonas Weber'),
|
||||
}),
|
||||
)
|
||||
|
||||
const text = textOf(details())
|
||||
expect(text).toContain('Ownership transferred from Marta Alves to Jonas Weber')
|
||||
expect(text).toContain('Security')
|
||||
expect(
|
||||
textOf(details()!.querySelector('[data-testid="activity-target-section"]')),
|
||||
).toContain('Jonas Weber')
|
||||
expect(details()!.querySelector('[data-testid="activity-diff"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders an unknown action as before', async () => {
|
||||
await openWith(makeItem({ id: 93, action: 'role.mystery' }))
|
||||
|
||||
expect(textOf(details())).toContain('role.mystery')
|
||||
})
|
||||
|
||||
it('shows the failed badge on a failed role event', async () => {
|
||||
await openWith(
|
||||
roleEvent({ action: 'role.create', outcome: 'failed', failure_reason: 'duplicate' }),
|
||||
)
|
||||
|
||||
expect(textOf(details())).toContain('Failed')
|
||||
})
|
||||
})
|
||||
@@ -425,7 +425,7 @@ describe('Owner: list', () => {
|
||||
)
|
||||
expect(document.body.querySelector('h1')?.textContent).toBe('API keys')
|
||||
expect(document.body.textContent).toContain(
|
||||
"Keys act as the user who created them. As an owner you see everyone's keys.",
|
||||
"Keys act as the user who created them. You see everyone's keys.",
|
||||
)
|
||||
expect(findButton('Create key')).toBeTruthy()
|
||||
expect(document.body.textContent).toContain(
|
||||
@@ -433,12 +433,14 @@ describe('Owner: list', () => {
|
||||
)
|
||||
|
||||
const header = document.body.querySelector('thead tr')!
|
||||
expect(textOf(header)).toBe('Name Key Status Created by Created Last used Expires')
|
||||
expect(textOf(header)).toBe(
|
||||
'Name Key Status Created by Follows role Created Last used Expires',
|
||||
)
|
||||
expect(rowTexts()).toEqual([
|
||||
'Website build rcms_Kf3x… active Marta Alves 12 Mar 2026 3 Oct 2026 None Revoke',
|
||||
'Nightly media import rcms_Mq7b… active Jonas 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
|
||||
'Staging preview rcms_Zt2d… expired Marta Alves 14 Jun 2026 20 Aug 2026 30 Aug 2026 Revoke',
|
||||
'Old migration script rcms_Hn5w… revoked Inès Duarte 3 Apr 2026 9 May 2026 None',
|
||||
'Website build rcms_Kf3x… active Marta Alves owner 12 Mar 2026 3 Oct 2026 None Revoke',
|
||||
'Nightly media import rcms_Mq7b… active Jonas — 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
|
||||
'Staging preview rcms_Zt2d… expired Marta Alves owner 14 Jun 2026 20 Aug 2026 30 Aug 2026 Revoke',
|
||||
'Old migration script rcms_Hn5w… revoked Inès Duarte — 3 Apr 2026 9 May 2026 None',
|
||||
])
|
||||
expect(document.body.textContent).not.toContain('rcms_Kf3x9')
|
||||
})
|
||||
@@ -458,17 +460,17 @@ describe('Owner: list', () => {
|
||||
fixture[0]!.created_by_name = ''
|
||||
await mountPage({ list: () => Promise.resolve(fixture) })
|
||||
|
||||
expect(rowTexts()[0]).toContain('rcms_Kf3x… active — 12 Mar 2026')
|
||||
expect(rowTexts()[0]).toContain('rcms_Kf3x… active — owner 12 Mar 2026')
|
||||
})
|
||||
|
||||
it('renders the same keys as cards below md', async () => {
|
||||
await mountPage({}, { desktop: false })
|
||||
|
||||
expect(rowTexts()).toEqual([
|
||||
'Website build active rcms_Kf3x… Created 12 Mar 2026 by Marta Alves · Last used 3 Oct 2026 · Expires None Revoke',
|
||||
'Nightly media import active rcms_Mq7b… Created 2 Apr 2026 by Jonas · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
|
||||
'Staging preview expired rcms_Zt2d… Created 14 Jun 2026 by Marta Alves · Last used 20 Aug 2026 · Expires 30 Aug 2026 Revoke',
|
||||
'Old migration script revoked rcms_Hn5w… Created 3 Apr 2026 by Inès Duarte · Last used 9 May 2026 · Expires None',
|
||||
'Website build active Follows role owner rcms_Kf3x… Created 12 Mar 2026 by Marta Alves · Last used 3 Oct 2026 · Expires None Revoke',
|
||||
'Nightly media import active Follows role — rcms_Mq7b… Created 2 Apr 2026 by Jonas · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
|
||||
'Staging preview expired Follows role owner rcms_Zt2d… Created 14 Jun 2026 by Marta Alves · Last used 20 Aug 2026 · Expires 30 Aug 2026 Revoke',
|
||||
'Old migration script revoked Follows role — rcms_Hn5w… Created 3 Apr 2026 by Inès Duarte · Last used 9 May 2026 · Expires None',
|
||||
])
|
||||
expect(document.body.querySelector('table')).toBeNull()
|
||||
expect(document.body.querySelector('thead')).toBeNull()
|
||||
@@ -504,11 +506,11 @@ describe('Own keys only', () => {
|
||||
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { access: ownAccess() })
|
||||
|
||||
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
|
||||
'Name Key Status Created Last used Expires',
|
||||
'Name Key Status Follows role Created Last used Expires',
|
||||
)
|
||||
expect(rowTexts()).toEqual([
|
||||
'Nightly media import rcms_Mq7b… active 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
|
||||
'Old CI token rcms_Lp9c… revoked 18 Jan 2026 2 Feb 2026 None',
|
||||
'Nightly media import rcms_Mq7b… active Key holder 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
|
||||
'Old CI token rcms_Lp9c… revoked Key holder 18 Jan 2026 2 Feb 2026 None',
|
||||
])
|
||||
expect(document.body.textContent).toContain(
|
||||
'Keys act as you, with your role. You only see your own keys.',
|
||||
@@ -523,7 +525,7 @@ describe('Own keys only', () => {
|
||||
)
|
||||
|
||||
expect(rowTexts()[0]).toBe(
|
||||
'Nightly media import active rcms_Mq7b… Created 2 Apr 2026 · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
|
||||
'Nightly media import active Follows role Key holder rcms_Mq7b… Created 2 Apr 2026 · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1177,7 +1179,7 @@ describe('Language: every string has a French entry', () => {
|
||||
expect(frenchText()).toContain('Clés d’API')
|
||||
expect(frenchText()).toContain('Les clés agissent comme l’utilisateur qui les a créées.')
|
||||
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
|
||||
'Nom Clé Statut Créée par Créé Dernière utilisation Expire le',
|
||||
'Nom Clé Statut Créée par Suit le rôle Créé Dernière utilisation Expire le',
|
||||
)
|
||||
expect(rowTexts()[0]).toContain('active')
|
||||
expect(rowTexts()[2]).toContain('expirée')
|
||||
@@ -1224,3 +1226,54 @@ describe('Language: every string has a French entry', () => {
|
||||
expect(toast.description).toBe('« Website build » ne peut plus être utilisée.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keys follow the role', () => {
|
||||
const ALERT_TITLE = "Keys follow their creator's role"
|
||||
const ALERT_BODY =
|
||||
"A key can do what its creator's role can do, today. If the person's role changes, their keys change with it."
|
||||
|
||||
it('shows the info alert with the design copy', async () => {
|
||||
await mountPage()
|
||||
|
||||
const title = [...document.body.querySelectorAll('[data-slot="title"]')].find(
|
||||
(candidate) => candidate.textContent === ALERT_TITLE,
|
||||
)
|
||||
expect(title?.closest('[data-slot="root"]')?.textContent).toContain(ALERT_BODY)
|
||||
})
|
||||
|
||||
it('shows the caller badge on the caller keys and a dash on other creators keys', async () => {
|
||||
await mountPage()
|
||||
|
||||
expect(rowTexts()[0]).toContain('owner')
|
||||
expect(rowTexts()[1]).toContain('Jonas — 2 Apr')
|
||||
})
|
||||
|
||||
it('shows the caller badge on every row when only own keys are listed', async () => {
|
||||
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { access: ownAccess() })
|
||||
|
||||
rowTexts().forEach((text) => expect(text).toContain('Key holder'))
|
||||
})
|
||||
|
||||
it('labels the badge Follows role on each card below md', async () => {
|
||||
await mountPage({}, { desktop: false })
|
||||
|
||||
rowTexts().forEach((text) => expect(text).toContain('Follows role'))
|
||||
})
|
||||
|
||||
it('follows a refreshed role without reading the keys again', async () => {
|
||||
await mountPage()
|
||||
const readsBefore = requestMock.mock.calls.length
|
||||
|
||||
useSessionStore().session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
access: customAccess('apikeys.view=all, apikeys.create', 'Contributor'),
|
||||
}
|
||||
await flushPromises()
|
||||
|
||||
expect(rowTexts()[0]).toContain('Contributor')
|
||||
expect(requestMock.mock.calls.length).toBe(readsBefore)
|
||||
})
|
||||
})
|
||||
@@ -71,7 +71,13 @@ describe('Profile: the Author profile card sits between Account and Password', (
|
||||
it.each(['owner', 'admin', 'verified'] as const)('for a %s', async (role) => {
|
||||
await mountMeta('profile', PROFILE, { backend: backendWith(role) })
|
||||
|
||||
expect(cardTitles()).toEqual(['Display name', 'Account', 'Author profile', 'Password'])
|
||||
expect(cardTitles()).toEqual([
|
||||
'Display name',
|
||||
'Account',
|
||||
'Your role',
|
||||
'Author profile',
|
||||
'Password',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import router from '@/router'
|
||||
import App from '@/App.vue'
|
||||
import { useSessionStore, type Session } from '@/stores/session'
|
||||
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { accessFor } from '@/test/access'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { accessFor, customAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -85,13 +85,20 @@ interface MountOptions {
|
||||
getMe?: () => Promise<Session>
|
||||
handlers?: Record<string, Handler>
|
||||
path?: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
let mountedWrapper: VueWrapper | null = null
|
||||
|
||||
async function mountApp(options: MountOptions = {}): Promise<Mounted> {
|
||||
const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
const {
|
||||
session = MARTA,
|
||||
status = 'ready',
|
||||
handlers = {},
|
||||
path = '/profile',
|
||||
width = DESKTOP_WIDTH,
|
||||
} = options
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
const store = useSessionStore()
|
||||
store.session = session ? { ...session } : null
|
||||
@@ -304,18 +311,24 @@ describe('Default: the usual first visit with no display name', () => {
|
||||
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
||||
})
|
||||
|
||||
it('places the four cards in order Display name, Account, Author profile, Password', async () => {
|
||||
it('places the cards in order Display name, Account, Your role, Author profile, Password', async () => {
|
||||
await mountApp()
|
||||
|
||||
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
|
||||
el.textContent?.trim(),
|
||||
)
|
||||
expect(titles).toEqual(['Display name', 'Account', 'Author profile', 'Password'])
|
||||
expect(titles).toEqual([
|
||||
'Display name',
|
||||
'Account',
|
||||
'Your role',
|
||||
'Author profile',
|
||||
'Password',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading: opened by URL before /auth/me has returned', () => {
|
||||
it('shows the header and four skeleton cards without AppBoot, then the Default state', async () => {
|
||||
it('shows the header and five skeleton cards without AppBoot, then the Default state', async () => {
|
||||
const pending = deferred<Session>()
|
||||
await mountApp({
|
||||
session: null,
|
||||
@@ -328,7 +341,8 @@ describe('Loading: opened by URL before /auth/me has returned', () => {
|
||||
)
|
||||
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
|
||||
const busy = document.body.querySelector('[aria-busy="true"]')!
|
||||
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(4)
|
||||
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(5)
|
||||
expect(document.body.textContent).not.toContain('Your role')
|
||||
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
|
||||
expect(displayNameField()).toBeNull()
|
||||
|
||||
@@ -905,3 +919,105 @@ describe('Reset: failed leaves the button pressable', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const MARTA_ASSIGNER = { id: 3, name: 'Marta Alves' }
|
||||
const KEYS_SENTENCE = 'Your API keys follow this role. If your role changes, they change too.'
|
||||
|
||||
function contributor(assigner?: { id: number; name: string } | null): Session {
|
||||
const access = customAccess(
|
||||
'content.read=all, content.create, content.update=own',
|
||||
'Contributor',
|
||||
)
|
||||
return {
|
||||
...MARTA,
|
||||
access: assigner === undefined ? access : { ...access, roleAssignedBy: assigner },
|
||||
}
|
||||
}
|
||||
|
||||
function pageText() {
|
||||
return document.body.textContent ?? ''
|
||||
}
|
||||
|
||||
function roleRequests(request: ReturnType<typeof vi.fn>) {
|
||||
return request.mock.calls.filter(([path]) => String(path).startsWith('/v1/role'))
|
||||
}
|
||||
|
||||
describe('Profile: your role', () => {
|
||||
it('shows the role card with the badge, the assigner and the keys sentence', 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()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['an absent key', undefined],
|
||||
])('renders no Assigned by text for %s', async (_label, assigner) => {
|
||||
await mountApp({ session: contributor(assigner) })
|
||||
|
||||
expect(pageText()).toContain('Your role')
|
||||
expect(pageText()).not.toContain('Assigned by')
|
||||
})
|
||||
|
||||
it('places the card after the Account card', async () => {
|
||||
await mountApp({ session: contributor(MARTA_ASSIGNER) })
|
||||
|
||||
const titles = [...document.body.querySelectorAll('h2')].map((h2) => h2.textContent?.trim())
|
||||
expect(titles.indexOf('Your role')).toBe(titles.indexOf('Account') + 1)
|
||||
})
|
||||
|
||||
it('follows a refreshed session for the assigner and the role', async () => {
|
||||
await mountApp({ session: contributor(MARTA_ASSIGNER) })
|
||||
|
||||
useSessionStore().session = {
|
||||
...contributor({ id: 2, name: 'Jonas Weber' }),
|
||||
}
|
||||
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')
|
||||
})
|
||||
|
||||
it('opens the summary dialog from the session grants without reading roles, and Close returns focus', async () => {
|
||||
const { request } = await mountApp({ session: contributor(MARTA_ASSIGNER) })
|
||||
const opener = buttonByText(/^See what I can do$/)!
|
||||
opener.focus()
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
|
||||
const dialog = document.body.querySelector('[role="dialog"]')!
|
||||
expect(dialog.textContent).toContain('What you can do')
|
||||
expect(dialog.textContent).toContain('A summary of your role, Contributor. Read-only.')
|
||||
expect(dialog.textContent).not.toContain('Updates as you edit.')
|
||||
expect(dialog.textContent).toContain('Content')
|
||||
expect(roleRequests(request)).toHaveLength(0)
|
||||
|
||||
const close = [...dialog.querySelectorAll('button')].find(
|
||||
(button) => button.textContent?.trim() === 'Close',
|
||||
)!
|
||||
close.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('opens the summary as a bottom sheet on mobile', async () => {
|
||||
await mountApp({ session: contributor(MARTA_ASSIGNER), width: MOBILE_WIDTH })
|
||||
buttonByText(/^See what I can do$/)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
|
||||
'What you can do',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,507 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { UserListItem } from '@/composables/useUsers'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { accessFor, adminAccess, customAccess, ownerAccess, roleRefFor } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import { CONTRIBUTOR, WORLD } from '@/components/__tests__/userRolesKit'
|
||||
import UsersView from '../UsersView.vue'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
})
|
||||
|
||||
const LIST_PATH = '/v1/user/'
|
||||
const ROLE_PATH = '/v1/role/'
|
||||
const JONAS_EMAIL = 'jonas@renewbuild.example'
|
||||
const OWNER_HINT = "The owner can't be changed or removed. Transfer ownership first."
|
||||
const OWN_ROW_HINT = "You can't change your own role. Ask another owner or admin."
|
||||
|
||||
function accepted(
|
||||
id: number,
|
||||
username: string,
|
||||
name: string,
|
||||
email: string,
|
||||
role: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
): UserListItem {
|
||||
return {
|
||||
kind: 'user',
|
||||
id,
|
||||
username,
|
||||
display_name: name,
|
||||
email,
|
||||
role: roleRefFor(role),
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
...extra,
|
||||
} as UserListItem
|
||||
}
|
||||
|
||||
function fixture(): UserListItem[] {
|
||||
return [
|
||||
{
|
||||
kind: 'invite',
|
||||
id: 2,
|
||||
email: 'sam.oliveira@renew.example',
|
||||
role: { id: 4, name: 'Contributor', built_in: false, locked: false },
|
||||
status: 'expired',
|
||||
created_at: '2026-08-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'invite',
|
||||
id: 3,
|
||||
email: 'pending@renew.example',
|
||||
status: 'pending',
|
||||
created_at: '2026-09-27T09:00:00Z',
|
||||
},
|
||||
accepted(4, 'ines', 'Ines Duarte', 'ines.duarte@renew.example', 'member', {
|
||||
role_assigned_by: { id: 1, name: 'Marta Alves' },
|
||||
}),
|
||||
accepted(3, 'lea', 'Lea Martin', 'lea@renewbuild.example', 'admin'),
|
||||
accepted(2, 'jonas', 'Jonas Weber', JONAS_EMAIL, 'admin'),
|
||||
accepted(1, 'marta', 'Marta Alves', 'marta@renewbuild.example', 'owner'),
|
||||
]
|
||||
}
|
||||
|
||||
function without(access: Access, key: string): Access {
|
||||
return { ...access, permissions: access.permissions.filter((grant) => grant.key !== key) }
|
||||
}
|
||||
|
||||
function setViewport(desktop: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: desktop,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
let requestMock: ReturnType<typeof vi.fn>
|
||||
let getMe: ReturnType<typeof vi.fn>
|
||||
let wrapper: VueWrapper | null = null
|
||||
let answers: Record<string, (options?: RequestInit) => Promise<unknown>> = {}
|
||||
let listItems: UserListItem[] = []
|
||||
|
||||
async function mountPage(access: Access = ownerAccess(), sessionId = 1, desktop = true) {
|
||||
setViewport(desktop)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'me', id: sessionId, displayName: '', access }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, options?: RequestInit) => {
|
||||
const key = `${options?.method ?? 'GET'} ${path}`
|
||||
if (answers[key]) {
|
||||
return answers[key]!(options)
|
||||
}
|
||||
if (key === `GET ${LIST_PATH}`) {
|
||||
return Promise.resolve(listItems)
|
||||
}
|
||||
if (key === `GET ${ROLE_PATH}`) {
|
||||
return Promise.resolve(WORLD)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${key}`)
|
||||
})
|
||||
getMe = vi.fn().mockResolvedValue({ verified: true, id: sessionId, access: adminAccess() })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe, request: requestMock } as unknown as ReturnType<
|
||||
typeof useApi
|
||||
>)
|
||||
await router.push('/users')
|
||||
wrapper = mount(defineComponent({ render: () => h(UApp, null, () => h(UsersView)) }), {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function calls(key: string) {
|
||||
return requestMock.mock.calls.filter(
|
||||
([path, options]) => `${options?.method ?? 'GET'} ${path}` === key,
|
||||
)
|
||||
}
|
||||
|
||||
function trigger(name: string) {
|
||||
return document.body.querySelector<HTMLElement>(`[aria-label="Actions for ${name}"]`)
|
||||
}
|
||||
|
||||
async function openMenu(name: string) {
|
||||
trigger(name)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function menuItems() {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
}
|
||||
|
||||
function labelOfItem(item: HTMLElement) {
|
||||
return item.querySelector('span span')?.textContent?.trim() ?? ''
|
||||
}
|
||||
|
||||
function menuItem(label: string) {
|
||||
return menuItems().find((item) => labelOfItem(item) === label)
|
||||
}
|
||||
|
||||
function menuLabels() {
|
||||
return menuItems().map(labelOfItem)
|
||||
}
|
||||
|
||||
function buttonNamed(label: string) {
|
||||
return [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
)
|
||||
}
|
||||
|
||||
function rowOf(email: string) {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[data-testid="user-row"]')].find(
|
||||
(row) => row.textContent?.includes(email),
|
||||
)!
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"], [role="dialog"]')
|
||||
}
|
||||
|
||||
async function click(target: HTMLElement | undefined) {
|
||||
target?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function radio(name: string) {
|
||||
return [...dialog()!.querySelectorAll('label')]
|
||||
.find((label) => label.querySelector('[data-slot="label"]')?.textContent?.includes(name))
|
||||
?.querySelector<HTMLButtonElement>('[role="radio"]')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
answers = {}
|
||||
listItems = fixture()
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('What the Users page offers per grant', () => {
|
||||
it.each([
|
||||
['users.view', false, false],
|
||||
['users.view, users.invite', true, false],
|
||||
['users.view, users.change_role, roles.view', false, true],
|
||||
['users.view, users.invite, users.change_role, roles.view', true, true],
|
||||
])('grants %s: invite %s, menu %s', async (grants, invite, menu) => {
|
||||
await mountPage(customAccess(grants, 'Custom'), 9)
|
||||
|
||||
expect(Boolean(buttonNamed('Invite user'))).toBe(invite)
|
||||
expect(trigger('Ines Duarte') !== null).toBe(menu)
|
||||
if (menu) {
|
||||
await openMenu('Ines Duarte')
|
||||
expect(menuLabels()).toEqual(['Change role'])
|
||||
}
|
||||
})
|
||||
|
||||
it('offers the Owner the invite button and Change role', async () => {
|
||||
await mountPage()
|
||||
|
||||
expect(buttonNamed('Invite user')).toBeInstanceOf(HTMLButtonElement)
|
||||
await openMenu('Ines Duarte')
|
||||
expect(menuLabels()).toEqual(['Change role'])
|
||||
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps invite rows to Cancel invite and Renew invite only', async () => {
|
||||
await mountPage()
|
||||
|
||||
expect(trigger('sam.oliveira@renew.example')).toBeNull()
|
||||
expect(buttonNamed('Renew invite')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(buttonNamed('Cancel invite')).toBeInstanceOf(HTMLButtonElement)
|
||||
})
|
||||
|
||||
it('shows no Assigned by text in the table or the cards', async () => {
|
||||
await mountPage()
|
||||
expect(document.body.textContent).not.toContain('Assigned by')
|
||||
wrapper?.unmount()
|
||||
await mountPage(ownerAccess(), 1, false)
|
||||
expect(document.body.textContent).not.toContain('Assigned by')
|
||||
expect(document.body.textContent).not.toContain('Marta Alves assigned')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Your own row and the owner row', () => {
|
||||
it('offers Change role disabled with the reason on the row marked you', async () => {
|
||||
await mountPage(adminAccess(), 2)
|
||||
await openMenu('Jonas Weber')
|
||||
|
||||
expect(menuLabels()).toEqual(['Change role'])
|
||||
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(menuItem('Change role')?.textContent).toContain(OWN_ROW_HINT)
|
||||
})
|
||||
|
||||
it('gives an Admin the owner row with Change role disabled and no Transfer ownership', async () => {
|
||||
await mountPage(adminAccess(), 2)
|
||||
await openMenu('Marta Alves')
|
||||
|
||||
expect(menuLabels()).toEqual(['Change role'])
|
||||
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(menuItem('Change role')?.textContent).toContain(OWNER_HINT)
|
||||
})
|
||||
|
||||
it('gives the Owner on their own row Change role disabled and Transfer ownership enabled', async () => {
|
||||
await mountPage()
|
||||
await openMenu('Marta Alves')
|
||||
|
||||
expect(menuLabels()).toEqual(['Change role', 'Transfer ownership'])
|
||||
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(menuItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('gives users.view alone no menu on the owner row', async () => {
|
||||
await mountPage(customAccess('users.view', 'Viewer'), 9)
|
||||
|
||||
expect(trigger('Marta Alves')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Transfer ownership everywhere without system.transfer_ownership', async () => {
|
||||
await mountPage(without(ownerAccess(), 'system.transfer_ownership'))
|
||||
await openMenu('Marta Alves')
|
||||
|
||||
expect(menuLabels()).toEqual(['Change role'])
|
||||
})
|
||||
})
|
||||
|
||||
function sheetItems() {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]')]
|
||||
}
|
||||
|
||||
function sheetLabels() {
|
||||
return sheetItems().map((item) => item.textContent?.trim() ?? '')
|
||||
}
|
||||
|
||||
function sheetItem(label: string) {
|
||||
return sheetItems().find((item) => item.textContent?.trim() === label)
|
||||
}
|
||||
|
||||
describe('The mobile Users menu', () => {
|
||||
it('lists Change role disabled with the reason on the owner card', async () => {
|
||||
await mountPage(adminAccess(), 2, false)
|
||||
|
||||
expect(document.body.querySelector('[data-testid="user-card"]')).not.toBeNull()
|
||||
await openMenu('Marta Alves')
|
||||
expect(sheetLabels()).toEqual(['Change role'])
|
||||
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.body.querySelector('[role="menu"]')?.textContent).toContain(OWNER_HINT)
|
||||
})
|
||||
|
||||
it('offers Transfer ownership to the Owner on their own card and opens the dialog', async () => {
|
||||
await mountPage(ownerAccess(), 1, false)
|
||||
|
||||
await openMenu('Marta Alves')
|
||||
expect(sheetLabels()).toEqual(['Change role', 'Transfer ownership'])
|
||||
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(sheetItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
|
||||
await click(sheetItem('Transfer ownership'))
|
||||
expect(dialog()?.textContent).toContain('Transfer ownership')
|
||||
})
|
||||
|
||||
it('offers a plain Change role on another member card', async () => {
|
||||
await mountPage(ownerAccess(), 1, false)
|
||||
|
||||
await openMenu('Ines Duarte')
|
||||
expect(sheetLabels()).toEqual(['Change role'])
|
||||
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
|
||||
await click(sheetItem('Change role'))
|
||||
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Changing without roles.view', () => {
|
||||
it('disables Change role with a hint and never reads the roles', async () => {
|
||||
await mountPage(customAccess('users.view, users.change_role', 'Custom'), 9)
|
||||
await openMenu('Ines Duarte')
|
||||
|
||||
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
||||
await click(menuItem('Change role'))
|
||||
expect(dialog()).toBeNull()
|
||||
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Changing the role', () => {
|
||||
it('updates only that row from the answer and does not reload the list', async () => {
|
||||
answers['PUT /v1/user/4/role'] = () =>
|
||||
Promise.resolve(
|
||||
accepted(4, 'ines', 'Ines Duarte', 'ines.duarte@renew.example', 'admin', {
|
||||
role: {
|
||||
id: CONTRIBUTOR.id,
|
||||
name: 'Contributor',
|
||||
built_in: false,
|
||||
locked: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
await mountPage()
|
||||
await openMenu('Ines Duarte')
|
||||
await click(menuItem('Change role'))
|
||||
|
||||
expect(dialog()?.textContent).toContain('Change role for Ines Duarte')
|
||||
await click(radio('Contributor'))
|
||||
await click(
|
||||
[...dialog()!.querySelectorAll('button')].find(
|
||||
(b) => b.textContent?.trim() === 'Change role',
|
||||
),
|
||||
)
|
||||
|
||||
expect(calls('PUT /v1/user/4/role')).toHaveLength(1)
|
||||
expect(JSON.parse(calls('PUT /v1/user/4/role')[0]![1].body)).toEqual({
|
||||
role_id: CONTRIBUTOR.id,
|
||||
})
|
||||
expect(rowOf('ines.duarte@renew.example').textContent).toContain('Contributor')
|
||||
expect(rowOf(JONAS_EMAIL).textContent).toContain('admin')
|
||||
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(1)
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('re-reads the list and closes 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' }),
|
||||
)
|
||||
await mountPage()
|
||||
await openMenu('Ines Duarte')
|
||||
await click(menuItem('Change role'))
|
||||
await click(radio('Contributor'))
|
||||
await click(
|
||||
[...dialog()!.querySelectorAll('button')].find(
|
||||
(b) => b.textContent?.trim() === 'Change role',
|
||||
),
|
||||
)
|
||||
|
||||
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2)
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Transferring ownership', () => {
|
||||
it('refreshes the session then the list and shows the toast', async () => {
|
||||
answers['POST /v1/site/transfer-ownership'] = () =>
|
||||
Promise.resolve({
|
||||
owner: { id: 2, name: 'Jonas Weber' },
|
||||
former_owner: { id: 1, name: 'Marta Alves', role: roleRefFor('admin') },
|
||||
})
|
||||
await mountPage()
|
||||
await openMenu('Marta Alves')
|
||||
await click(menuItem('Transfer ownership'))
|
||||
await click(radio('Jonas Weber'))
|
||||
await click(
|
||||
[...dialog()!.querySelectorAll('button')].find(
|
||||
(b) => b.textContent?.trim() === 'Continue',
|
||||
),
|
||||
)
|
||||
const input = dialog()!.querySelector('input')!
|
||||
input.value = JONAS_EMAIL
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
await click(
|
||||
[...dialog()!.querySelectorAll('button')].find(
|
||||
(b) => b.textContent?.trim() === 'Transfer ownership',
|
||||
),
|
||||
)
|
||||
|
||||
expect(JSON.parse(calls('POST /v1/site/transfer-ownership')[0]![1].body)).toEqual({
|
||||
user_id: 2,
|
||||
confirm_email: JONAS_EMAIL,
|
||||
})
|
||||
expect(getMe).toHaveBeenCalledTimes(1)
|
||||
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2)
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite rows keep their role', () => {
|
||||
it('keeps the Contributor role on a renewed invite', async () => {
|
||||
answers['POST /v1/user/invite/2/renew'] = () =>
|
||||
Promise.resolve({
|
||||
id: 2,
|
||||
kind: 'invite',
|
||||
email: 'sam.oliveira@renew.example',
|
||||
status: 'pending',
|
||||
created_at: '2026-10-01T09:00:00Z',
|
||||
expires_at: '2026-10-04T09:00:00Z',
|
||||
})
|
||||
await mountPage()
|
||||
await click(buttonNamed('Renew invite'))
|
||||
|
||||
const row = rowOf('sam.oliveira@renew.example')
|
||||
expect(row.textContent).toContain('Contributor')
|
||||
expect(row.textContent).toContain('Pending')
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the escalation refusal and leaves the row Expired', async () => {
|
||||
answers['POST /v1/user/invite/2/renew'] = () =>
|
||||
Promise.reject(new ApiError(403, undefined, 'escalation', { code: 'escalation' }))
|
||||
await mountPage()
|
||||
await click(buttonNamed('Renew invite'))
|
||||
|
||||
expect(rowOf('sam.oliveira@renew.example').textContent).toContain('Expired')
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
const toast = toastAdd.mock.calls[0]![0]
|
||||
const content = typeof toast === 'function' ? toast() : toast
|
||||
expect(content.title).toBe("You can't grant that")
|
||||
expect(content.description).toBe('You can only grant what your role has.')
|
||||
})
|
||||
|
||||
it('puts the invite at the top with the chosen role badge', async () => {
|
||||
answers['POST /v1/user/invite'] = () =>
|
||||
Promise.resolve({
|
||||
id: 8,
|
||||
kind: 'invite',
|
||||
email: 'lea.martin@renew.example',
|
||||
role: { id: 3, name: 'Writer', built_in: true, locked: false },
|
||||
status: 'pending',
|
||||
created_at: '2026-10-01T09:00:00Z',
|
||||
expires_at: '2026-10-04T09:00:00Z',
|
||||
})
|
||||
await mountPage()
|
||||
await click(buttonNamed('Invite user'))
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="name@example.com"]',
|
||||
)!
|
||||
input.value = 'lea.martin@renew.example'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
await click(buttonNamed('Send invite'))
|
||||
|
||||
expect(JSON.parse(calls('POST /v1/user/invite')[0]![1].body)).toEqual({
|
||||
email: 'lea.martin@renew.example',
|
||||
role_id: 3,
|
||||
})
|
||||
const first = document.body.querySelector<HTMLElement>('[data-testid="user-row"]')!
|
||||
expect(first.textContent).toContain('lea.martin@renew.example')
|
||||
expect(first.textContent).toContain('writer')
|
||||
expect(accessFor('owner')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -50,7 +50,6 @@ function defaultFixture(): UserListItem[] {
|
||||
kind: 'invite',
|
||||
id: 1,
|
||||
email: 'p.huber@gmail.com',
|
||||
display_name: 'Paul Huber',
|
||||
status: 'expired',
|
||||
created_at: '2026-08-03T09:00:00Z',
|
||||
},
|
||||
@@ -281,7 +280,10 @@ describe.each([
|
||||
expect(iconOf(renew)).toBe('i-lucide-refresh-cw')
|
||||
expect(renew.className).toContain('text-highlighted')
|
||||
expect(accepted).toHaveLength(3)
|
||||
accepted.forEach((row) => expect(buttonsIn(row)).toHaveLength(0))
|
||||
accepted.forEach((row) => {
|
||||
expect(buttonIn(row, 'Cancel invite')).toBeUndefined()
|
||||
expect(buttonIn(row, 'Renew invite')).toBeUndefined()
|
||||
})
|
||||
expect(cancel.className).toContain(desktop ? 'h-7' : 'h-11')
|
||||
})
|
||||
},
|
||||
@@ -494,7 +496,7 @@ describe('Renew invite: done', () => {
|
||||
const [first, second] = rows()
|
||||
expect(first.textContent).toContain('p.huber@gmail.com')
|
||||
expect(first.textContent).toContain('Pending')
|
||||
expect(first.textContent).toContain('Paul Huber')
|
||||
expect(first.textContent).toContain('p.huber@gmail.com')
|
||||
expect(first.className).toContain('bg-elevated')
|
||||
expect(buttonIn(first, 'Cancel invite')).toBeDefined()
|
||||
expect(second.textContent).toContain('sam.oliveira@renew.example')
|
||||
|
||||
@@ -47,7 +47,6 @@ function defaultFixture(): UserListItem[] {
|
||||
kind: 'invite',
|
||||
id: 1,
|
||||
email: 'p.huber@gmail.com',
|
||||
display_name: 'Paul Huber',
|
||||
role: roleRefFor('admin'),
|
||||
status: 'expired',
|
||||
created_at: '2026-08-03T09:00:00Z',
|
||||
@@ -284,7 +283,7 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
||||
|
||||
expect(rowTexts()).toEqual([
|
||||
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
|
||||
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026 Renew invite',
|
||||
'p.huber@gmail.com — admin Expired 3 Aug 2026 Renew invite',
|
||||
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
|
||||
'jonas@renew.example — admin Accepted 2 Apr 2026',
|
||||
'marta@renew.example you — owner Accepted 12 Mar 2026',
|
||||
@@ -306,7 +305,7 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
||||
Array.from(row.querySelectorAll('span')).findLast(
|
||||
(el) => el.textContent?.trim() === role,
|
||||
)!
|
||||
const [sam, huber, , jonas, marta] = rows()
|
||||
const [sam, huber, ines, jonas, marta] = rows()
|
||||
|
||||
expect(badge(marta, 'owner').className).toContain('bg-primary')
|
||||
expect(badge(marta, 'owner').className).not.toContain('bg-transparent')
|
||||
@@ -315,10 +314,11 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
||||
|
||||
expect(badge(jonas, '—').className).toContain('text-muted')
|
||||
expect(badge(sam, '—').className).toContain('text-muted')
|
||||
expect(badge(huber, 'Paul Huber').className).toContain('text-highlighted')
|
||||
expect(badge(huber, 'Paul Huber').className).not.toContain('text-muted')
|
||||
expect(badge(huber, '—').className).toContain('text-muted')
|
||||
expect(badge(ines, 'Inès Duarte').className).toContain('text-highlighted')
|
||||
expect(badge(ines, 'Inès Duarte').className).not.toContain('text-muted')
|
||||
|
||||
expect(badge(marta, 'you')).toBeTruthy()
|
||||
expect(badge(marta, 'you')).toBeInstanceOf(HTMLElement)
|
||||
expect(rows().filter((row) => badge(row, 'you'))).toEqual([marta])
|
||||
})
|
||||
|
||||
@@ -326,15 +326,16 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
||||
await mountPage({}, false)
|
||||
|
||||
const spans = (row: Element) => Array.from(row.querySelectorAll('span'))
|
||||
const [, huber, , jonas, marta] = rows()
|
||||
const [, huber, ines, jonas, marta] = rows()
|
||||
const find = (row: Element, text: string) =>
|
||||
spans(row).findLast((el) => el.textContent?.trim() === text)
|
||||
|
||||
expect(find(marta, 'owner')!.className).toContain('bg-primary')
|
||||
expect(find(jonas, 'admin')!.className).toContain('bg-transparent')
|
||||
expect(find(jonas, '—')).toBeUndefined()
|
||||
expect(find(huber, 'Paul Huber')).toBeTruthy()
|
||||
expect(find(marta, 'you')).toBeTruthy()
|
||||
expect(find(ines, 'Inès Duarte')).toBeInstanceOf(HTMLElement)
|
||||
expect(find(huber, '—')).toBeUndefined()
|
||||
expect(find(marta, 'you')).toBeInstanceOf(HTMLElement)
|
||||
expect(find(jonas, 'you')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -343,7 +344,7 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
||||
|
||||
expect(rowTexts()).toEqual([
|
||||
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026 Cancel invite',
|
||||
'p.huber@gmail.com Paul Huber admin Expired Created 3 Aug 2026 Renew invite',
|
||||
'p.huber@gmail.com admin Expired Created 3 Aug 2026 Renew invite',
|
||||
'ines.duarte@renew.example Inès Duarte admin Accepted Created 18 Jun 2026',
|
||||
'jonas@renew.example admin Accepted Created 2 Apr 2026',
|
||||
'marta@renew.example you owner Accepted Created 12 Mar 2026',
|
||||
@@ -515,10 +516,8 @@ describe('Invite: default — opening the modal', () => {
|
||||
await mountPage()
|
||||
await openModal()
|
||||
|
||||
expect(dialog()?.textContent).toContain('Invite a user')
|
||||
expect(dialog()?.textContent).toContain(
|
||||
"We'll email them a link to choose a username and password. The link expires after 3 days.",
|
||||
)
|
||||
expect(dialog()?.textContent).toContain('Invite user')
|
||||
expect(dialog()?.textContent).toContain('They get an email with a link to set a password.')
|
||||
expect(emailInput().value).toBe('')
|
||||
expect(document.activeElement).toBe(emailInput())
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
@@ -616,7 +615,7 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
|
||||
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending Today')
|
||||
expect(texts.slice(1)).toEqual([
|
||||
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
|
||||
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026 Renew invite',
|
||||
'p.huber@gmail.com — admin Expired 3 Aug 2026 Renew invite',
|
||||
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
|
||||
'jonas@renew.example — admin Accepted 2 Apr 2026',
|
||||
'marta@renew.example you — owner Accepted 12 Mar 2026',
|
||||
@@ -632,6 +631,27 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 'has 1 day to accept.'],
|
||||
[7, 'has 7 days to accept.'],
|
||||
])('words a %s-day expiry from expires_at', async (days, wording) => {
|
||||
const created = new Date()
|
||||
await mountPage({
|
||||
invite: () =>
|
||||
Promise.resolve({
|
||||
...inviteFixture(),
|
||||
created_at: created.toISOString(),
|
||||
expires_at: new Date(
|
||||
created.getTime() + days * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
}),
|
||||
})
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
|
||||
expect(toastAdd.mock.calls[0]![0].description).toBe(`${VALID_EMAIL} ${wording}`)
|
||||
})
|
||||
|
||||
it('returns focus to Invite user', async () => {
|
||||
await mountPage()
|
||||
await openModal()
|
||||
@@ -670,36 +690,46 @@ describe('Invite sent into the Only-you state keeps a coherent list', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite: already a user (409) — the field error depends on the response text', () => {
|
||||
describe('Invite: 409 conflicts are told apart by the error code', () => {
|
||||
it.each([
|
||||
{
|
||||
email: 'jonas@renew.example',
|
||||
body: 'This address already has an account.',
|
||||
code: 'email_has_account' as const,
|
||||
error: 'This address already has an account.',
|
||||
},
|
||||
{
|
||||
email: 'sam.oliveira@renew.example',
|
||||
body: "There's already a pending invite for this address.",
|
||||
code: 'invite_pending' as const,
|
||||
error: "There's already a pending invite for this address.",
|
||||
},
|
||||
{
|
||||
email: 'jonas@renew.example',
|
||||
body: 'Something else',
|
||||
error: 'This address already has an account.',
|
||||
},
|
||||
])('shows "$error" for the body "$body"', async ({ email, body, error }) => {
|
||||
])('shows "$error" for the code $code', async ({ email, code, error }) => {
|
||||
await mountPage({
|
||||
invite: () => Promise.reject(new ApiError(409, undefined, body)),
|
||||
invite: () => Promise.reject(new ApiError(409, undefined, 'ignored body', { code })),
|
||||
})
|
||||
await openModal()
|
||||
await submitWith(email)
|
||||
|
||||
expect(dialog()).toBeTruthy()
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(emailInput().value).toBe(email)
|
||||
expect(emailInput().disabled).toBe(false)
|
||||
expect(dialog()?.textContent).toContain(error)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores the server wording and falls back to the generic alert on an unknown 409', async () => {
|
||||
await mountPage({
|
||||
invite: () =>
|
||||
Promise.reject(
|
||||
new ApiError(409, undefined, 'This address already has an account.'),
|
||||
),
|
||||
})
|
||||
await openModal()
|
||||
await submitWith('jonas@renew.example')
|
||||
|
||||
expect(alertText()).toContain(FAILED_ALERT)
|
||||
expect(dialog()?.textContent).not.toContain('This address already has an account.')
|
||||
expect(emailInput().disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("A 400 that got past this form's own check", () => {
|
||||
|
||||
Reference in new issue
Block a user