mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
af8f55976f
Add a Style setting beside mode, accent, radius and background. Nostalgia restyles the app through tokens only (neutral surface palettes, system type, flat shell, top glow) and pins background, opacity and brightness. Add a top glow reach slider for both styles. Tidy the theme layer first: charts read live tokens, bare `rounded` follows the radius scale, one shared sign-in toolbar. Also carries the settings page header redesign.
563 lines
20 KiB
Vue
563 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { Search, TriangleAlert, UserPlus, X } from '@lucide/vue'
|
|
import { Permission, type DefaultLibraryAccessConfig, type UserListSortField, type UserListState } from '@bookorbit/types'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
|
import { api } from '@/lib/api'
|
|
import { formatNumber } from '@/i18n/formatters'
|
|
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
|
import { useAuth } from '@/features/auth/composables/useAuth'
|
|
import { useLoginOptions } from '@/features/auth/composables/useLoginOptions'
|
|
import { useUsers, type UserRow } from './composables/useUsers'
|
|
import { useSelfRegistration } from './composables/useSelfRegistration'
|
|
import UserFormDrawer from './UserFormDrawer.vue'
|
|
import ResetLinkModal from './ResetLinkModal.vue'
|
|
import NewAccountDefaults from './components/NewAccountDefaults.vue'
|
|
import UserAttentionBand from './components/UserAttentionBand.vue'
|
|
import UserRosterCards from './components/UserRosterCards.vue'
|
|
import UserRosterTable from './components/UserRosterTable.vue'
|
|
|
|
const HEADER_ACTIONS_TARGET = '#settings-header-actions'
|
|
|
|
const { t } = useI18n()
|
|
const { isSuperuser, hasPermission } = usePermissions()
|
|
const { user: currentUser } = useAuth()
|
|
const { loginOptions, fetchLoginOptions } = useLoginOptions()
|
|
const passwordLoginEnabled = computed(() => loginOptions.value?.passwordLoginEnabled === true)
|
|
|
|
const {
|
|
users,
|
|
libraries,
|
|
summary,
|
|
attention,
|
|
page,
|
|
totalPages,
|
|
total,
|
|
search,
|
|
state,
|
|
sortBy,
|
|
sortDir,
|
|
loading,
|
|
initialLoad,
|
|
error,
|
|
hasActiveFilters,
|
|
defaultLibraryIds,
|
|
defaultLibraryIdsArray,
|
|
hasDefaultLibraryChanges,
|
|
load,
|
|
loadStatic,
|
|
reload,
|
|
toggleSort,
|
|
setState,
|
|
resetFilters,
|
|
toggleDefaultLibrary,
|
|
markDefaultLibrariesSaved,
|
|
} = useUsers()
|
|
|
|
const drawerOpen = ref(false)
|
|
const editingUser = ref<UserRow | null>(null)
|
|
const resetUrl = ref<string | null>(null)
|
|
const deleteConfirmUser = ref<UserRow | null>(null)
|
|
const superuserConfirm = ref<{ user: UserRow; isSuperuser: boolean } | null>(null)
|
|
const deleting = ref(false)
|
|
const changingSuperuser = ref(false)
|
|
const superuserError = ref<string | null>(null)
|
|
const actionError = ref<string | null>(null)
|
|
const busyUserId = ref<number | null>(null)
|
|
/**
|
|
* The header slot only exists when this page is mounted inside the settings shell. Falling
|
|
* back to rendering in place keeps the primary action reachable anywhere else, including tests.
|
|
*/
|
|
const headerSlotAvailable = ref(false)
|
|
|
|
const savingDefaultLibraryAccess = ref(false)
|
|
const defaultLibraryAccessError = ref<string | null>(null)
|
|
|
|
const canManageUserDefaults = computed(() => hasPermission(Permission.ManageUsers))
|
|
const canManageAppSettings = computed(() => hasPermission(Permission.ManageAppSettings))
|
|
const showDefaults = computed(() => !initialLoad.value && !error.value && (canManageAppSettings.value || canManageUserDefaults.value))
|
|
|
|
const {
|
|
allowRegistration,
|
|
saving: savingSelfRegistration,
|
|
error: selfRegistrationError,
|
|
load: loadSelfRegistration,
|
|
setAllowRegistration,
|
|
} = useSelfRegistration()
|
|
|
|
const stateFilters = computed<{ value: UserListState | ''; label: string; count: number }[]>(() => [
|
|
{ value: '', label: t('adminFeature.usersPage.filters.allUsers'), count: summary.value.total },
|
|
{ value: 'admins', label: t('adminFeature.usersPage.filters.admins'), count: summary.value.admins },
|
|
{ value: 'active', label: t('adminFeature.usersPage.filters.active'), count: summary.value.active },
|
|
{ value: 'inactive', label: t('adminFeature.usersPage.filters.inactive'), count: summary.value.inactive },
|
|
])
|
|
|
|
const pageSubtitle = computed(() =>
|
|
t('adminFeature.usersPage.headerSummary', {
|
|
accounts: formatNumber(summary.value.total),
|
|
admins: formatNumber(summary.value.admins),
|
|
count: summary.value.total,
|
|
}),
|
|
)
|
|
|
|
const superuserDialogTitle = computed(() =>
|
|
superuserConfirm.value?.isSuperuser
|
|
? t('adminFeature.userForm.superuserAccess.promoteDialogTitle')
|
|
: t('adminFeature.userForm.superuserAccess.demoteDialogTitle'),
|
|
)
|
|
const superuserDialogDescription = computed(() => {
|
|
const pending = superuserConfirm.value
|
|
if (!pending) return ''
|
|
return pending.isSuperuser
|
|
? t('adminFeature.userForm.superuserAccess.promoteDialogBody', { name: pending.user.name })
|
|
: t('adminFeature.userForm.superuserAccess.demoteDialogBody', { name: pending.user.name })
|
|
})
|
|
const superuserConfirmLabel = computed(() => {
|
|
if (changingSuperuser.value) return t('adminFeature.userForm.superuserAccess.changing')
|
|
return superuserConfirm.value?.isSuperuser ? t('adminFeature.userForm.superuserAccess.promote') : t('adminFeature.userForm.superuserAccess.demote')
|
|
})
|
|
|
|
onMounted(async () => {
|
|
headerSlotAvailable.value = document.getElementById(HEADER_ACTIONS_TARGET.slice(1)) !== null
|
|
await Promise.all([reload(), loadStatic(), fetchLoginOptions().catch(() => undefined)])
|
|
if (canManageAppSettings.value) await loadSelfRegistration()
|
|
})
|
|
|
|
function clearSearch() {
|
|
search.value = ''
|
|
}
|
|
|
|
function clearFilters() {
|
|
resetFilters()
|
|
void load()
|
|
}
|
|
|
|
function showAttentionOnly() {
|
|
setState(state.value === 'attention' ? '' : 'attention')
|
|
}
|
|
|
|
function previousPage() {
|
|
if (page.value <= 1) return
|
|
page.value--
|
|
void load()
|
|
}
|
|
|
|
function nextPage() {
|
|
if (page.value >= totalPages.value) return
|
|
page.value++
|
|
void load()
|
|
}
|
|
|
|
function openCreate() {
|
|
editingUser.value = null
|
|
drawerOpen.value = true
|
|
}
|
|
|
|
function openEdit(user: UserRow) {
|
|
editingUser.value = user
|
|
drawerOpen.value = true
|
|
}
|
|
|
|
function openById(userId: number) {
|
|
const match = users.value.find((user) => user.id === userId)
|
|
if (match) openEdit(match)
|
|
}
|
|
|
|
const canDeleteEditingUser = computed(() => !!editingUser.value && canManage(editingUser.value))
|
|
|
|
/** Deleting from inside the editor closes it first: the confirm is the page's, and so is the list. */
|
|
function handleDrawerDelete() {
|
|
const user = editingUser.value
|
|
if (!user) return
|
|
drawerOpen.value = false
|
|
requestDeleteUser(user)
|
|
}
|
|
|
|
function closeDrawer() {
|
|
drawerOpen.value = false
|
|
}
|
|
|
|
function clearResetUrl() {
|
|
resetUrl.value = null
|
|
}
|
|
|
|
function canManage(user: UserRow): boolean {
|
|
return isSuperuser.value || !user.isSuperuser
|
|
}
|
|
|
|
function isPasswordResettable(user: UserRow): boolean {
|
|
return passwordLoginEnabled.value && user.provisioningMethod !== 'oidc' && user.provisioningMethod !== 'shared'
|
|
}
|
|
|
|
function isLocked(user: UserRow): boolean {
|
|
return Boolean(user.lockedUntil && new Date(user.lockedUntil).getTime() > Date.now())
|
|
}
|
|
|
|
/** Mirrors the server's attention predicate so the row marker agrees with the band. */
|
|
function needsAttention(user: UserRow): boolean {
|
|
if (!user.active) return false
|
|
return isLocked(user) || user.lastAuthenticatedAt === null || user.isDefaultPassword
|
|
}
|
|
|
|
async function handleUnlock(userId: number) {
|
|
actionError.value = null
|
|
busyUserId.value = userId
|
|
try {
|
|
const res = await api(`/api/v1/users/${userId}/unlock`, { method: 'POST' })
|
|
if (!res.ok) {
|
|
actionError.value = t('adminFeature.usersPage.errors.unlock')
|
|
return
|
|
}
|
|
await reload()
|
|
} finally {
|
|
busyUserId.value = null
|
|
}
|
|
}
|
|
|
|
async function handleResetPassword(userId: number) {
|
|
actionError.value = null
|
|
busyUserId.value = userId
|
|
try {
|
|
const res = await api(`/api/v1/users/${userId}/reset-password`, { method: 'POST' })
|
|
if (!res.ok) {
|
|
actionError.value = t('adminFeature.usersPage.errors.resetPassword')
|
|
return
|
|
}
|
|
const data = await res.json()
|
|
resetUrl.value = data.resetUrl
|
|
await reload()
|
|
} finally {
|
|
busyUserId.value = null
|
|
}
|
|
}
|
|
|
|
function unlockUser(user: UserRow) {
|
|
void handleUnlock(user.id)
|
|
}
|
|
|
|
function resetUserPassword(user: UserRow) {
|
|
void handleResetPassword(user.id)
|
|
}
|
|
|
|
function requestDeleteUser(user: UserRow) {
|
|
actionError.value = null
|
|
deleteConfirmUser.value = user
|
|
}
|
|
|
|
function cancelDeleteUser() {
|
|
deleteConfirmUser.value = null
|
|
}
|
|
|
|
function requestSuperuserChange(nextIsSuperuser: boolean) {
|
|
if (
|
|
!isSuperuser.value ||
|
|
currentUser.value?.id === undefined ||
|
|
!editingUser.value?.id ||
|
|
editingUser.value.id === currentUser.value?.id ||
|
|
editingUser.value.provisioningMethod === 'shared'
|
|
) {
|
|
return
|
|
}
|
|
superuserError.value = null
|
|
superuserConfirm.value = { user: editingUser.value as UserRow, isSuperuser: nextIsSuperuser }
|
|
}
|
|
|
|
function cancelSuperuserChange() {
|
|
if (changingSuperuser.value) return
|
|
superuserConfirm.value = null
|
|
superuserError.value = null
|
|
}
|
|
|
|
async function confirmSuperuserChange() {
|
|
const pending = superuserConfirm.value
|
|
if (!pending || changingSuperuser.value) return
|
|
changingSuperuser.value = true
|
|
superuserError.value = null
|
|
try {
|
|
const res = await api(`/api/v1/users/${pending.user.id}/superuser`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ isSuperuser: pending.isSuperuser }),
|
|
})
|
|
if (!res.ok) {
|
|
superuserError.value = t('adminFeature.userForm.superuserAccess.changeError')
|
|
return
|
|
}
|
|
superuserConfirm.value = null
|
|
drawerOpen.value = false
|
|
await reload()
|
|
} catch {
|
|
superuserError.value = t('adminFeature.userForm.superuserAccess.changeError')
|
|
} finally {
|
|
changingSuperuser.value = false
|
|
}
|
|
}
|
|
|
|
async function confirmDeleteUser() {
|
|
if (!deleteConfirmUser.value || deleting.value) return
|
|
deleting.value = true
|
|
const user = deleteConfirmUser.value
|
|
try {
|
|
const res = await api(`/api/v1/users/${user.id}`, { method: 'DELETE' })
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
actionError.value = data.message ?? t('adminFeature.usersPage.errors.deleteUser')
|
|
return
|
|
}
|
|
deleteConfirmUser.value = null
|
|
await reload()
|
|
} catch {
|
|
actionError.value = t('adminFeature.usersPage.errors.deleteUser')
|
|
} finally {
|
|
deleting.value = false
|
|
}
|
|
}
|
|
|
|
async function onSaved(newResetUrl?: string) {
|
|
drawerOpen.value = false
|
|
if (newResetUrl) resetUrl.value = newResetUrl
|
|
await reload()
|
|
}
|
|
|
|
async function toggleSelfRegistration() {
|
|
if (savingSelfRegistration.value) return
|
|
await setAllowRegistration(!allowRegistration.value)
|
|
}
|
|
|
|
function handleSort(field: UserListSortField) {
|
|
toggleSort(field)
|
|
}
|
|
|
|
function selectState(value: UserListState | '') {
|
|
setState(value)
|
|
}
|
|
|
|
async function saveDefaultLibraryAccess() {
|
|
if (savingDefaultLibraryAccess.value) return
|
|
savingDefaultLibraryAccess.value = true
|
|
defaultLibraryAccessError.value = null
|
|
try {
|
|
const res = await api('/api/v1/app-settings/default-library-access', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ libraryIds: defaultLibraryIdsArray.value }),
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
defaultLibraryAccessError.value = data.message ?? t('adminFeature.usersPage.defaultLibraryAccess.saveError')
|
|
return
|
|
}
|
|
const saved = (await res.json()) as DefaultLibraryAccessConfig
|
|
markDefaultLibrariesSaved(saved.libraryIds ?? [])
|
|
} catch {
|
|
defaultLibraryAccessError.value = t('adminFeature.usersPage.defaultLibraryAccess.saveError')
|
|
} finally {
|
|
savingDefaultLibraryAccess.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-4">
|
|
<Teleport :to="HEADER_ACTIONS_TARGET" defer :disabled="!headerSlotAvailable">
|
|
<p class="hidden text-sm text-muted-foreground lg:block">{{ pageSubtitle }}</p>
|
|
<Button size="sm" type="button" @click="openCreate">
|
|
<UserPlus :size="14" aria-hidden="true" />
|
|
{{ passwordLoginEnabled ? t('adminFeature.usersPage.createUser') : t('adminFeature.userForm.createSharedAccount') }}
|
|
</Button>
|
|
</Teleport>
|
|
|
|
<p v-if="error" role="alert" class="settings-error-state">{{ t('adminFeature.usersPage.errors.load') }}</p>
|
|
|
|
<template v-else>
|
|
<UserAttentionBand
|
|
v-if="attention.length > 0"
|
|
:items="attention"
|
|
:total="summary.attention"
|
|
:busy-user-id="busyUserId"
|
|
:password-login-enabled="passwordLoginEnabled"
|
|
@unlock="handleUnlock"
|
|
@send-reset-link="handleResetPassword"
|
|
@open="openById"
|
|
/>
|
|
|
|
<p v-if="actionError" role="alert" class="settings-error-state">{{ actionError }}</p>
|
|
|
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-center">
|
|
<label class="relative block min-w-0 flex-1 lg:order-1">
|
|
<span class="sr-only">{{ t('adminFeature.usersPage.filters.search') }}</span>
|
|
<Search :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
|
<input
|
|
v-model="search"
|
|
type="search"
|
|
class="input-field h-9 w-full ps-9 pe-9"
|
|
:placeholder="t('adminFeature.usersPage.filters.searchPlaceholder')"
|
|
/>
|
|
<button
|
|
v-if="search"
|
|
type="button"
|
|
class="absolute end-2 top-1/2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
:aria-label="t('adminFeature.usersPage.filters.clear')"
|
|
@click="clearSearch"
|
|
>
|
|
<X :size="14" aria-hidden="true" />
|
|
</button>
|
|
</label>
|
|
|
|
<div class="flex min-w-0 items-center gap-2 lg:order-2 lg:shrink-0">
|
|
<div
|
|
role="group"
|
|
:aria-label="t('adminFeature.usersPage.filters.state')"
|
|
class="flex min-w-0 gap-0.5 overflow-x-auto rounded-lg border border-border bg-muted p-0.5"
|
|
>
|
|
<button
|
|
v-for="filter in stateFilters"
|
|
:key="filter.value || 'all'"
|
|
type="button"
|
|
class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
:class="state === filter.value ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
|
:aria-pressed="state === filter.value"
|
|
@click="selectState(filter.value)"
|
|
>
|
|
{{ filter.label }}
|
|
<span
|
|
class="rounded-xs px-1 text-xs tabular-nums"
|
|
:class="state === filter.value ? 'bg-primary/15 text-primary' : 'bg-foreground/8 text-muted-foreground'"
|
|
>
|
|
{{ formatNumber(filter.count) }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<Button
|
|
v-if="summary.attention > 0"
|
|
variant="outline"
|
|
size="sm"
|
|
type="button"
|
|
class="h-9 shrink-0 border-[var(--pill-warning)]/40 text-[var(--pill-warning)] hover:text-[var(--pill-warning)]"
|
|
:aria-pressed="state === 'attention'"
|
|
:aria-label="t('adminFeature.usersPage.attention.filter', { count: summary.attention })"
|
|
@click="showAttentionOnly"
|
|
>
|
|
<TriangleAlert :size="14" aria-hidden="true" />
|
|
<span class="sm:hidden">{{ formatNumber(summary.attention) }}</span>
|
|
<span class="hidden sm:inline">{{ t('adminFeature.usersPage.attention.filter', { count: summary.attention }) }}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="initialLoad" role="status" class="settings-loading-state">{{ t('common.loading') }}</p>
|
|
|
|
<div v-else-if="users.length === 0" class="settings-empty-state">
|
|
<p class="text-sm font-medium text-foreground">{{ t('adminFeature.usersPage.empty.title') }}</p>
|
|
<p class="mt-1 text-sm text-muted-foreground">
|
|
{{ hasActiveFilters ? t('adminFeature.usersPage.empty.filtered') : t('adminFeature.usersPage.empty.description') }}
|
|
</p>
|
|
<Button v-if="hasActiveFilters" variant="outline" size="sm" type="button" class="mt-3" @click="clearFilters">
|
|
{{ t('adminFeature.usersPage.filters.clear') }}
|
|
</Button>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div :class="loading ? 'opacity-60 transition-opacity' : 'transition-opacity'" :aria-busy="loading">
|
|
<UserRosterTable
|
|
:users="users"
|
|
:library-total="libraries.length"
|
|
:sort-by="sortBy"
|
|
:sort-dir="sortDir"
|
|
:can-manage="canManage"
|
|
:is-locked="isLocked"
|
|
:is-resettable="isPasswordResettable"
|
|
:needs-attention="needsAttention"
|
|
:password-login-enabled="passwordLoginEnabled"
|
|
@sort="handleSort"
|
|
@edit="openEdit"
|
|
@unlock="unlockUser"
|
|
@reset-password="resetUserPassword"
|
|
@remove="requestDeleteUser"
|
|
/>
|
|
<UserRosterCards
|
|
:users="users"
|
|
:library-total="libraries.length"
|
|
:can-manage="canManage"
|
|
:needs-attention="needsAttention"
|
|
@open="openEdit"
|
|
/>
|
|
</div>
|
|
|
|
<nav :aria-label="t('adminFeature.usersPage.pagination.label')" class="flex items-center justify-between gap-3">
|
|
<p class="text-sm text-muted-foreground">
|
|
{{ t('adminFeature.usersPage.pagination.showing', { shown: formatNumber(users.length), total: formatNumber(total) }) }}
|
|
</p>
|
|
<div v-if="totalPages > 1" class="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" type="button" :disabled="page <= 1" @click="previousPage">{{ t('common.previous') }}</Button>
|
|
<span class="text-sm tabular-nums text-muted-foreground">
|
|
{{ t('adminFeature.usersPage.pagination.page', { page: formatNumber(page), totalPages: formatNumber(totalPages) }) }}
|
|
</span>
|
|
<Button variant="outline" size="sm" type="button" :disabled="page >= totalPages" @click="nextPage">{{ t('common.next') }}</Button>
|
|
</div>
|
|
</nav>
|
|
</template>
|
|
</template>
|
|
|
|
<NewAccountDefaults
|
|
v-if="showDefaults"
|
|
:show-self-registration="canManageAppSettings && passwordLoginEnabled"
|
|
:show-library-defaults="canManageUserDefaults"
|
|
:allow-registration="allowRegistration"
|
|
:saving-self-registration="savingSelfRegistration"
|
|
:self-registration-error="selfRegistrationError"
|
|
:libraries="libraries"
|
|
:selected-library-ids="defaultLibraryIds"
|
|
:saving-library-defaults="savingDefaultLibraryAccess"
|
|
:library-defaults-error="defaultLibraryAccessError"
|
|
:has-library-changes="hasDefaultLibraryChanges"
|
|
@toggle-self-registration="toggleSelfRegistration"
|
|
@toggle-library="toggleDefaultLibrary"
|
|
@save-library-defaults="saveDefaultLibraryAccess"
|
|
/>
|
|
|
|
<UserFormDrawer
|
|
v-if="drawerOpen"
|
|
:user="editingUser"
|
|
:libraries="libraries"
|
|
:default-library-ids="defaultLibraryIdsArray"
|
|
:can-manage-superuser="isSuperuser"
|
|
:current-user-id="currentUser?.id"
|
|
:can-delete="canDeleteEditingUser"
|
|
:password-login-enabled="passwordLoginEnabled"
|
|
@close="closeDrawer"
|
|
@saved="onSaved"
|
|
@request-superuser-change="requestSuperuserChange"
|
|
@delete="handleDrawerDelete"
|
|
/>
|
|
<ResetLinkModal v-if="resetUrl" :reset-url="resetUrl" @close="clearResetUrl" />
|
|
|
|
<ConfirmDialog
|
|
:open="deleteConfirmUser !== null"
|
|
:title="t('adminFeature.usersPage.deleteDialogTitle')"
|
|
:description="t('adminFeature.usersPage.deleteDialogBody', { username: deleteConfirmUser?.username ?? '' })"
|
|
:confirm-label="deleting ? t('adminFeature.usersPage.deleting') : t('common.delete')"
|
|
:busy="deleting"
|
|
@confirm="confirmDeleteUser"
|
|
@cancel="cancelDeleteUser"
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
:open="superuserConfirm !== null"
|
|
:destructive="superuserConfirm?.isSuperuser === false"
|
|
:title="superuserDialogTitle"
|
|
:description="superuserDialogDescription"
|
|
:confirm-label="superuserConfirmLabel"
|
|
:busy="changingSuperuser"
|
|
@confirm="confirmSuperuserChange"
|
|
@cancel="cancelSuperuserChange"
|
|
>
|
|
<p v-if="superuserError" role="alert" class="mt-3 text-sm text-destructive">{{ superuserError }}</p>
|
|
</ConfirmDialog>
|
|
</div>
|
|
</template>
|