refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types

This commit is contained in:
Florian Sylvain committed 2026-10-29 16:28:25 +01:00
1 parent 5d6f378fce
commit b39a41be88
124 files changed
+8816 -523

No files matched your search

+119 -11
View File
@@ -19,16 +19,24 @@ const emit = defineEmits<{
deleted: [role: RoleItem]
gone: [role: RoleItem]
duplicate: [role: RoleItem]
conflict: [role: RoleItem]
refreshed: [roles: RoleItem[]]
}>()
const open = defineModel<boolean>('open', { default: true })
const { reassignRole, deleteRole } = useRoles()
const { reassignRole, deleteRole, listRoles } = useRoles()
const target = ref<number | undefined>(undefined)
const pending = ref(false)
const notice = ref<DirectoryNotice | null>(null)
const members = ref(props.role.members)
const missingTargets = ref<number[]>([])
const gainedMembers = ref(false)
const targets = computed(() =>
props.roles.filter((item) => !missingTargets.value.includes(item.id)),
)
const stage = computed<'builtin' | 'blocked' | 'confirm'>(() => {
if (props.role.built_in) {
@@ -37,9 +45,14 @@ const stage = computed<'builtin' | 'blocked' | 'confirm'>(() => {
return members.value > 0 ? 'blocked' : 'confirm'
})
const deleteTop = computed(() => (notice.value ? 25 : 35))
const title = computed(() => t('Delete {0}?', [props.role.name]))
const description = computed(() => {
if (gainedMembers.value) {
return ''
}
switch (stage.value) {
case 'builtin':
return t("Built-in roles can't be deleted.")
@@ -58,7 +71,9 @@ const confirmLabel = computed(() => {
return t('Duplicate role')
}
return stage.value === 'blocked'
? t('Move {0} members and delete role', [members.value])
? members.value === 1
? t('Move 1 member and delete role')
: t('Move {0} members and delete role', [members.value])
: t('Delete role')
})
@@ -66,14 +81,65 @@ 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.") }
const GENERIC_FAILURE = "Couldn't delete the role. Try again in a moment."
async function liveMemberCount() {
try {
const roles = await listRoles()
const live = roles.find((item) => item.id === props.role.id)
return Math.max(live?.members ?? 0, 1)
} catch {
return Math.max(members.value, 1)
}
}
async function showGainedMembers() {
const count = await liveMemberCount()
members.value = count
gainedMembers.value = true
target.value = undefined
notice.value = {
tone: 'warning',
title: t('Role still has members'),
description:
count === 1
? t('{0} now has 1 member. Move them to another role first.', [props.role.name])
: t('{0} now has {1} members. Move them to another role first.', [
props.role.name,
count,
]),
}
}
async function showTargetGone() {
const missing = props.roles.find((item) => item.id === target.value)
if (missing) {
missingTargets.value = [...missingTargets.value, missing.id]
}
notice.value = {
title: t('{0} no longer exists', [missing?.name ?? '']),
description: t(
'It was deleted somewhere else. Choose another role to move the members to.',
),
}
target.value = undefined
try {
emit('refreshed', await listRoles())
} catch {
return
}
if (error.code === 'role_has_members') {
members.value = Math.max(members.value, 1)
target.value = undefined
}
function showMoveFailure() {
notice.value = {
title: t("Couldn't move the members"),
description: t('Nothing was changed. Try again in a moment.'),
}
}
function showRefusal(error: unknown) {
if (!(error instanceof ApiError)) {
notice.value = { description: t(GENERIC_FAILURE) }
return
}
if (error.code === 'role_not_found') {
@@ -81,13 +147,43 @@ function showRefusal(error: unknown) {
open.value = false
return
}
if (error.code === 'role_conflict') {
emit('conflict', 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.") }
? { description: t(GENERIC_FAILURE) }
: { title: copy.title, description: copy.description ?? '' }
}
async function showMoveRefusal(error: unknown) {
if (!(error instanceof ApiError)) {
showMoveFailure()
return
}
if (error.code === 'target_role_not_found') {
await showTargetGone()
return
}
if (error.code === 'invalid_reassign') {
target.value = undefined
showRefusal(error)
return
}
if (error.code === 'role_conflict' || error.code === 'role_not_found') {
showRefusal(error)
return
}
if (error.code === undefined || refusalCopy(error.code, error.permission) === undefined) {
showMoveFailure()
return
}
showRefusal(error)
}
async function onConfirm() {
if (stage.value === 'builtin') {
emit('duplicate', props.role)
@@ -101,13 +197,22 @@ async function onConfirm() {
notice.value = null
try {
if (stage.value === 'blocked' && target.value !== undefined) {
await reassignRole(props.role.id, target.value)
try {
await reassignRole(props.role.id, target.value)
} catch (error) {
await showMoveRefusal(error)
return
}
members.value = 0
}
await deleteRole(props.role.id)
emit('deleted', props.role)
open.value = false
} catch (error) {
if (error instanceof ApiError && error.code === 'role_has_members') {
await showGainedMembers()
return
}
showRefusal(error)
} finally {
pending.value = false
@@ -117,6 +222,7 @@ async function onConfirm() {
<template>
<DirectoryDialog
closable
v-model:open="open"
:title="title"
:description="description"
@@ -126,17 +232,19 @@ async function onConfirm() {
:tone="stage === 'builtin' ? 'neutral' : 'danger'"
:pending="pending"
:notice="notice"
:top="deleteTop"
:confirm-disabled="stage === 'blocked' && target === undefined"
@confirm="onConfirm"
>
<template v-if="stage === 'blocked'">
<UFormField
:label="t('Move members to')"
required
:help="t('They keep their API keys. Keys follow the new role.')"
>
<RolePicker
v-model:selected="target"
:roles="roles"
:roles="targets"
:source-id="role.id"
:can-assign="canAssign"
/>