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:
Florian Sylvain committed 2026-10-29 16:28:25 +01:00
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')
})
})
+39
View File
@@ -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 })
})
})
+101
View File
@@ -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' })
})
})
+44
View File
@@ -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' })
})
})
+65 -1
View File
@@ -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')
})
})
+108
View File
@@ -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.',
)
})
})
+1 -1
View File
@@ -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]
+81 -4
View File
@@ -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 }]
+1
View File
@@ -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',
+2
View File
@@ -6,5 +6,7 @@ export interface LinkableUser {
export interface DirectoryNotice {
title?: string
tone?: 'error' | 'warning'
description: string
action?: { label: string; onClick: () => void }
}
+9
View File
@@ -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
}
+1
View File
@@ -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':
+9
View File
@@ -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,
+132
View File
@@ -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),
]
}
+22
View File
@@ -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
View File
@@ -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),
}))
}
+28
View File
@@ -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
}
+37 -2
View File
@@ -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)
}