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)
})
})
+15
View File
@@ -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
}
+88
View File
@@ -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')
}
}
+42 -1
View File
@@ -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
View File
@@ -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 {
+2 -4
View File
@@ -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]
})
}
+4
View File
@@ -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
}
}
+20
View File
@@ -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 {
+27 -1
View File
@@ -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 })),
],
})
}
+53
View File
@@ -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
}
+63
View File
@@ -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')
}
+48
View File
@@ -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])
}