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
@@ -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