feat: SPA Roles screens

This commit is contained in:
Florian Sylvain committed 2026-10-27 23:05:04 +01:00
1 parent 907949aa12
commit 3db490a615
47 files changed
+6947 -9

No files matched your search

+557
View File
@@ -0,0 +1,557 @@
import { t, locale } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { RoleConflictError, useRoles } from '@/composables/useRoles'
import { refusalCopy, type RefusalCopy } from '@/lib/permissionCopy'
import { permissionName } from '@/lib/permissionNames'
import {
applyPreset,
cloneForm,
differences,
emptyForm,
formFromRole,
grantsToPayload,
resetRow,
setScope,
unsavedCount,
activePreset,
contentKey,
type ContentAction,
type PresetName,
type RoleForm,
} from '@/lib/roleForm'
import { buildRolePreview } from '@/lib/rolePreview'
import {
OWNER_ONLY_FALLBACK,
formatClock,
type PermissionGroupItem,
type RoleItem,
} from '@/lib/roles'
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
import { parseGrantKey, type Scope } from '@/lib/permissions'
import { useContentTypesStore } from '@/stores/contentTypes'
import { usePermissionsStore } from '@/stores/permissions'
import { useSessionStore } from '@/stores/session'
export type LoadState = 'loading' | 'ready' | 'error' | 'missing'
export type ReadOnlyReason = 'owner' | 'lowered' | 'view' | 'own' | 'builtin' | 'locked'
export interface ConflictState {
title: string
description: string
latest: RoleItem | null
}
const NO_CONFLICT = null
export function useRoleEditor() {
const route = useRoute()
const router = useRouter()
const toast = useLocalizedToast()
const permissions = usePermissionsStore()
const session = useSessionStore()
const contentTypes = useContentTypesStore()
const api = useRoles()
const loadState = ref<LoadState>('loading')
const role = ref<RoleItem | null>(null)
const form = ref<RoleForm>(emptyForm())
const initial = ref<RoleForm>(emptyForm())
const catalogue = ref<PermissionGroupItem[]>([])
const saving = ref(false)
const nameError = ref('')
const conflict = ref<ConflictState | null>(NO_CONFLICT)
const lowered = ref(false)
const serverLocked = ref(false)
const serverOwnRole = ref(false)
const refusal = ref<RefusalCopy | null>(null)
const reviewing = ref(false)
let loadedKey = ''
let loadToken = 0
const isNew = computed(() => route.params.id === undefined)
const ownerOnlyKeys = computed(() => {
const keys = catalogue.value.flatMap((group) =>
group.items.filter((item) => item.owner_only).map((item) => item.key),
)
return keys.length > 0 ? new Set(keys) : OWNER_ONLY_FALLBACK
})
const readOnlyReason = computed<ReadOnlyReason | null>(() => {
if (role.value?.locked) {
return 'owner'
}
if (lowered.value) {
return 'lowered'
}
if (!permissions.can('roles.manage')) {
return 'view'
}
if (serverOwnRole.value || (role.value && role.value.id === permissions.role?.id)) {
return 'own'
}
if (role.value?.built_in && !permissions.can('roles.edit_owner_only')) {
return 'builtin'
}
return serverLocked.value ? 'locked' : null
})
const readOnly = computed(() => readOnlyReason.value !== null)
const unsaved = computed(() => unsavedCount(form.value, initial.value))
const problems = computed(() => (nameError.value === '' ? 0 : 1))
const nameMissing = computed(() => form.value.name.trim() === '')
const canSave = computed(
() =>
!readOnly.value &&
!saving.value &&
conflict.value === null &&
!(isNew.value && nameMissing.value),
)
const barVisible = computed(
() =>
!readOnly.value &&
loadState.value === 'ready' &&
(isNew.value ||
unsaved.value > 0 ||
saving.value ||
problems.value > 0 ||
conflict.value !== null),
)
const preset = computed(() => activePreset(form.value.grants))
const canReset = computed(
() =>
role.value !== null &&
role.value.built_in &&
!role.value.locked &&
permissions.can('roles.manage') &&
permissions.can('roles.edit_owner_only') &&
!readOnly.value,
)
const gridTypes = computed(() => {
const named = [
{ key: ARTICLE_TYPE_KEY, name: t('Articles'), builtin: true },
...contentTypes.items.map((type) => ({
key: type.key,
name: type.name,
builtin: false,
})),
]
const known = new Set(named.map((type) => type.key))
const typed = Object.keys(form.value.grants)
.map((key) => parseGrantKey(key))
.filter(({ base }) => base.startsWith('content.'))
.map(({ typeKey }) => typeKey)
.filter((key): key is string => key !== undefined)
.filter((key, index, all) => !known.has(key) && all.indexOf(key) === index)
.map((key) => ({ key, name: key, builtin: false }))
return [...named, ...typed]
})
const preview = computed(() =>
buildRolePreview({
grants: form.value.grants,
types: gridTypes.value.map((type) => ({ key: type.key, name: type.name })),
members: role.value?.members ?? null,
initial: isNew.value || readOnly.value ? undefined : initial.value.grants,
}),
)
const differenceRows = computed(() => {
const latest = conflict.value?.latest
if (!latest) {
return []
}
return differences(form.value, formFromRole(latest)).map((entry) => ({
name: permissionName(entry.key) ?? entry.key,
key: entry.key,
kind:
entry.before === undefined || entry.before === 'none'
? ('added' as const)
: entry.after === undefined || entry.after === 'none'
? ('removed' as const)
: ('changed' as const),
}))
})
function adopt(next: RoleItem) {
role.value = next
form.value = formFromRole(next)
initial.value = cloneForm(form.value)
nameError.value = ''
conflict.value = NO_CONFLICT
reviewing.value = false
refusal.value = null
}
function routeKey() {
return isNew.value ? `new:${route.query.from ?? ''}` : String(route.params.id)
}
async function loadCatalogue() {
try {
catalogue.value = await api.listPermissions()
} catch {
catalogue.value = []
}
}
async function loadSource(token: number) {
if (isNew.value) {
const from = Number(route.query.from)
if (route.query.from === undefined || !Number.isInteger(from)) {
role.value = null
form.value = emptyForm()
initial.value = emptyForm()
return
}
const source = await api.getRole(from)
if (token !== loadToken) {
return
}
role.value = null
form.value = { name: '', description: '', grants: formFromRole(source).grants }
initial.value = cloneForm(form.value)
loadedKey = 'new:'
await router.replace({ path: '/settings/roles/new' })
return
}
const id = Number(route.params.id)
if (!Number.isInteger(id)) {
loadState.value = 'missing'
return
}
const fetched = await api.getRole(id)
if (token !== loadToken) {
return
}
adopt(fetched)
}
async function load() {
loadedKey = routeKey()
const token = ++loadToken
loadState.value = 'loading'
lowered.value = false
serverLocked.value = false
serverOwnRole.value = false
conflict.value = NO_CONFLICT
const pending = [loadCatalogue()]
if (permissions.can('schema.view')) {
pending.push(contentTypes.load())
}
try {
await Promise.all([loadSource(token), ...pending])
if (token !== loadToken) {
return
}
if (loadState.value === 'loading') {
loadState.value = 'ready'
}
} catch (error) {
if (token !== loadToken) {
return
}
if (error instanceof ApiError && error.code === 'role_not_found') {
loadState.value = 'missing'
} else if (error instanceof ApiError && error.code === 'missing_permission') {
lowered.value = true
loadState.value = 'ready'
} else {
loadState.value = 'error'
}
}
}
watch(
() => [route.params.id, route.query.from],
() => {
if (routeKey() !== loadedKey) {
void load()
}
},
)
function changeCell(change: { action: ContentAction; scope: Scope; typeKey?: string }) {
form.value = {
...form.value,
grants: setScope(
form.value.grants,
contentKey(change.action),
change.scope,
change.typeKey,
),
}
}
function changeGeneral(change: { key: string; scope: Scope }) {
form.value = {
...form.value,
grants: setScope(form.value.grants, change.key, change.scope),
}
}
function choosePreset(next: PresetName) {
form.value = { ...form.value, grants: applyPreset(form.value.grants, next) }
}
function resetType(typeKey: string) {
form.value = { ...form.value, grants: resetRow(form.value.grants, typeKey) }
}
function setName(name: string) {
form.value = { ...form.value, name }
nameError.value = ''
}
function setDescription(description: string) {
form.value = { ...form.value, description }
}
function discard() {
form.value = cloneForm(initial.value)
nameError.value = ''
}
function conflictFrom(latest: RoleItem | null): ConflictState {
return {
title: t('This role changed while you were editing'),
description: t(
'{0} saved changes to {1} at {2}. Reload to see them, or review how they differ from your edits.',
[
latest?.updated_by?.name ?? t('Someone'),
latest?.name ?? form.value.name,
formatClock(latest?.updated_at, locale.value),
],
),
latest,
}
}
function reloadRole() {
if (conflict.value?.latest) {
adopt(conflict.value.latest)
return
}
void load()
}
async function reloadPage() {
await session.refresh()
lowered.value = false
await load()
}
function toastSuccess(id: string, title: string, description: string) {
toast.add(() => ({
id,
color: 'success',
icon: 'i-lucide-circle-check',
title,
description,
}))
}
function toastFailure() {
toast.add(() => ({
id: 'role-save-failed',
color: 'error',
icon: 'i-lucide-circle-x',
title: t("Couldn't save the role"),
description: t('Try again in a moment.'),
}))
}
function memberCountCopy(saved: RoleItem) {
return saved.members === 1
? t('{0} is updated for 1 member.', [saved.name])
: t('{0} is updated for {1} members.', [saved.name, saved.members])
}
function showRefusal(error: ApiError) {
const copy =
error.code === undefined ? undefined : refusalCopy(error.code, error.permission)
if (copy === undefined) {
toastFailure()
return
}
refusal.value = error.escalationKey
? {
title: copy.title,
description:
`${copy.description ?? ''} ${permissionName(error.escalationKey) ?? error.escalationKey}`.trim(),
}
: copy
}
function handleSaveError(error: unknown) {
if (error instanceof RoleConflictError) {
conflict.value = conflictFrom(error.latest)
return
}
if (!(error instanceof ApiError)) {
toastFailure()
return
}
switch (error.code) {
case 'invalid_role_name':
nameError.value = t('Give the role a name.')
return
case 'role_name_taken':
nameError.value = t('A role named "{0}" already exists. Choose a different name.', [
form.value.name.trim(),
])
return
case 'built_in_name':
nameError.value = t('Built-in roles keep their name.')
return
case 'missing_permission':
lowered.value = true
return
case 'role_locked':
serverLocked.value = true
showRefusal(error)
return
case 'own_role':
serverOwnRole.value = true
showRefusal(error)
return
default:
showRefusal(error)
}
}
async function save() {
if (!canSave.value) {
return
}
nameError.value = ''
refusal.value = null
if (nameMissing.value) {
nameError.value = t('Give the role a name.')
return
}
const payload = {
name: form.value.name.trim(),
description: form.value.description,
grants: grantsToPayload(form.value.grants, ownerOnlyKeys.value),
}
saving.value = true
try {
if (isNew.value) {
const created = await api.createRole(payload)
adopt(created)
loadedKey = String(created.id)
toastSuccess(
`role-created-${created.id}`,
t('Role created'),
t('{0} is ready. Assign it from Users.', [created.name]),
)
await router.replace(`/settings/roles/${created.id}`)
return
}
const updated = await api.updateRole(role.value!.id, payload, role.value!.version)
adopt(updated)
toastSuccess(`role-saved-${updated.id}`, t('Role saved'), memberCountCopy(updated))
} catch (error) {
handleSaveError(error)
} finally {
saving.value = false
}
}
function toastResetFailure() {
toast.add(() => ({
id: 'role-reset-failed',
color: 'error',
icon: 'i-lucide-circle-x',
title: t("Couldn't reset the role. Try again in a moment."),
}))
}
async function resetRole(): Promise<boolean> {
const target = role.value
if (!target) {
return true
}
try {
const restored = await api.resetRole(target.id, target.version)
adopt(restored)
toastSuccess(
`role-reset-${restored.id}`,
t('Role reset'),
t('{0} is back to its defaults.', [restored.name]),
)
} catch (error) {
if (error instanceof RoleConflictError) {
conflict.value = conflictFrom(error.latest)
} else if (error instanceof ApiError && error.code === 'missing_permission') {
lowered.value = true
} else if (error instanceof ApiError) {
showRefusal(error)
} else {
toastResetFailure()
return false
}
}
return true
}
function toggleReview() {
reviewing.value = !reviewing.value
}
function duplicate() {
if (role.value) {
void router.push({
path: '/settings/roles/new',
query: { from: String(role.value.id) },
})
}
}
return {
isNew,
loadState,
role,
form,
initial,
catalogue,
saving,
nameError,
conflict,
lowered,
refusal,
reviewing,
readOnlyReason,
readOnly,
unsaved,
problems,
nameMissing,
canSave,
barVisible,
preset,
canReset,
gridTypes,
preview,
differenceRows,
load,
changeCell,
changeGeneral,
choosePreset,
resetType,
setName,
setDescription,
discard,
save,
reloadRole,
reloadPage,
resetRole,
toggleReview,
duplicate,
}
}