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
@@ -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])
|
||||
}
|
||||
Reference in new issue
Block a user