558 lines
13 KiB
TypeScript
558 lines
13 KiB
TypeScript
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,
|
|
}
|
|
}
|