feat: roles SPA Users, Profile, API keys, Activity

This commit is contained in:
Florian Sylvain committed 2026-10-28 00:10:15 +01:00
1 parent 3db490a615
commit 34dffe222f
98 files changed
+4957 -434

No files matched your search

@@ -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')
})
})
+22
View File
@@ -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()
})
})
+61
View File
@@ -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)
})
})