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

+153
View File
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import RolePicker from '@/components/RolePicker.vue'
import { ApiError } from '@/composables/useApi'
import { useRoles } from '@/composables/useRoles'
import { refusalCopy } from '@/lib/permissionCopy'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import type { RoleItem } from '@/lib/roles'
const props = defineProps<{
role: RoleItem
roles: RoleItem[]
canAssign: (role: RoleItem) => boolean
}>()
const emit = defineEmits<{
deleted: [role: RoleItem]
gone: [role: RoleItem]
duplicate: [role: RoleItem]
}>()
const open = defineModel<boolean>('open', { default: true })
const { reassignRole, deleteRole } = useRoles()
const target = ref<number | undefined>(undefined)
const pending = ref(false)
const notice = ref<DirectoryNotice | null>(null)
const members = ref(props.role.members)
const stage = computed<'builtin' | 'blocked' | 'confirm'>(() => {
if (props.role.built_in) {
return 'builtin'
}
return members.value > 0 ? 'blocked' : 'confirm'
})
const title = computed(() => t('Delete {0}?', [props.role.name]))
const description = computed(() => {
switch (stage.value) {
case 'builtin':
return t("Built-in roles can't be deleted.")
case 'blocked':
return t(
'{0} members have this role. Move them to another role first. Then {1} can be deleted.',
[members.value, props.role.name],
)
default:
return t("No one has this role. This can't be undone.")
}
})
const confirmLabel = computed(() => {
if (stage.value === 'builtin') {
return t('Duplicate role')
}
return stage.value === 'blocked'
? t('Move {0} members and delete role', [members.value])
: t('Delete role')
})
const pendingLabel = computed(() =>
stage.value === 'blocked' ? t('Moving and deleting…') : t('Deleting…'),
)
function showRefusal(error: unknown) {
if (!(error instanceof ApiError)) {
notice.value = { description: t("Couldn't delete the role. Try again in a moment.") }
return
}
if (error.code === 'role_has_members') {
members.value = Math.max(members.value, 1)
target.value = undefined
return
}
if (error.code === 'role_not_found') {
emit('gone', props.role)
open.value = false
return
}
const copy = error.code === undefined ? undefined : refusalCopy(error.code, error.permission)
notice.value =
copy === undefined
? { description: t("Couldn't delete the role. Try again in a moment.") }
: { title: copy.title, description: copy.description ?? '' }
}
async function onConfirm() {
if (stage.value === 'builtin') {
emit('duplicate', props.role)
open.value = false
return
}
if (stage.value === 'blocked' && target.value === undefined) {
return
}
pending.value = true
notice.value = null
try {
if (stage.value === 'blocked' && target.value !== undefined) {
await reassignRole(props.role.id, target.value)
members.value = 0
}
await deleteRole(props.role.id)
emit('deleted', props.role)
open.value = false
} catch (error) {
showRefusal(error)
} finally {
pending.value = false
}
}
</script>
<template>
<DirectoryDialog
v-model:open="open"
:title="title"
:description="description"
:confirm-label="confirmLabel"
:pending-label="pendingLabel"
:cancel-label="stage === 'builtin' ? t('Close') : t('Cancel')"
:tone="stage === 'builtin' ? 'neutral' : 'danger'"
:pending="pending"
:notice="notice"
:confirm-disabled="stage === 'blocked' && target === undefined"
@confirm="onConfirm"
>
<template v-if="stage === 'blocked'">
<UFormField
:label="t('Move members to')"
:help="t('They keep their API keys. Keys follow the new role.')"
>
<RolePicker
v-model:selected="target"
:roles="roles"
:source-id="role.id"
:can-assign="canAssign"
/>
</UFormField>
</template>
<p v-else-if="stage === 'builtin'" class="flex flex-col gap-1 text-muted">
<span class="font-medium text-highlighted">{{
t('{0} is built in', [role.name])
}}</span>
<span>{{ t('Duplicate it to make a custom role you can change or delete.') }}</span>
</p>
<p v-else class="text-muted">{{ t("Deleting a role doesn't affect content.") }}</p>
</DirectoryDialog>
</template>