feat: roles SPA foundations
This commit is contained in:
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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface DirectoryMenuItem {
|
||||
icon: string
|
||||
destructive?: boolean
|
||||
unavailable?: boolean
|
||||
ownerOnly?: boolean
|
||||
hint?: string
|
||||
separated?: boolean
|
||||
onSelect: (trigger: HTMLElement | null) => void
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user