Files
dbx/apps/desktop/src/components/admin/DamengRoleAdmin.vue
T

537 lines
25 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Loader2, Plus, RefreshCcw, ShieldCheck, Trash2, UserRoundPlus, UsersRound } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { translateBackendError } from "@/i18n/backend-errors";
import {
DAMENG_SYSTEM_PRIVILEGES,
damengCreateRoleSql,
damengDropRoleSql,
damengGrantRoleSql,
damengGrantSystemPrivilegeSql,
damengListRoleMembersSql,
damengListRoleSysPrivsSql,
damengListRolesSql,
damengListUsersSql,
damengRevokeRoleSql,
damengRevokeSystemPrivilegeSql,
damengSystemPrivilegeMapSql,
damengEnableDdlAnyPrivSql,
isDamengAnyPrivilege,
parseDamengEnableDdlAnyPriv,
isDamengSystemUser,
isDamengPredefinedRole,
parseDamengRolePrivs,
parseDamengRoles,
parseDamengSysPrivs,
parseDamengUsers,
parseDamengSystemPrivilegeMap,
damengAvailableSystemPrivileges,
type DamengGrant,
type DamengRole,
type DamengSysPrivilege,
type DamengUser,
} from "@/lib/database/damengPrincipalAdmin";
const props = defineProps<{
connection: ConnectionConfig;
}>();
type DetailTab = "members" | "privileges";
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const roles = ref<DamengRole[]>([]);
const allUsers = ref<DamengUser[]>([]);
const selectedRoleName = ref("");
const search = ref("");
const loadingRoles = ref(false);
const loadingDetails = ref(false);
const applying = ref(false);
const loadError = ref("");
const detailError = ref("");
const detailTab = ref<DetailTab>("members");
const members = ref<DamengGrant[]>([]);
const grantedPrivileges = ref<DamengSysPrivilege[]>([]);
const systemPrivilegeMap = ref<Set<string> | null>(null);
const enableDdlAnyPriv = ref(true);
const createDialogOpen = ref(false);
const createRoleName = ref("");
const grantUserDialogOpen = ref(false);
const grantUserValue = ref("");
const grantPrivilegeDialogOpen = ref(false);
const grantPrivilegeValue = ref("");
const previewDialogOpen = ref(false);
const pendingSql = ref("");
const pendingAfterApply = ref<(() => void | Promise<void>) | undefined>();
const pendingDanger = ref(false);
const supported = computed(() => props.connection.db_type === "dameng");
const executionDatabase = computed(() => (props.connection.db_type === "dameng" ? "" : props.connection.database || ""));
const selectedRole = computed(() => roles.value.find((role) => role.role === selectedRoleName.value));
const selectedIsBuiltinRole = computed(() => !!selectedRole.value && isDamengPredefinedRole(selectedRole.value.role));
const filteredRoles = computed(() => {
const query = search.value.trim().toLowerCase();
if (!query) return roles.value;
return roles.value.filter((role) => role.role.toLowerCase().includes(query));
});
const availableUsersForGrant = computed(() => {
const granted = new Set(members.value.map((member) => member.grantee.toUpperCase()));
// System users are protected: not offered for grant.
return allUsers.value.filter((user) => !granted.has(user.username.toUpperCase()) && !isDamengSystemUser(user.username));
});
const availablePrivilegesForGrant = computed(() => {
const granted = new Set(grantedPrivileges.value.map((grant) => grant.privilege.toUpperCase()));
const catalog = systemPrivilegeMap.value ? damengAvailableSystemPrivileges(DAMENG_SYSTEM_PRIVILEGES, systemPrivilegeMap.value) : DAMENG_SYSTEM_PRIVILEGES;
return catalog.filter((privilege) => !granted.has(privilege.toUpperCase()));
});
const canCreateRole = computed(() => createRoleName.value.trim() !== "");
async function ensureConnection() {
await connectionStore.ensureConnected(props.connection.id);
}
function isDdlAnyPrivDenied(message: string): boolean {
// DM8 error -5567: "Grantor has no granted privilege" / "授权者没有此授权权限",
// typically because ENABLE_DDL_ANY_PRIV=0 blocks granting ANY-type privileges.
return /授权者没有此授权权限|Grantor no granted privilege|[-]?5567/i.test(message);
}
function formatApplyFailedMessage(message: string): string {
return isDdlAnyPrivDenied(message) ? `${message}\n${t("damengRoleAdmin.enableDdlAnyPrivHint")}` : message;
}
function isPrivilegeGrantDisabled(privilege: string): boolean {
return !enableDdlAnyPriv.value && isDamengAnyPrivilege(privilege);
}
async function loadRoles() {
if (!supported.value) return;
loadingRoles.value = true;
loadError.value = "";
try {
await ensureConnection();
const [rolesResult, usersResult, privilegeMapResult, ddlAnyPrivResult] = await Promise.all([
api.executeQuery(props.connection.id, executionDatabase.value, damengListRolesSql(), undefined, undefined, { maxRows: 5000 }),
api.executeQuery(props.connection.id, executionDatabase.value, damengListUsersSql(), undefined, undefined, { maxRows: 5000 }),
// SYSTEM_PRIVILEGE_MAP may be unreadable for non-privileged accounts; fall back to the full catalog.
api.executeQuery(props.connection.id, executionDatabase.value, damengSystemPrivilegeMapSql(), undefined, undefined, { maxRows: 5000 }).catch(() => undefined),
// DM8 default disables ANY-type privilege grants (ENABLE_DDL_ANY_PRIV=0); keep the default enabled on failure.
api.executeQuery(props.connection.id, executionDatabase.value, damengEnableDdlAnyPrivSql(), undefined, undefined, { maxRows: 10 }).catch(() => undefined),
]);
roles.value = parseDamengRoles(rolesResult);
allUsers.value = parseDamengUsers(usersResult);
systemPrivilegeMap.value = privilegeMapResult ? parseDamengSystemPrivilegeMap(privilegeMapResult) : null;
enableDdlAnyPriv.value = ddlAnyPrivResult ? parseDamengEnableDdlAnyPriv(ddlAnyPrivResult) : true;
if (!selectedRole.value) selectedRoleName.value = roles.value[0]?.role || "";
await loadDetails();
} catch (error: any) {
loadError.value = error?.message || String(error);
roles.value = [];
members.value = [];
grantedPrivileges.value = [];
} finally {
loadingRoles.value = false;
}
}
let detailRequestSeq = 0;
async function loadDetails() {
const role = selectedRole.value;
if (!role) {
members.value = [];
grantedPrivileges.value = [];
return;
}
const seq = ++detailRequestSeq;
loadingDetails.value = true;
detailError.value = "";
try {
await ensureConnection();
const [membersResult, privsResult] = await Promise.all([
api.executeQuery(props.connection.id, executionDatabase.value, damengListRoleMembersSql(role.role), undefined, undefined, { maxRows: 1000 }),
api.executeQuery(props.connection.id, executionDatabase.value, damengListRoleSysPrivsSql(role.role), undefined, undefined, { maxRows: 1000 }),
]);
if (seq !== detailRequestSeq) return; // stale response for a previous selection
members.value = parseDamengRolePrivs(membersResult);
grantedPrivileges.value = parseDamengSysPrivs(privsResult);
} catch (error: any) {
if (seq !== detailRequestSeq) return; // stale response for a previous selection
detailError.value = error?.message || String(error);
members.value = [];
grantedPrivileges.value = [];
} finally {
if (seq === detailRequestSeq) loadingDetails.value = false;
}
}
function selectRole(role: DamengRole) {
selectedRoleName.value = role.role;
void loadDetails();
}
function previewSql(sql: string, options: { danger?: boolean; afterApply?: () => void | Promise<void> } = {}) {
pendingSql.value = sql;
pendingDanger.value = !!options.danger;
pendingAfterApply.value = options.afterApply;
previewDialogOpen.value = true;
}
async function applyPendingSql() {
if (!pendingSql.value.trim()) return;
applying.value = true;
try {
await ensureConnection();
const results = await executeWithProductionSqlGuard({
connection: props.connection,
database: executionDatabase.value,
sql: pendingSql.value,
source: t("production.sourceAdmin"),
execute: () =>
api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
maxRows: 1000,
useTransaction: true,
}),
});
if (!results) return;
// Per-statement errors are returned inside the result array (HTTP 200), so
// they must be surfaced explicitly instead of being treated as success.
const failed = results.find((result) => !!result.error);
if (failed) {
toast(t("damengRoleAdmin.applyFailed", { message: formatApplyFailedMessage(translateBackendError(t, failed.error)) }), 5000);
// Statements before the error may already be committed, so refresh regardless.
await loadRoles();
return;
}
toast(t("damengRoleAdmin.applySuccess"), 2500);
previewDialogOpen.value = false;
await (pendingAfterApply.value?.() ?? Promise.resolve());
await loadRoles();
} catch (error: any) {
toast(t("damengRoleAdmin.applyFailed", { message: formatApplyFailedMessage(error?.message || String(error)) }), 5000);
} finally {
applying.value = false;
}
}
function openCreateDialog() {
createRoleName.value = "";
createDialogOpen.value = true;
}
function previewCreateRole() {
if (!canCreateRole.value) return;
previewSql(damengCreateRoleSql(createRoleName.value), {
afterApply: () => {
createDialogOpen.value = false;
},
});
}
function previewDropRole() {
const role = selectedRole.value;
if (!role || selectedIsBuiltinRole.value) return;
previewSql(damengDropRoleSql(role.role), { danger: true });
}
function openGrantUserDialog() {
grantUserValue.value = "";
grantUserDialogOpen.value = true;
}
function previewGrantUser() {
const role = selectedRole.value;
if (!role || !grantUserValue.value) return;
previewSql(damengGrantRoleSql(grantUserValue.value, role.role), {
afterApply: () => {
grantUserDialogOpen.value = false;
},
});
}
function previewRevokeMember(member: DamengGrant) {
const role = selectedRole.value;
if (!role) return;
previewSql(damengRevokeRoleSql(member.grantee, role.role), { danger: true });
}
function openGrantPrivilegeDialog() {
grantPrivilegeValue.value = "";
grantPrivilegeDialogOpen.value = true;
}
function previewGrantPrivilege() {
const role = selectedRole.value;
if (!role || !grantPrivilegeValue.value) return;
previewSql(damengGrantSystemPrivilegeSql(role.role, grantPrivilegeValue.value), {
afterApply: () => {
grantPrivilegeDialogOpen.value = false;
},
});
}
function previewRevokePrivilege(grant: DamengSysPrivilege) {
const role = selectedRole.value;
if (!role) return;
previewSql(damengRevokeSystemPrivilegeSql(role.role, grant.privilege), { danger: true });
}
function isSystemMember(grantee: string): boolean {
return isDamengSystemUser(grantee);
}
onMounted(() => {
void loadRoles();
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<div class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<ShieldCheck class="h-4 w-4 text-primary" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold">{{ t("damengRoleAdmin.title") }}</div>
<div class="truncate text-[11px] text-muted-foreground">{{ connection.name }}</div>
</div>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loadingRoles" @click="loadRoles">
<Loader2 v-if="loadingRoles" class="h-3.5 w-3.5 animate-spin" />
<RefreshCcw v-else class="h-3.5 w-3.5" />
{{ t("contextMenu.refreshChildren") }}
</Button>
<Button size="sm" class="h-8 gap-1.5" @click="openCreateDialog">
<Plus class="h-3.5 w-3.5" />
{{ t("damengRoleAdmin.newRole") }}
</Button>
</div>
<div v-if="!supported" class="m-4 rounded border border-dashed p-4 text-sm text-muted-foreground">
{{ t("damengRoleAdmin.unsupported") }}
</div>
<div v-else class="grid min-h-0 flex-1 grid-cols-[300px_minmax(0,1fr)]">
<aside class="flex min-h-0 flex-col border-r">
<div class="border-b p-2">
<Input v-model="search" class="h-8 text-xs" :placeholder="t('damengRoleAdmin.searchRole')" />
</div>
<div v-if="loadError" class="m-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
<div class="mb-1 flex items-center gap-1 font-medium"><AlertTriangle class="h-3.5 w-3.5" />{{ t("damengRoleAdmin.loadFailed") }}</div>
<div class="break-all">{{ loadError }}</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-2">
<button v-for="role in filteredRoles" :key="role.role" type="button" class="mb-1 w-full rounded border px-2 py-2 text-left text-xs transition hover:bg-accent" :class="selectedRoleName === role.role ? 'border-primary bg-primary/10' : 'border-transparent'" @click="selectRole(role)">
<div class="flex items-center gap-2">
<span class="min-w-0 flex-1 truncate font-medium">{{ role.role }}</span>
<Badge v-if="role.passwordRequired === 'YES'" variant="outline" class="h-5 px-1.5 text-[10px]">PWD</Badge>
<Badge v-if="isDamengPredefinedRole(role.role)" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">{{ t("damengRoleAdmin.builtinRole") }}</Badge>
</div>
</button>
<div v-if="!loadingRoles && filteredRoles.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("damengRoleAdmin.emptyRoles") }}
</div>
</div>
</aside>
<main class="flex min-h-0 flex-col">
<div v-if="selectedRole" class="flex shrink-0 flex-wrap items-center gap-2 border-b p-3">
<ShieldCheck class="h-4 w-4 text-primary" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-semibold">{{ selectedRole.role }}</span>
</div>
</div>
<Button size="sm" variant="destructive" class="h-8 gap-1.5" :disabled="selectedIsBuiltinRole" :title="selectedIsBuiltinRole ? t('damengRoleAdmin.builtinRoleProtected') : ''" @click="previewDropRole">
<Trash2 class="h-3.5 w-3.5" />
{{ t("damengRoleAdmin.dropRole") }}
</Button>
</div>
<div v-if="selectedRole" class="flex h-10 shrink-0 items-center gap-1 border-b px-3">
<Button size="sm" :variant="detailTab === 'members' ? 'secondary' : 'ghost'" class="h-7 px-2 text-xs" @click="detailTab = 'members'">{{ t("damengRoleAdmin.members") }}</Button>
<Button size="sm" :variant="detailTab === 'privileges' ? 'secondary' : 'ghost'" class="h-7 px-2 text-xs" @click="detailTab = 'privileges'">{{ t("damengRoleAdmin.systemPrivileges") }}</Button>
<Loader2 v-if="loadingDetails" class="ml-auto h-3.5 w-3.5 animate-spin text-muted-foreground" />
</div>
<div class="min-h-0 flex-1 overflow-auto p-3">
<div v-if="!selectedRole" class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ t("damengRoleAdmin.selectRole") }}
</div>
<div v-else-if="detailError" class="rounded border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<div class="mb-1 flex items-center gap-1 font-medium"><AlertTriangle class="h-3.5 w-3.5" />{{ t("damengRoleAdmin.loadFailed") }}</div>
<div class="break-all">{{ detailError }}</div>
</div>
<div v-else-if="detailTab === 'members'">
<div class="mb-2 flex items-center gap-2">
<span class="text-xs font-medium">{{ t("damengRoleAdmin.grantedToUsers") }} ({{ members.length }})</span>
<Button size="sm" variant="outline" class="ml-auto h-7 gap-1 px-2 text-xs" :disabled="availableUsersForGrant.length === 0" :title="availableUsersForGrant.length === 0 ? t('damengRoleAdmin.noUsersToGrant') : ''" @click="openGrantUserDialog">
<UserRoundPlus class="h-3.5 w-3.5" />
{{ t("damengRoleAdmin.grantToUser") }}
</Button>
</div>
<div class="grid gap-1.5">
<div v-for="member in members" :key="member.grantee" class="flex items-center gap-2 rounded border bg-muted/20 px-3 py-2">
<UsersRound class="h-3.5 w-3.5 shrink-0 text-primary" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ member.grantee }}</span>
<Badge v-if="isSystemMember(member.grantee)" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">{{ t("damengRoleAdmin.systemUser") }}</Badge>
<Badge v-if="isDamengPredefinedRole(member.grantee)" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">{{ t("damengRoleAdmin.builtinRole") }}</Badge>
<Badge v-if="member.adminOption" variant="outline" class="h-5 px-1.5 text-[10px]">ADMIN OPTION</Badge>
<Button
size="sm"
variant="ghost"
class="h-6 gap-1 px-2 text-xs text-destructive hover:text-destructive"
:disabled="isSystemMember(member.grantee) || (selectedIsBuiltinRole && isDamengPredefinedRole(member.grantee))"
:title="isSystemMember(member.grantee) ? t('damengRoleAdmin.systemUserProtected') : selectedIsBuiltinRole && isDamengPredefinedRole(member.grantee) ? t('damengRoleAdmin.builtinRoleProtected') : ''"
@click="previewRevokeMember(member)"
>
<Trash2 class="h-3 w-3" />
{{ t("damengRoleAdmin.revokeFromUser") }}
</Button>
</div>
<div v-if="members.length === 0" class="rounded border border-dashed p-6 text-center text-xs text-muted-foreground">
{{ t("damengRoleAdmin.noMembers") }}
</div>
</div>
</div>
<div v-else>
<div class="mb-2 flex items-center gap-2">
<span class="text-xs font-medium">{{ t("damengRoleAdmin.grantedPrivileges") }} ({{ grantedPrivileges.length }})</span>
<Button
size="sm"
variant="outline"
class="ml-auto h-7 gap-1 px-2 text-xs"
:disabled="selectedIsBuiltinRole || availablePrivilegesForGrant.length === 0"
:title="selectedIsBuiltinRole ? t('damengRoleAdmin.builtinRoleProtected') : availablePrivilegesForGrant.length === 0 ? t('damengRoleAdmin.noPrivilegesToGrant') : ''"
@click="openGrantPrivilegeDialog"
>
<UserRoundPlus class="h-3.5 w-3.5" />
{{ t("damengRoleAdmin.grantPrivilege") }}
</Button>
</div>
<div class="grid gap-1.5">
<div v-for="grant in grantedPrivileges" :key="grant.privilege" class="flex items-center gap-2 rounded border bg-muted/20 px-3 py-2">
<ShieldCheck class="h-3.5 w-3.5 shrink-0 text-primary" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ grant.privilege }}</span>
<Badge v-if="grant.adminOption" variant="outline" class="h-5 px-1.5 text-[10px]">ADMIN OPTION</Badge>
<Button
size="sm"
variant="ghost"
class="h-6 gap-1 px-2 text-xs text-destructive hover:text-destructive"
:disabled="selectedIsBuiltinRole || isPrivilegeGrantDisabled(grant.privilege)"
:title="selectedIsBuiltinRole ? t('damengRoleAdmin.builtinRoleProtected') : isPrivilegeGrantDisabled(grant.privilege) ? t('damengRoleAdmin.anyPrivilegeDisabled') : ''"
@click="previewRevokePrivilege(grant)"
>
<Trash2 class="h-3 w-3" />
{{ t("damengRoleAdmin.revokePrivilege") }}
</Button>
</div>
<div v-if="grantedPrivileges.length === 0" class="rounded border border-dashed p-6 text-center text-xs text-muted-foreground">
{{ t("damengRoleAdmin.noGrantedPrivileges") }}
</div>
</div>
</div>
</div>
</main>
</div>
<Dialog v-model:open="createDialogOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>{{ t("damengRoleAdmin.newRole") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 text-xs">
<label class="grid gap-1">
<span>{{ t("damengRoleAdmin.roleName") }}</span>
<Input v-model="createRoleName" class="h-8 text-xs" :placeholder="t('damengRoleAdmin.roleNamePlaceholder')" />
</label>
</div>
<DialogFooter>
<Button variant="outline" @click="createDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :disabled="!canCreateRole" @click="previewCreateRole">{{ t("damengRoleAdmin.previewSql") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="grantUserDialogOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>{{ t("damengRoleAdmin.grantToUser") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 text-xs">
<label class="grid gap-1">
<span>{{ t("damengRoleAdmin.userToGrant") }}</span>
<Select v-model="grantUserValue">
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('damengRoleAdmin.selectUser')" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="user in availableUsersForGrant" :key="user.username" :value="user.username">{{ user.username }}</SelectItem>
</SelectContent>
</Select>
</label>
<p class="text-[11px] text-muted-foreground">{{ t("damengRoleAdmin.grantUserHint", { role: selectedRole?.role ?? "" }) }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="grantUserDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :disabled="!grantUserValue" @click="previewGrantUser">{{ t("damengRoleAdmin.previewSql") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="grantPrivilegeDialogOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>{{ t("damengRoleAdmin.grantPrivilege") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 text-xs">
<label class="grid gap-1">
<span>{{ t("damengRoleAdmin.privilegeToGrant") }}</span>
<Select v-model="grantPrivilegeValue">
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('damengRoleAdmin.selectPrivilege')" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="privilege in availablePrivilegesForGrant" :key="privilege" :value="privilege" :disabled="isPrivilegeGrantDisabled(privilege)">{{ privilege }}</SelectItem>
</SelectContent>
</Select>
</label>
<p v-if="!enableDdlAnyPriv" class="text-[11px] text-amber-600 dark:text-amber-400">{{ t("damengRoleAdmin.anyPrivilegeDisabled") }}</p>
<p class="text-[11px] text-muted-foreground">{{ t("damengRoleAdmin.grantPrivilegeHint", { role: selectedRole?.role ?? "" }) }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="grantPrivilegeDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :disabled="!grantPrivilegeValue" @click="previewGrantPrivilege">{{ t("damengRoleAdmin.previewSql") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="previewDialogOpen">
<DialogContent class="max-w-3xl">
<DialogHeader>
<DialogTitle>{{ t("damengRoleAdmin.sqlPreview") }}</DialogTitle>
</DialogHeader>
<pre class="max-h-[50vh] overflow-auto rounded-md border bg-muted/30 p-3 text-xs"><code>{{ pendingSql }}</code></pre>
<DialogFooter>
<Button variant="outline" @click="previewDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :variant="pendingDanger ? 'destructive' : 'default'" :disabled="applying" @click="applyPendingSql">
<Loader2 v-if="applying" class="mr-1 h-3.5 w-3.5 animate-spin" />
{{ t("damengRoleAdmin.applySql") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>