feat: roles SPA foundations

This commit is contained in:
Florian Sylvain committed 2026-10-27 22:21:12 +01:00
1 parent 80f7037d3a
commit 907949aa12
180 files changed
+4308 -835

No files matched your search

@@ -5,7 +5,7 @@ import { classifyFailure, type FailureContext, type FailureKind } from '@/lib/di
const CASES: [FailureContext, number, string | undefined, FailureKind][] = [
['site', 401, undefined, 'session'],
['site', 403, 'default_site', 'defaultSite'],
['site', 403, 'Only the owner can do this.', 'forbidden'],
['site', 403, 'Only the owner can do this.', 'permission'],
['site', 404, 'site_not_found', 'gone'],
['site', 409, 'site_key_taken', 'keyTaken'],
['site', 409, 'site_has_articles', 'hasArticles'],
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setLocale } from '@/i18n'
import { messages } from '@/i18n/messages'
import { PERMISSION_KEYS, permissionName } from '@/lib/permissionNames'
import { CATALOGUE_KEYS } from '@/test/access'
afterEach(() => setLocale('en'))
describe('permissionName', () => {
it('knows every key of the catalogue', () => {
expect([...PERMISSION_KEYS].sort()).toEqual([...CATALOGUE_KEYS].sort())
expect(PERMISSION_KEYS).toHaveLength(34)
})
it.each(CATALOGUE_KEYS)('%s has an English and a French name', (key) => {
const english = permissionName(key)
expect(english).toBeTruthy()
expect(messages[english as keyof typeof messages]).toBeTruthy()
setLocale('fr')
expect(permissionName(key)).not.toBe(english)
})
it('names content.delete Delete entries', () => {
expect(permissionName('content.delete')).toBe('Delete entries')
})
it('shows the base-key name for a typed key', () => {
expect(permissionName('content.publish:events')).toBe('Publish entries')
})
it('has no name for an unknown key', () => {
expect(permissionName('content.explode')).toBeUndefined()
})
})
+36 -1
View File
@@ -1,12 +1,47 @@
export const KNOWN_ERROR_CODES = [
'missing_permission',
'escalation',
'role_locked',
'own_role',
'role_built_in',
'built_in_name',
'role_conflict',
'user_role_changed',
'session_required',
] as const
export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number]
export interface ApiErrorDetails {
code?: ApiErrorCode
permission?: string
escalationKey?: string
}
const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
export function errorCodeOf(body: string | undefined): ApiErrorCode | undefined {
if (body === SESSION_REQUIRED_MESSAGE) {
return 'session_required'
}
return KNOWN_ERROR_CODES.find((code) => code === body)
}
export class ApiError extends Error {
status: number
retryAfter?: number
body?: string
code?: ApiErrorCode
permission?: string
escalationKey?: string
constructor(status: number, retryAfter?: number, body?: string) {
constructor(status: number, retryAfter?: number, body?: string, details: ApiErrorDetails = {}) {
super(`Request failed with status ${status}`)
this.status = status
this.retryAfter = retryAfter
this.body = body
this.code = details.code
this.permission = details.permission
this.escalationKey = details.escalationKey
}
}
+8 -2
View File
@@ -39,7 +39,7 @@ export type FailureContext = 'site' | 'author'
export type FailureKind =
| 'session'
| 'forbidden'
| 'permission'
| 'defaultSite'
| 'rights'
| 'gone'
@@ -64,10 +64,16 @@ export function classifyFailure(error: unknown, context: FailureContext): Failur
return 'gone'
}
if (status === 403) {
if (
code === 'missing_permission' ||
(error instanceof ApiError && error.code === 'missing_permission')
) {
return 'permission'
}
if (context === 'author') {
return 'rights'
}
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'forbidden'
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'permission'
}
const table: Record<FailureContext, Record<string, FailureKind>> = {
site: {
+1
View File
@@ -4,6 +4,7 @@ export interface DirectoryMenuItem {
icon: string
destructive?: boolean
unavailable?: boolean
ownerOnly?: boolean
hint?: string
separated?: boolean
onSelect: (trigger: HTMLElement | null) => void
+55
View File
@@ -0,0 +1,55 @@
import { t } from '@/i18n'
import { permissionName } from '@/lib/permissionNames'
import type { ApiErrorCode } from '@/lib/apiError'
export interface RefusalCopy {
title: string
description?: string
}
export function missingPermissionCopy(permission?: string): RefusalCopy {
const name = permission === undefined ? undefined : permissionName(permission)
return {
title: t("You don't have permission to do that"),
description:
name === undefined
? t("Your role doesn't include this action. Ask an owner or admin.")
: t("Your role doesn't include {0}. Ask an owner or admin.", [name]),
}
}
export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCopy | undefined {
switch (code) {
case 'missing_permission':
return missingPermissionCopy(permission)
case 'escalation':
return {
title: t("You can't grant that"),
description: t('You can only grant what your role has.'),
}
case 'role_locked':
return {
title: t('This role is locked'),
description: t(
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.",
),
}
case 'own_role':
return {
title: t("You can't change your own role"),
description: t('Ask another owner or admin.'),
}
case 'role_built_in':
return {
title: t("Built-in roles can't be deleted"),
description: t('Duplicate it to make a custom role.'),
}
case 'built_in_name':
return { title: t('Built-in roles keep their name.') }
case 'role_conflict':
case 'user_role_changed':
return { title: t('This changed somewhere else. Reload and try again.') }
case 'session_required':
return undefined
}
}
+50
View File
@@ -0,0 +1,50 @@
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
const NAMES = {
'content.read': 'Read entries',
'content.create': 'Create entries',
'content.update': 'Update entries',
'content.delete': 'Delete entries',
'content.publish': 'Publish entries',
'schema.view': 'View content types',
'schema.edit': 'Create and edit content types',
'schema.delete': 'Delete content types',
'media.view': 'View the media library',
'media.upload': 'Upload files',
'media.delete': 'Delete files',
'authors.view': 'View authors',
'authors.create': 'Create authors',
'authors.edit': 'Edit authors',
'authors.delete': 'Delete authors',
'activity.view': 'See activity',
'activity.export': 'Export activity',
'users.view': 'View users',
'users.invite': 'Invite users',
'users.change_role': "Change a user's role",
'users.remove': 'Remove users',
'settings.view': 'View settings',
'settings.edit_general': 'Edit general settings',
'settings.manage_sites': 'Manage sites',
'settings.delete_site': 'Delete a site',
'apikeys.view': 'See API keys',
'apikeys.create': 'Create API keys',
'apikeys.revoke': 'Revoke API keys',
'roles.view': 'View roles',
'roles.manage': 'Manage roles',
'roles.edit_owner_only': 'Edit roles with owner-only power',
'system.apply_updates': 'Apply software updates',
'system.edit_retention': 'Edit activity retention',
'system.transfer_ownership': 'Transfer ownership',
} as const satisfies Record<string, MessageKey>
export const PERMISSION_KEYS = Object.keys(NAMES)
export function baseKey(key: string) {
return key.split(':')[0]!
}
export function permissionName(key: string): string | undefined {
const name = NAMES[baseKey(key) as keyof typeof NAMES]
return name === undefined ? undefined : t(name)
}
+70
View File
@@ -0,0 +1,70 @@
export type Scope = 'none' | 'own' | 'all'
export interface Grant {
key: string
scope: Scope
}
export interface RoleRef {
id: number
name: string
built_in: boolean
locked: boolean
}
export interface Access {
role: RoleRef
permissions: Grant[]
}
export interface CanOptions {
ownerId?: number
typeKey?: string
scope?: 'own' | 'all'
}
export interface ScopeOptions {
typeKey?: string
}
const TYPE_SEPARATOR = ':'
function scopeOfGrant(grants: readonly Grant[], key: string): Scope | undefined {
return grants.find((grant) => grant.key === key)?.scope
}
export function effectiveScope(
grants: readonly Grant[],
key: string,
options: ScopeOptions = {},
): Scope {
if (options.typeKey) {
const typed = scopeOfGrant(grants, `${key}${TYPE_SEPARATOR}${options.typeKey}`)
if (typed !== undefined) {
return typed
}
}
return scopeOfGrant(grants, key) ?? 'none'
}
export function allows(
grants: readonly Grant[],
userId: number | undefined,
key: string,
options: CanOptions = {},
): boolean {
const scope = effectiveScope(grants, key, options)
if (scope === 'none') {
return false
}
if (scope === 'all') {
return true
}
if (options.scope === 'all') {
return false
}
if (options.ownerId === undefined) {
return true
}
return userId !== undefined && options.ownerId !== 0 && options.ownerId === userId
}
+34
View File
@@ -0,0 +1,34 @@
import type { CanOptions } from '@/lib/permissions'
export interface RouteAccess {
key: string
typeKey?: string
typeParam?: string
}
export type CanCheck = (key: string, options?: CanOptions) => boolean
export const ARTICLE_TYPE_KEY = 'article'
function typeKeyOf(access: RouteAccess, params: Record<string, unknown>) {
if (access.typeKey !== undefined) {
return access.typeKey
}
if (access.typeParam === undefined) {
return undefined
}
const value = params[access.typeParam]
return typeof value === 'string' ? value : undefined
}
export function isRouteAllowed(
access: RouteAccess | undefined,
params: Record<string, unknown>,
can: CanCheck,
): boolean {
if (access === undefined) {
return true
}
const typeKey = typeKeyOf(access, params)
return can(access.key, typeKey === undefined ? {} : { typeKey })
}