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('loading') const role = ref(null) const form = ref(emptyForm()) const initial = ref(emptyForm()) const catalogue = ref([]) const saving = ref(false) const nameError = ref('') const conflict = ref(NO_CONFLICT) const lowered = ref(false) const serverLocked = ref(false) const serverOwnRole = ref(false) const refusal = ref(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(() => { 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 { 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, } }