refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types
This commit is contained in:
1 parent
5d6f378fce
commit
b39a41be88
124 files changed
+8816
-523
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { ACTIVITY_CATEGORIES, categoryIcon, categoryLabel } from '@/lib/activity'
|
||||
|
||||
describe('activity categories', () => {
|
||||
@@ -9,3 +10,49 @@ describe('activity categories', () => {
|
||||
expect(categoryIcon('roles')).toMatch(/^i-lucide-/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activity category names', () => {
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
it('lists the categories in the design order', () => {
|
||||
expect([...ACTIVITY_CATEGORIES]).toEqual([
|
||||
'articles',
|
||||
'media',
|
||||
'users',
|
||||
'roles',
|
||||
'api_keys',
|
||||
'auth',
|
||||
'system',
|
||||
])
|
||||
})
|
||||
|
||||
it('names them in English', () => {
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
|
||||
'Articles',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Roles',
|
||||
'API keys',
|
||||
'Authentication',
|
||||
'System',
|
||||
])
|
||||
})
|
||||
|
||||
it('names them in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
|
||||
'Articles',
|
||||
'Médias',
|
||||
'Utilisateurs et invitations',
|
||||
'Rôles',
|
||||
'Clés d’API',
|
||||
'Authentification',
|
||||
'Système',
|
||||
])
|
||||
})
|
||||
|
||||
it('never writes "Users & invites"', () => {
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel).join()).not.toContain('&')
|
||||
})
|
||||
})
|
||||
@@ -291,6 +291,7 @@ describe('The catalogue is complete in both languages', () => {
|
||||
'role.reassign',
|
||||
'user.change_role',
|
||||
'user.transfer_ownership',
|
||||
'user.remove',
|
||||
].sort(),
|
||||
)
|
||||
})
|
||||
@@ -380,3 +381,188 @@ describe('role and ownership sentences', () => {
|
||||
expect(sentenceText(item({ action: 'role.unknown' }))).toBe('role.unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: the six role events in EN and FR', () => {
|
||||
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
|
||||
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
|
||||
const roleChange = {
|
||||
field: 'role',
|
||||
kind: 'text' as const,
|
||||
before: 'Writer',
|
||||
after: 'Content lead',
|
||||
}
|
||||
|
||||
function event(overrides: Partial<ActivityItem>) {
|
||||
return item({ category: 'roles', target: roleTarget('Intern'), ...overrides })
|
||||
}
|
||||
|
||||
function both(source: ActivityItem) {
|
||||
setLocale('en')
|
||||
const english = sentenceText(source)
|
||||
setLocale('fr')
|
||||
const french = sentenceText(source)
|
||||
return [english, french]
|
||||
}
|
||||
|
||||
it.each([
|
||||
['role.create', 'Contributor', 'Role Contributor created', 'Rôle Contributor créé'],
|
||||
['role.edit', 'Contributor', 'Role Contributor edited', 'Rôle Contributor modifié'],
|
||||
['role.delete', 'Intern', 'Role Intern deleted', 'Rôle Intern supprimé'],
|
||||
])('success %s', (action, name, english, french) => {
|
||||
expect(both(event({ action, target: roleTarget(name) }))).toEqual([english, french])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[3, 'Moved 3 members from Intern to Writer', '3 membres déplacés de Intern vers Writer'],
|
||||
[1, 'Moved 1 member from Intern to Writer', '1 membre déplacé de Intern vers Writer'],
|
||||
])('success role.reassign with moved %s', (moved, english, french) => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
params: { from: 4, to: 3, moved, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([english, french])
|
||||
setLocale('en')
|
||||
expect(composeSentence(source).find((segment) => segment.strong)?.text).toBe('Intern')
|
||||
})
|
||||
|
||||
it('success user.change_role', () => {
|
||||
const source = event({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
target: userTarget('Ines Duarte'),
|
||||
changes: [roleChange],
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
"Ines Duarte's role changed from Writer to Content lead",
|
||||
"Le rôle d'Ines Duarte est passé de Writer à Content lead",
|
||||
])
|
||||
})
|
||||
|
||||
it('success user.transfer_ownership', () => {
|
||||
const source = event({
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
target: userTarget('Jonas Weber'),
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
'Ownership transferred from Marta Alves to Jonas Weber',
|
||||
'Propriété transférée de Marta Alves à Jonas Weber',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'role.create',
|
||||
'Contributor',
|
||||
'Failed to create the role Contributor',
|
||||
'Échec de la création du rôle Contributor',
|
||||
],
|
||||
[
|
||||
'role.edit',
|
||||
'Contributor',
|
||||
'Failed to edit the role Contributor',
|
||||
'Échec de la modification du rôle Contributor',
|
||||
],
|
||||
[
|
||||
'role.delete',
|
||||
'Intern',
|
||||
'Failed to delete the role Intern',
|
||||
'Échec de la suppression du rôle Intern',
|
||||
],
|
||||
])('failed %s', (action, name, english, french) => {
|
||||
expect(both(event({ action, outcome: 'failed', target: roleTarget(name) }))).toEqual([
|
||||
english,
|
||||
french,
|
||||
])
|
||||
})
|
||||
|
||||
it('failed role.reassign with the count and the destination', () => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
outcome: 'failed',
|
||||
params: { moved: 3, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
'Failed to move 3 members from Intern to Writer',
|
||||
'Échec du déplacement de 3 membres de Intern vers Writer',
|
||||
])
|
||||
})
|
||||
|
||||
it('failed role.reassign for one member', () => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
outcome: 'failed',
|
||||
params: { moved: 1, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)[0]).toBe('Failed to move 1 member from Intern to Writer')
|
||||
})
|
||||
|
||||
it('failed user.change_role and user.transfer_ownership', () => {
|
||||
expect(
|
||||
both(
|
||||
event({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
outcome: 'failed',
|
||||
target: userTarget('Ines Duarte'),
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
'Failed to change the role of Ines Duarte',
|
||||
"Échec du changement de rôle d'Ines Duarte",
|
||||
])
|
||||
expect(
|
||||
both(
|
||||
event({
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
outcome: 'failed',
|
||||
target: userTarget('Jonas Weber'),
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
'Failed to transfer ownership to Jonas Weber',
|
||||
'Échec du transfert de la propriété à Jonas Weber',
|
||||
])
|
||||
})
|
||||
|
||||
it('writes user.remove with the success and the failed sentence', () => {
|
||||
const target = userTarget('Ines Duarte')
|
||||
const removed = event({ action: 'user.remove', category: 'users', target })
|
||||
|
||||
expect(both(removed)).toEqual(['Removed Ines Duarte', 'Ines Duarte retiré'])
|
||||
expect(both({ ...removed, outcome: 'failed' })).toEqual([
|
||||
'Failed to remove Ines Duarte',
|
||||
"Échec du retrait d'Ines Duarte",
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', { moved: 3 }, 'Moved 3 members from Intern to another role'],
|
||||
['success', { moved: 1 }, 'Moved 1 member from Intern to another role'],
|
||||
['success', { to_name: 'Writer' }, 'Moved the members of the role Intern'],
|
||||
['failed', {}, 'Failed to move the members of the role Intern'],
|
||||
] as const)('role.reassign %s without all params: %j', (outcome, params, sentence) => {
|
||||
const text = sentenceText(event({ action: 'role.reassign', outcome, params }))
|
||||
|
||||
expect(text).toBe(sentence)
|
||||
expect(text).not.toContain('undefined')
|
||||
})
|
||||
|
||||
it('ignores an empty destination name', () => {
|
||||
const text = sentenceText(
|
||||
event({ action: 'role.reassign', params: { moved: 2, to_name: ' ' } }),
|
||||
)
|
||||
|
||||
expect(text).toBe('Moved 2 members from Intern to another role')
|
||||
})
|
||||
|
||||
it('renders an unknown action as its name', () => {
|
||||
expect(sentenceText(event({ action: 'role.shuffle' }))).toBe('role.shuffle')
|
||||
})
|
||||
})
|
||||
@@ -251,6 +251,45 @@ describe('custom content types', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('roles sync: permission-gated routes', () => {
|
||||
const GATED = [
|
||||
'POST /v1/content-type/',
|
||||
'PUT /v1/content-type/{key}',
|
||||
'DELETE /v1/content-type/{key}',
|
||||
'GET /v1/builtin-type/',
|
||||
'GET /v1/builtin-type/{key}',
|
||||
'GET /v1/media/purge-settings',
|
||||
'PATCH /v1/media/purge-settings',
|
||||
'GET /v1/media/purge-preview',
|
||||
'POST /v1/media/purge',
|
||||
]
|
||||
|
||||
it('matches "permission" on the nine routes through the access chip and the description', () => {
|
||||
const found = routes(search('permission').endpoints)
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(found).toContain(route)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches "missing_permission" on the error code of the gated routes', () => {
|
||||
const found = search('missing_permission').endpoints
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(routes(found)).toContain(route)
|
||||
}
|
||||
expect(found.find((hit) => routes([hit])[0] === GATED[0])!.via!.kind).toBe('error')
|
||||
})
|
||||
|
||||
it('no longer matches "owner or admin" on the nine routes', () => {
|
||||
const found = routes(search('owner or admin').endpoints)
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(found).not.toContain(route)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('lists the five suggested rows in order', () => {
|
||||
expect(routes(suggestedHits(index))).toEqual([
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { elideDe } from '@/lib/frenchElision'
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('elideDe', () => {
|
||||
it('elides before a vowel in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(elideDe('Le rôle de Ines Duarte a changé', 'Ines Duarte')).toBe(
|
||||
"Le rôle d'Ines Duarte a changé",
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps de before a consonant', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(elideDe('Le rôle de Jonas Weber a changé', 'Jonas Weber')).toBe(
|
||||
'Le rôle de Jonas Weber a changé',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves English untouched', () => {
|
||||
expect(elideDe("Ines Duarte's role", 'Ines Duarte')).toBe("Ines Duarte's role")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
entryNounOf,
|
||||
inverseNoun,
|
||||
inverseSide,
|
||||
kindFromInverseSide,
|
||||
@@ -87,3 +88,14 @@ describe('the preview', () => {
|
||||
expect(relationSample('many_to_many').ownerChips).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the entry noun of a type name', () => {
|
||||
it.each([
|
||||
['Events', 'an event', 'events'],
|
||||
['Recipes', 'a recipe', 'recipes'],
|
||||
['Press mentions', 'a press mention', 'press mentions'],
|
||||
['Categories', 'a category', 'categories'],
|
||||
])('turns %s into %s', (name, singular, plural) => {
|
||||
expect(entryNounOf(name)).toEqual({ singular, plural })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { roleDiffRows } from '@/lib/roleDiff'
|
||||
import type { RoleForm } from '@/lib/roleForm'
|
||||
|
||||
const TYPES = [
|
||||
{ key: 'article', name: 'Articles' },
|
||||
{ key: 'events', name: 'Events' },
|
||||
]
|
||||
|
||||
const CONTEXT = {
|
||||
types: TYPES,
|
||||
toggles: new Set(['activity.export']),
|
||||
order: ['activity.view', 'activity.export'],
|
||||
}
|
||||
|
||||
function form(grants: RoleForm['grants'], name = 'Contributor', description = ''): RoleForm {
|
||||
return { name, description, grants }
|
||||
}
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('roleDiffRows', () => {
|
||||
it('lists only the settings where the saved role and the edits disagree', () => {
|
||||
const saved = form({ 'content.read': 'all', 'content.update:events': 'own' })
|
||||
const yours = form({
|
||||
'content.read': 'all',
|
||||
'content.update:events': 'all',
|
||||
'content.delete:events': 'own',
|
||||
})
|
||||
|
||||
expect(
|
||||
roleDiffRows(saved, yours, CONTEXT).map(({ setting, saved, yours }) => [
|
||||
setting,
|
||||
saved,
|
||||
yours,
|
||||
]),
|
||||
).toEqual([
|
||||
['Events · Update', 'Own', 'All'],
|
||||
['Events · Delete', 'None', 'Own'],
|
||||
])
|
||||
})
|
||||
|
||||
it('shows nothing when an override equals the inherited value', () => {
|
||||
const saved = form({ 'content.read': 'all' })
|
||||
const yours = form({ 'content.read': 'all', 'content.read:events': 'all' })
|
||||
|
||||
expect(roleDiffRows(saved, yours, CONTEXT)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the Create switch as On and Off', () => {
|
||||
const rows = roleDiffRows(form({}), form({ 'content.create:events': 'all' }), CONTEXT)
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ id: 'content.create:events', setting: 'Events · Create', saved: 'Off', yours: 'On' },
|
||||
])
|
||||
})
|
||||
|
||||
it('names general permissions and keeps the catalogue order', () => {
|
||||
const rows = roleDiffRows(
|
||||
form({ 'activity.export': 'all' }),
|
||||
form({ 'activity.view': 'own', 'activity.export': 'none' }),
|
||||
CONTEXT,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
|
||||
['See activity', 'None', 'Own'],
|
||||
['Export activity', 'On', 'Off'],
|
||||
])
|
||||
})
|
||||
|
||||
it('lists a changed name or description first', () => {
|
||||
const rows = roleDiffRows(
|
||||
form({}, 'Contributor', 'Old'),
|
||||
form({}, 'Writer', 'New'),
|
||||
CONTEXT,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
|
||||
['Name', 'Contributor', 'Writer'],
|
||||
['Description', 'Old', 'New'],
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to one row per action when there are no types', () => {
|
||||
const rows = roleDiffRows(form({}), form({ 'content.read': 'all' }), {
|
||||
...CONTEXT,
|
||||
types: [],
|
||||
})
|
||||
|
||||
expect(rows.map((row) => row.setting)).toEqual(['All content types · Read'])
|
||||
})
|
||||
|
||||
it('translates the scopes', () => {
|
||||
setLocale('fr')
|
||||
|
||||
const rows = roleDiffRows(form({}), form({ 'content.update:events': 'own' }), CONTEXT)
|
||||
|
||||
expect(rows[0]).toMatchObject({ saved: 'Aucune', yours: 'Les siennes' })
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formFromRole,
|
||||
grantsToPayload,
|
||||
isCustomRow,
|
||||
reapplyEdits,
|
||||
resetRow,
|
||||
scopeAt,
|
||||
setScope,
|
||||
@@ -191,3 +192,46 @@ describe('differences', () => {
|
||||
).toEqual(['content.create', 'content.update', 'content.update:case-studies'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reapplyEdits', () => {
|
||||
const initial = {
|
||||
name: 'Contributor',
|
||||
description: '',
|
||||
grants: { 'content.read': 'all' } as const,
|
||||
}
|
||||
|
||||
it('puts the edited cells back on top of the new saved role', () => {
|
||||
const base = {
|
||||
name: 'Contributor',
|
||||
description: 'Saved',
|
||||
grants: { 'content.read': 'own' } as const,
|
||||
}
|
||||
const edited = {
|
||||
name: 'Contributor',
|
||||
description: '',
|
||||
grants: { 'content.read': 'all', 'content.delete:events': 'own' } as const,
|
||||
}
|
||||
|
||||
const merged = reapplyEdits(base, initial, edited)
|
||||
|
||||
expect(merged).toEqual({
|
||||
name: 'Contributor',
|
||||
description: 'Saved',
|
||||
grants: { 'content.read': 'own', 'content.delete:events': 'own' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an edited name and removes a cell the person reset', () => {
|
||||
const withOverride = {
|
||||
...initial,
|
||||
grants: { ...initial.grants, 'content.update:events': 'own' } as const,
|
||||
}
|
||||
const base = { name: 'Contributor', description: '', grants: { ...withOverride.grants } }
|
||||
const edited = { ...initial, name: 'Writer', grants: { 'content.read': 'all' } as const }
|
||||
|
||||
const merged = reapplyEdits(base, withOverride, edited)
|
||||
|
||||
expect(merged.name).toBe('Writer')
|
||||
expect(merged.grants).toEqual({ 'content.read': 'all' })
|
||||
})
|
||||
})
|
||||
@@ -49,7 +49,7 @@ describe('buildRolePreview content', () => {
|
||||
const preview = buildRolePreview({ grants, types: TYPES })
|
||||
|
||||
expect(preview.content.map((line) => line.label)).toEqual([
|
||||
'Articles, Case studies',
|
||||
'Articles and Case studies',
|
||||
'Events',
|
||||
])
|
||||
expect(preview.content[1]!.sentence).toContain('edit any entry')
|
||||
@@ -132,3 +132,67 @@ describe('buildRolePreview Changed tagging and members', () => {
|
||||
expect(buildRolePreview({ grants: {}, types: TYPES, members: 0 }).members).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRolePreview Read = Own', () => {
|
||||
const readOwn = {
|
||||
'content.read': 'own',
|
||||
'content.create': 'all',
|
||||
'content.update': 'own',
|
||||
} as const
|
||||
|
||||
it('writes one line per kind of type when Read is Own', () => {
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(preview.content.map((line) => [line.label, line.sentence])).toEqual([
|
||||
[
|
||||
'Articles',
|
||||
"Can read online articles and their own drafts, create entries and edit only their own. Can't delete and publish.",
|
||||
],
|
||||
[
|
||||
'Events and Case studies',
|
||||
"Can read only entries they created, create entries and edit only their own. Can't delete and publish.",
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the custom type sentence for a custom type override', () => {
|
||||
const grants = setScope({ 'content.read': 'all' }, 'content.read', 'own', 'events')
|
||||
|
||||
const preview = buildRolePreview({ grants, types: TYPES })
|
||||
|
||||
const events = preview.content.find((line) => line.label === 'Events')
|
||||
expect(events?.sentence).toContain('read only entries they created')
|
||||
})
|
||||
|
||||
it('uses the articles sentence alone when only Articles exists', () => {
|
||||
const preview = buildRolePreview({
|
||||
grants: { ...readOwn },
|
||||
types: [{ key: 'article', name: 'Articles' }],
|
||||
})
|
||||
|
||||
expect(preview.content).toHaveLength(1)
|
||||
expect(preview.content[0]).toMatchObject({ label: 'All content types' })
|
||||
expect(preview.content[0]!.sentence).toContain('read online articles and their own drafts')
|
||||
})
|
||||
|
||||
it('never says read only their own', () => {
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(JSON.stringify(preview)).not.toContain('read only their own')
|
||||
})
|
||||
|
||||
it('keeps the Read All wording', () => {
|
||||
const preview = buildRolePreview({ grants: applyPreset({}, 'write_own'), types: TYPES })
|
||||
|
||||
expect(preview.content[0]!.sentence).toContain('Can read any entry')
|
||||
})
|
||||
|
||||
it('translates both sentences', () => {
|
||||
setLocale('fr')
|
||||
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(preview.content[0]!.sentence).toContain('lire les articles en ligne')
|
||||
expect(preview.content[1]!.sentence).toContain('créées par la personne')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { userMenuItems, type UserMenuContext } from '@/lib/userMenu'
|
||||
import type { AcceptedUser } from '@/lib/users'
|
||||
import { roleRefFor } from '@/test/access'
|
||||
|
||||
function accepted(id: number, role: string): AcceptedUser {
|
||||
return {
|
||||
kind: 'user',
|
||||
id,
|
||||
username: `user${id}`,
|
||||
display_name: `User ${id}`,
|
||||
email: `user${id}@renew.example`,
|
||||
role: roleRefFor(role),
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
function context(overrides: Partial<UserMenuContext> = {}): UserMenuContext {
|
||||
return {
|
||||
sessionUserId: 1,
|
||||
canChangeRole: true,
|
||||
canTransfer: false,
|
||||
canViewRoles: true,
|
||||
canRemove: true,
|
||||
canCover: () => true,
|
||||
changeRole: vi.fn(),
|
||||
transfer: vi.fn(),
|
||||
removeUser: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
function remove(user: AcceptedUser, overrides: Partial<UserMenuContext> = {}) {
|
||||
return userMenuItems(user, context(overrides)).find((item) => item.key === 'remove-user')
|
||||
}
|
||||
|
||||
describe('Remove user in the row menu', () => {
|
||||
it('is enabled below Change role for an accepted user the person can cover', () => {
|
||||
const items = userMenuItems(accepted(2, 'editor'), context())
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['change-role', 'remove-user'])
|
||||
expect(items[1]).toMatchObject({
|
||||
label: 'Remove user',
|
||||
icon: 'i-lucide-user-x',
|
||||
destructive: true,
|
||||
unavailable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('is the only item for a person who can remove but not change roles', () => {
|
||||
const items = userMenuItems(accepted(2, 'editor'), context({ canChangeRole: false }))
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['remove-user'])
|
||||
})
|
||||
|
||||
it('is absent without the permission', () => {
|
||||
expect(remove(accepted(2, 'editor'), { canRemove: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is disabled on the owner row with the owner reason', () => {
|
||||
expect(remove(accepted(3, 'owner'))).toMatchObject({
|
||||
unavailable: true,
|
||||
hint: "The owner can't be changed or removed. Transfer ownership first.",
|
||||
})
|
||||
})
|
||||
|
||||
it('is disabled on the row marked you', () => {
|
||||
const item = remove(accepted(1, 'admin'))
|
||||
|
||||
expect(item?.unavailable).toBe(true)
|
||||
expect(item?.hint).toBe("You can't remove yourself. Ask another owner or admin.")
|
||||
})
|
||||
|
||||
it('is disabled with the escalation reason when the role cannot be covered', () => {
|
||||
const item = remove(accepted(2, 'admin'), { canCover: () => false })
|
||||
|
||||
expect(item?.unavailable).toBe(true)
|
||||
expect(item?.hint).toBe("Your role can't assign a role with more power than yours.")
|
||||
})
|
||||
|
||||
it('selects through the context', () => {
|
||||
const removeUser = vi.fn()
|
||||
const user = accepted(2, 'editor')
|
||||
|
||||
remove(user, { removeUser })!.onSelect(null)
|
||||
|
||||
expect(removeUser).toHaveBeenCalledWith(user, null)
|
||||
})
|
||||
|
||||
it('is absent for a user without a role', () => {
|
||||
const user = { ...accepted(2, 'editor'), role: null }
|
||||
|
||||
expect(remove(user)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(remove(accepted(2, 'editor'))?.label).toBe('Retirer l’utilisateur')
|
||||
expect(remove(accepted(1, 'admin'))?.hint).toBe(
|
||||
'Vous ne pouvez pas vous retirer vous-même. Demandez à un autre propriétaire ou administrateur.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,10 @@ export const ACTIVITY_CATEGORIES = [
|
||||
'articles',
|
||||
'media',
|
||||
'users',
|
||||
'roles',
|
||||
'api_keys',
|
||||
'auth',
|
||||
'system',
|
||||
'roles',
|
||||
] as const
|
||||
|
||||
export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
import { formatNumber, locale, t } from '@/i18n'
|
||||
import type { MessageKey } from '@/i18n/messages'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { sizeLabel } from '@/lib/activityRetention'
|
||||
@@ -19,6 +19,8 @@ interface Template {
|
||||
const TARGET_SLOT = '\u0000'
|
||||
const DROPPED_SLOT = new RegExp(`[\\s\\u00a0]*:?[\\s\\u00a0]*${TARGET_SLOT}`)
|
||||
const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
|
||||
const FRENCH_ELIDING_ACTIONS = ['user.change_role', 'user.remove']
|
||||
const FRENCH_VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
|
||||
|
||||
const CATALOGUE: Record<string, Template> = {
|
||||
'article.create': {
|
||||
@@ -123,8 +125,12 @@ const CATALOGUE: Record<string, Template> = {
|
||||
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}',
|
||||
success: 'Moved {0} members from {1} to {2}',
|
||||
failed: 'Failed to move {0} members from {1} to {2}',
|
||||
},
|
||||
'user.remove': {
|
||||
success: 'Removed {0}',
|
||||
failed: 'Failed to remove {0}',
|
||||
},
|
||||
'user.change_role': {
|
||||
success: 'Changed the role of {0}',
|
||||
@@ -226,9 +232,70 @@ function roleChangeOf(item: ActivityItem) {
|
||||
return item.changes.find((change) => change.field === 'role' && change.kind === 'text')
|
||||
}
|
||||
|
||||
interface SentenceParts {
|
||||
template: MessageKey
|
||||
values: string[]
|
||||
}
|
||||
|
||||
function destinationName(params: Record<string, unknown>) {
|
||||
const name = params.to_name
|
||||
return typeof name === 'string' && name.trim() !== '' ? name : null
|
||||
}
|
||||
|
||||
function movedCount(params: Record<string, unknown>) {
|
||||
const moved = params.moved
|
||||
return typeof moved === 'number' && Number.isFinite(moved) && moved >= 1 ? moved : null
|
||||
}
|
||||
|
||||
function reassignParts(item: ActivityItem): SentenceParts {
|
||||
const failed = item.outcome === 'failed'
|
||||
const moved = movedCount(item.params)
|
||||
const destination = destinationName(item.params)
|
||||
if (moved === null) {
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move the members of the role {0}'
|
||||
: 'Moved the members of the role {0}',
|
||||
values: [TARGET_SLOT],
|
||||
}
|
||||
}
|
||||
const count = formatNumber(moved)
|
||||
if (moved === 1) {
|
||||
if (destination === null) {
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move 1 member from {0} to another role'
|
||||
: 'Moved 1 member from {0} to another role',
|
||||
values: [TARGET_SLOT],
|
||||
}
|
||||
}
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move 1 member from {0} to {1}'
|
||||
: 'Moved 1 member from {0} to {1}',
|
||||
values: [TARGET_SLOT, destination],
|
||||
}
|
||||
}
|
||||
if (destination === null) {
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move {0} members from {1} to another role'
|
||||
: 'Moved {0} members from {1} to another role',
|
||||
values: [count, TARGET_SLOT],
|
||||
}
|
||||
}
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move {0} members from {1} to {2}'
|
||||
: 'Moved {0} members from {1} to {2}',
|
||||
values: [count, TARGET_SLOT, destination],
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||
const template = CATALOGUE[item.action]
|
||||
if (!template) return null
|
||||
if (item.action === 'role.reassign') return reassignParts(item).template
|
||||
if (item.action === 'user.change_role' && item.outcome === 'success' && roleChangeOf(item)) {
|
||||
return "{0}'s role changed from {1} to {2}"
|
||||
}
|
||||
@@ -248,6 +315,9 @@ function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||
}
|
||||
|
||||
function slotValues(item: ActivityItem, template: MessageKey) {
|
||||
if (item.action === 'role.reassign') {
|
||||
return reassignParts(item).values
|
||||
}
|
||||
if (PURGE_ACTIONS.includes(item.action) && item.outcome === 'success') {
|
||||
return [purgeSummary(item.params)]
|
||||
}
|
||||
@@ -276,6 +346,13 @@ function usesTargetSlot(values: string[]) {
|
||||
return values.includes(TARGET_SLOT)
|
||||
}
|
||||
|
||||
function elideFrench(item: ActivityItem, name: string, rendered: string) {
|
||||
if (locale.value !== 'fr' || !FRENCH_ELIDING_ACTIONS.includes(item.action)) return rendered
|
||||
return FRENCH_VOWEL_START.test(name)
|
||||
? rendered.replace(`de ${TARGET_SLOT}`, `d'${TARGET_SLOT}`)
|
||||
: rendered
|
||||
}
|
||||
|
||||
export function composeSentence(item: ActivityItem): SentenceSegment[] {
|
||||
const template = resolveTemplate(item)
|
||||
if (template === null) return [{ text: item.action, strong: false }]
|
||||
@@ -291,7 +368,7 @@ export function composeSentence(item: ActivityItem): SentenceSegment[] {
|
||||
|
||||
const values = slotValues(item, template)
|
||||
const name = targetName(item)
|
||||
const rendered = t(template, values)
|
||||
const rendered = elideFrench(item, name, t(template, values))
|
||||
if (!usesTargetSlot(values)) return [{ text: rendered, strong: false }]
|
||||
if (!name) return [{ text: rendered.replace(DROPPED_SLOT, ''), strong: false }]
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const KNOWN_ERROR_CODES = [
|
||||
'invalid_role_name',
|
||||
'invalid_reassign',
|
||||
'role_not_found',
|
||||
'target_role_not_found',
|
||||
'user_role_changed',
|
||||
'session_required',
|
||||
'invalid_role',
|
||||
|
||||
@@ -6,5 +6,7 @@ export interface LinkableUser {
|
||||
|
||||
export interface DirectoryNotice {
|
||||
title?: string
|
||||
tone?: 'error' | 'warning'
|
||||
description: string
|
||||
action?: { label: string; onClick: () => void }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { locale } from '@/i18n'
|
||||
|
||||
const VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
|
||||
|
||||
export function elideDe(text: string, name: string) {
|
||||
return locale.value === 'fr' && VOWEL_START.test(name)
|
||||
? text.replace(`de ${name}`, `d'${name}`)
|
||||
: text
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCop
|
||||
case 'invalid_role_name':
|
||||
case 'invalid_reassign':
|
||||
case 'role_not_found':
|
||||
case 'target_role_not_found':
|
||||
case 'session_required':
|
||||
case 'invalid_role':
|
||||
case 'invalid_transfer':
|
||||
|
||||
@@ -27,6 +27,15 @@ export function singularOf(name: string) {
|
||||
return name.length > 1 ? name.replace(FINAL_PLURAL, '') : name
|
||||
}
|
||||
|
||||
const PLURAL_IES = /ies$/i
|
||||
const VOWEL_START = /^[aeiou]/
|
||||
|
||||
export function entryNounOf(name: string) {
|
||||
const plural = name.trim().toLowerCase()
|
||||
const singular = PLURAL_IES.test(plural) ? plural.replace(PLURAL_IES, 'y') : singularOf(plural)
|
||||
return { singular: `${VOWEL_START.test(singular) ? 'an' : 'a'} ${singular}`, plural }
|
||||
}
|
||||
|
||||
export function relationNames(
|
||||
ownerName: string,
|
||||
relatedName: string,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { t } from '@/i18n'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import {
|
||||
CONTENT_ACTIONS,
|
||||
contentKey,
|
||||
scopeAt,
|
||||
type ContentAction,
|
||||
type RoleForm,
|
||||
} from '@/lib/roleForm'
|
||||
import { parseGrantKey, type Scope } from '@/lib/permissions'
|
||||
|
||||
export interface RoleDiffRow {
|
||||
id: string
|
||||
setting: string
|
||||
saved: string
|
||||
yours: string
|
||||
}
|
||||
|
||||
export interface RoleDiffContext {
|
||||
types: { key: string; name: string }[]
|
||||
toggles: ReadonlySet<string>
|
||||
order: readonly string[]
|
||||
}
|
||||
|
||||
const NAME_ROW = 'name'
|
||||
const DESCRIPTION_ROW = 'description'
|
||||
|
||||
function actionLabel(action: ContentAction) {
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return t('Read')
|
||||
case 'create':
|
||||
return t('Create')
|
||||
case 'update':
|
||||
return t('Update')
|
||||
case 'delete':
|
||||
return t('Delete')
|
||||
default:
|
||||
return t('Publish')
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(scope: Scope, toggle: boolean) {
|
||||
if (toggle) {
|
||||
return scope === 'none' ? t('Off') : t('On')
|
||||
}
|
||||
return scope === 'none' ? t('None') : scope === 'own' ? t('Own') : t('All')
|
||||
}
|
||||
|
||||
function contentRows(saved: RoleForm, yours: RoleForm, context: RoleDiffContext): RoleDiffRow[] {
|
||||
const rows: RoleDiffRow[] = []
|
||||
const types = context.types.length > 0 ? context.types : [{ key: undefined, name: '' }]
|
||||
for (const type of types) {
|
||||
for (const action of CONTENT_ACTIONS) {
|
||||
const key = contentKey(action)
|
||||
const before = scopeAt(saved.grants, key, type.key)
|
||||
const after = scopeAt(yours.grants, key, type.key)
|
||||
if (before === after) {
|
||||
continue
|
||||
}
|
||||
const toggle = action === 'create'
|
||||
const scope = type.key === undefined ? t('All content types') : type.name
|
||||
rows.push({
|
||||
id: `${key}:${type.key ?? ''}`,
|
||||
setting: `${scope} · ${actionLabel(action)}`,
|
||||
saved: scopeLabel(before, toggle),
|
||||
yours: scopeLabel(after, toggle),
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function generalKeys(saved: RoleForm, yours: RoleForm, order: readonly string[]) {
|
||||
const keys = new Set(
|
||||
[...Object.keys(saved.grants), ...Object.keys(yours.grants)]
|
||||
.map((key) => parseGrantKey(key).base)
|
||||
.filter((base) => !base.startsWith('content.')),
|
||||
)
|
||||
const rank = (key: string) => {
|
||||
const index = order.indexOf(key)
|
||||
return index === -1 ? order.length : index
|
||||
}
|
||||
return [...keys].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
}
|
||||
|
||||
function generalRows(saved: RoleForm, yours: RoleForm, context: RoleDiffContext): RoleDiffRow[] {
|
||||
return generalKeys(saved, yours, context.order).flatMap((key) => {
|
||||
const before = scopeAt(saved.grants, key)
|
||||
const after = scopeAt(yours.grants, key)
|
||||
if (before === after) {
|
||||
return []
|
||||
}
|
||||
const toggle = context.toggles.has(key)
|
||||
return [
|
||||
{
|
||||
id: key,
|
||||
setting: permissionName(key) ?? key,
|
||||
saved: scopeLabel(before, toggle),
|
||||
yours: scopeLabel(after, toggle),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function textRows(saved: RoleForm, yours: RoleForm): RoleDiffRow[] {
|
||||
const rows: RoleDiffRow[] = []
|
||||
if (saved.name !== yours.name) {
|
||||
rows.push({ id: NAME_ROW, setting: t('Name'), saved: saved.name, yours: yours.name })
|
||||
}
|
||||
if (saved.description !== yours.description) {
|
||||
rows.push({
|
||||
id: DESCRIPTION_ROW,
|
||||
setting: t('Description'),
|
||||
saved: saved.description,
|
||||
yours: yours.description,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function roleDiffRows(
|
||||
saved: RoleForm,
|
||||
yours: RoleForm,
|
||||
context: RoleDiffContext,
|
||||
): RoleDiffRow[] {
|
||||
return [
|
||||
...textRows(saved, yours),
|
||||
...contentRows(saved, yours, context),
|
||||
...generalRows(saved, yours, context),
|
||||
]
|
||||
}
|
||||
@@ -156,6 +156,28 @@ export function unsavedCount(current: RoleForm, initial: RoleForm): number {
|
||||
return count
|
||||
}
|
||||
|
||||
export function reapplyEdits(base: RoleForm, initial: RoleForm, edited: RoleForm): RoleForm {
|
||||
const grants = { ...base.grants }
|
||||
const keys = new Set([...Object.keys(initial.grants), ...Object.keys(edited.grants)])
|
||||
for (const key of keys) {
|
||||
if (rawEntry(initial.grants, key) === rawEntry(edited.grants, key)) {
|
||||
continue
|
||||
}
|
||||
const value = edited.grants[key]
|
||||
if (value === undefined) {
|
||||
delete grants[key]
|
||||
} else {
|
||||
grants[key] = value
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: edited.name !== initial.name ? edited.name : base.name,
|
||||
description:
|
||||
edited.description !== initial.description ? edited.description : base.description,
|
||||
grants,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCellChanged(
|
||||
current: GrantMap,
|
||||
initial: GrantMap,
|
||||
|
||||
+38
-10
@@ -145,8 +145,10 @@ const AREAS: Area[] = [
|
||||
|
||||
const OWNER_ONLY_AREA = 'system'
|
||||
|
||||
type TypeKind = 'article' | 'custom'
|
||||
|
||||
const GRANTED_CONTENT: Record<string, Record<'all' | 'own', () => string>> = {
|
||||
read: { all: () => t('read any entry'), own: () => t('read only their own entries') },
|
||||
read: { all: () => t('read any entry'), own: () => t('read only entries they created') },
|
||||
create: { all: () => t('create entries'), own: () => t('create entries') },
|
||||
update: { all: () => t('edit any entry'), own: () => t('edit only their own') },
|
||||
delete: { all: () => t('delete any entry'), own: () => t('delete only their own entries') },
|
||||
@@ -169,7 +171,7 @@ export function joinList(items: string[]): string {
|
||||
return t('{0} and {1}', [head, items[items.length - 1]])
|
||||
}
|
||||
|
||||
function contentSentence(scopes: Scope[]): string {
|
||||
function contentSentence(scopes: Scope[], kind: TypeKind): string {
|
||||
const granted: string[] = []
|
||||
const refused: string[] = []
|
||||
CONTENT_ACTIONS.forEach((action, index) => {
|
||||
@@ -178,7 +180,11 @@ function contentSentence(scopes: Scope[]): string {
|
||||
refused.push(REFUSED_CONTENT[action]!())
|
||||
return
|
||||
}
|
||||
granted.push(GRANTED_CONTENT[action]![scope]())
|
||||
granted.push(
|
||||
action === 'read' && scope === 'own' && kind === 'article'
|
||||
? t('read online articles and their own drafts')
|
||||
: GRANTED_CONTENT[action]![scope](),
|
||||
)
|
||||
})
|
||||
if (granted.length === 0) {
|
||||
return t('No access to entries.')
|
||||
@@ -187,6 +193,10 @@ function contentSentence(scopes: Scope[]): string {
|
||||
return refused.length === 0 ? can : `${can} ${t("Can't {0}.", [joinList(refused)])}`
|
||||
}
|
||||
|
||||
function kindOf(type: PreviewType): TypeKind {
|
||||
return type.key === ARTICLE_TYPE_KEY ? 'article' : 'custom'
|
||||
}
|
||||
|
||||
function contentLines(
|
||||
grants: GrantMap,
|
||||
types: PreviewType[],
|
||||
@@ -194,24 +204,42 @@ function contentLines(
|
||||
const hasOverrides = Object.keys(grants).some(
|
||||
(key) => key.startsWith('content.') && parseGrantKey(key).typeKey !== undefined,
|
||||
)
|
||||
if (!hasOverrides || types.length === 0) {
|
||||
const readsOwn = types.some((type) => scopeAt(grants, contentKey('read'), type.key) === 'own')
|
||||
if ((!hasOverrides && !readsOwn) || types.length === 0) {
|
||||
const scopes = CONTENT_ACTIONS.map((action) => scopeAt(grants, contentKey(action)))
|
||||
return [{ id: 'all', label: t('All content types'), sentence: contentSentence(scopes) }]
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: t('All content types'),
|
||||
sentence: contentSentence(scopes, 'custom'),
|
||||
},
|
||||
]
|
||||
}
|
||||
const groups = new Map<string, { names: string[]; scopes: Scope[] }>()
|
||||
const groups = new Map<string, { names: string[]; scopes: Scope[]; kind: TypeKind }>()
|
||||
for (const type of types) {
|
||||
const scopes = CONTENT_ACTIONS.map((action) =>
|
||||
scopeAt(grants, contentKey(action), type.key),
|
||||
)
|
||||
const signature = scopes.join('|')
|
||||
const group = groups.get(signature) ?? { names: [], scopes }
|
||||
const kind = kindOf(type)
|
||||
const signature = `${scopes.join('|')}${scopes[0] === 'own' ? `|${kind}` : ''}`
|
||||
const group = groups.get(signature) ?? { names: [], scopes, kind }
|
||||
group.names.push(type.name)
|
||||
groups.set(signature, group)
|
||||
}
|
||||
const [only] = [...groups.values()]
|
||||
if (!hasOverrides && groups.size === 1) {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: t('All content types'),
|
||||
sentence: contentSentence(only!.scopes, only!.kind),
|
||||
},
|
||||
]
|
||||
}
|
||||
return [...groups.entries()].map(([signature, group]) => ({
|
||||
id: signature,
|
||||
label: group.names.join(', '),
|
||||
sentence: contentSentence(group.scopes),
|
||||
label: joinList(group.names),
|
||||
sentence: contentSentence(group.scopes, group.kind),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,24 @@ export interface UserMenuContext {
|
||||
canChangeRole: boolean
|
||||
canTransfer: boolean
|
||||
canViewRoles: boolean
|
||||
canRemove: boolean
|
||||
canCover: (user: AcceptedUser) => boolean
|
||||
changeRole: (user: AcceptedUser, trigger: HTMLElement | null) => void
|
||||
transfer: (trigger: HTMLElement | null) => void
|
||||
removeUser: (user: AcceptedUser, trigger: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
function removeHint(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 remove yourself. Ask another owner or admin.")
|
||||
}
|
||||
if (!context.canCover(user)) {
|
||||
return t("Your role can't assign a role with more power than yours.")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function changeRoleHint(user: AcceptedUser, context: UserMenuContext): string | undefined {
|
||||
@@ -45,6 +61,18 @@ export function userMenuItems(user: AcceptedUser, context: UserMenuContext): Dir
|
||||
onSelect: (trigger) => context.transfer(trigger),
|
||||
})
|
||||
}
|
||||
if (context.canRemove && user.role) {
|
||||
const hint = removeHint(user, context)
|
||||
items.push({
|
||||
key: 'remove-user',
|
||||
label: t('Remove user'),
|
||||
icon: 'i-lucide-user-x',
|
||||
destructive: true,
|
||||
unavailable: hint !== undefined,
|
||||
hint,
|
||||
onSelect: (trigger) => context.removeUser(user, trigger),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '@/i18n'
|
||||
import { ApiError, type ApiErrorCode } from '@/lib/apiError'
|
||||
import { refusalCopy } from '@/lib/permissionCopy'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
@@ -31,16 +32,29 @@ function resolveText(text: ToastText) {
|
||||
return typeof text === 'function' ? text() : text
|
||||
}
|
||||
|
||||
type ToastColor = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
const TOAST_ICONS: Record<ToastColor, string> = {
|
||||
success: 'i-lucide-circle-check',
|
||||
error: 'i-lucide-circle-x',
|
||||
info: 'i-lucide-info',
|
||||
warning: 'i-lucide-triangle-alert',
|
||||
}
|
||||
|
||||
const STICKY_TOAST_MS = 0
|
||||
|
||||
export function userToast(
|
||||
id: string,
|
||||
color: 'success' | 'error',
|
||||
color: ToastColor,
|
||||
title: ToastText,
|
||||
description: ToastText,
|
||||
duration?: number,
|
||||
) {
|
||||
return () => ({
|
||||
id,
|
||||
color,
|
||||
icon: color === 'success' ? 'i-lucide-circle-check' : 'i-lucide-circle-x',
|
||||
duration,
|
||||
icon: TOAST_ICONS[color],
|
||||
get title() {
|
||||
return resolveText(title)
|
||||
},
|
||||
@@ -50,6 +64,27 @@ export function userToast(
|
||||
})
|
||||
}
|
||||
|
||||
export function staleToast(
|
||||
id: string,
|
||||
title: ToastText,
|
||||
description: ToastText,
|
||||
reload: () => void,
|
||||
actionLabel: ToastText = () => t('Reload'),
|
||||
) {
|
||||
const base = userToast(id, 'warning', title, description, STICKY_TOAST_MS)
|
||||
return () => ({
|
||||
...base(),
|
||||
actions: [
|
||||
{
|
||||
label: resolveText(actionLabel),
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: reload,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function refusalToast(id: string, refusal: DirectoryNotice) {
|
||||
return userToast(id, 'error', refusal.title ?? '', refusal.description)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user