feat(dameng): add user and role administration
This commit is contained in:
1 parent
9bacb752e4
commit
ec4f8582e6
25 files changed
+3435
-4
No files matched your search
@@ -0,0 +1,536 @@
|
||||
<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>
|
||||
@@ -0,0 +1,787 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, ChevronRight, Folder, KeyRound, Loader2, Lock, LockOpen, Plus, RefreshCcw, ShieldCheck, Trash2, UserRound, 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 PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
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_USER_GROUPS,
|
||||
DAMENG_SYSTEM_PRIVILEGES,
|
||||
damengAlterUserPasswordSql,
|
||||
damengCreateUserSql,
|
||||
damengDropUserSql,
|
||||
damengGrantRoleSql,
|
||||
damengGrantSystemPrivilegeSql,
|
||||
damengListRolesSql,
|
||||
damengListTablespacesSql,
|
||||
damengListUserRolesSql,
|
||||
damengListUserSysPrivsSql,
|
||||
damengListUsersSql,
|
||||
damengLockUserSql,
|
||||
damengRevokeRoleSql,
|
||||
damengRevokeSystemPrivilegeSql,
|
||||
damengSystemPrivilegeMapSql,
|
||||
damengRoleGraphSql,
|
||||
damengRolesClosure,
|
||||
damengUnlockUserSql,
|
||||
damengUserTypeGrantSqls,
|
||||
canDamengAlterUserPassword,
|
||||
canDamengRevokeRoleGrant,
|
||||
isDamengSystemUser,
|
||||
isDamengPredefinedRole,
|
||||
DAMENG_HIDDEN_ROLES,
|
||||
damengUserGroup,
|
||||
damengSystemUserCategory,
|
||||
parseDamengRoles,
|
||||
parseDamengRoleGraph,
|
||||
parseDamengRolePrivs,
|
||||
parseDamengSysPrivs,
|
||||
parseDamengTablespaces,
|
||||
parseDamengSystemPrivilegeMap,
|
||||
damengAvailableSystemPrivileges,
|
||||
parseDamengRoleGrants,
|
||||
parseDamengUsers,
|
||||
type DamengGrant,
|
||||
type DamengRole,
|
||||
type DamengSysPrivilege,
|
||||
type DamengUser,
|
||||
type DamengUserGroup,
|
||||
damengEnableDdlAnyPrivSql,
|
||||
isDamengAnyPrivilege,
|
||||
parseDamengEnableDdlAnyPriv,
|
||||
} from "@/lib/database/damengPrincipalAdmin";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
}>();
|
||||
|
||||
type DetailTab = "roles" | "privileges";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const users = ref<DamengUser[]>([]);
|
||||
const allRoles = ref<DamengRole[]>([]);
|
||||
const tablespaces = ref<string[]>([]);
|
||||
const selectedUsername = ref("");
|
||||
const search = ref("");
|
||||
const loadingUsers = ref(false);
|
||||
const loadingRoles = ref(false);
|
||||
const loadingDetails = ref(false);
|
||||
const applying = ref(false);
|
||||
const loadError = ref("");
|
||||
const detailError = ref("");
|
||||
const detailTab = ref<DetailTab>("roles");
|
||||
const grantedRoles = ref<DamengGrant[]>([]);
|
||||
const grantedPrivileges = ref<DamengSysPrivilege[]>([]);
|
||||
const systemPrivilegeMap = ref<Set<string> | null>(null);
|
||||
const enableDdlAnyPriv = ref(true);
|
||||
|
||||
const createDialogOpen = ref(false);
|
||||
const createUsername = ref("");
|
||||
const createPassword = ref("");
|
||||
const createTablespace = ref("");
|
||||
const createLocked = ref(false);
|
||||
const createUserType = ref<Exclude<DamengUserGroup, "other">>("admin");
|
||||
const createdUsername = ref("");
|
||||
const userRoleGrants = ref<Map<string, Set<string>>>(new Map());
|
||||
const collapsedGroups = ref<Record<string, boolean>>({});
|
||||
|
||||
function groupLabel(group: DamengUserGroup): string {
|
||||
if (group === "other") return t("damengUserAdmin.groupOther");
|
||||
const key = "damengUserAdmin.systemUser" + group.charAt(0).toUpperCase() + group.slice(1);
|
||||
return t(key);
|
||||
}
|
||||
|
||||
function usersInGroup(group: DamengUserGroup): DamengUser[] {
|
||||
return filteredUsers.value.filter((user) => damengUserGroup(user.username, userRoleGrants.value.get(user.username.trim().toUpperCase())) === group);
|
||||
}
|
||||
|
||||
function toggleGroup(group: DamengUserGroup) {
|
||||
collapsedGroups.value[group] = !collapsedGroups.value[group];
|
||||
}
|
||||
|
||||
const passwordDialogOpen = ref(false);
|
||||
const passwordValue = ref("");
|
||||
const passwordConfirm = ref("");
|
||||
|
||||
const grantRoleDialogOpen = ref(false);
|
||||
const grantRoleValue = 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 selectedUser = computed(() => users.value.find((user) => user.username === selectedUsername.value));
|
||||
const filteredUsers = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
if (!query) return users.value;
|
||||
return users.value.filter((user) => `${user.username} ${user.defaultTablespace}`.toLowerCase().includes(query));
|
||||
});
|
||||
const selectedIsSystemUser = computed(() => !!selectedUser.value && isDamengSystemUser(selectedUser.value.username));
|
||||
|
||||
/**
|
||||
* Whether the current connection user is allowed to change the selected
|
||||
* user's password, following the DM security model (see canDamengAlterUserPassword).
|
||||
*/
|
||||
const canAlterPasswordForSelected = computed(() => canDamengAlterUserPassword(props.connection.username, selectedUser.value?.username));
|
||||
|
||||
const selectedUserCategory = computed(() => (selectedUser.value ? damengSystemUserCategory(selectedUser.value.username) : undefined));
|
||||
const selectedUserCategoryLabel = computed(() => {
|
||||
if (!selectedUserCategory.value) return "";
|
||||
const key = "damengUserAdmin.systemUser" + selectedUserCategory.value.charAt(0).toUpperCase() + selectedUserCategory.value.slice(1);
|
||||
return t(key);
|
||||
});
|
||||
|
||||
function userCategoryLabel(username: string): string {
|
||||
const category = damengSystemUserCategory(username);
|
||||
if (!category) return "";
|
||||
const key = "damengUserAdmin.systemUser" + category.charAt(0).toUpperCase() + category.slice(1);
|
||||
return t(key);
|
||||
}
|
||||
const availableRolesForGrant = computed(() => {
|
||||
// Roles already held (directly or via nesting) plus hidden internal roles
|
||||
// that cannot be granted (e.g. SYS_ADMIN) are not offered.
|
||||
const owned = new Set(grantedRoles.value.map((grant) => grant.grantedRole.toUpperCase()));
|
||||
const hidden = DAMENG_HIDDEN_ROLES.map((role) => role.toUpperCase());
|
||||
return allRoles.value.filter((role) => !owned.has(role.role.toUpperCase()) && !hidden.includes(role.role.toUpperCase()));
|
||||
});
|
||||
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 canCreateUser = computed(() => createUsername.value.trim() !== "" && createPassword.value !== "");
|
||||
const canChangePassword = computed(() => passwordValue.value !== "" && passwordValue.value === passwordConfirm.value);
|
||||
|
||||
async function ensureConnection() {
|
||||
await connectionStore.ensureConnected(props.connection.id);
|
||||
}
|
||||
|
||||
async function refreshConnectionTree() {
|
||||
try {
|
||||
// DM users map 1:1 to schemas, so reload the sidebar database/schema tree
|
||||
// after user DDL. Best-effort: the in-page user list is already refreshed.
|
||||
await connectionStore.loadDatabases(props.connection.id, { force: true });
|
||||
} catch {
|
||||
// ignore tree refresh failures
|
||||
}
|
||||
}
|
||||
|
||||
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("damengUserAdmin.enableDdlAnyPrivHint")}` : message;
|
||||
}
|
||||
|
||||
function isPrivilegeGrantDisabled(privilege: string): boolean {
|
||||
return !enableDdlAnyPriv.value && isDamengAnyPrivilege(privilege);
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!supported.value) return;
|
||||
loadingUsers.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
await ensureConnection();
|
||||
// Load the full role-grant map once so list grouping can place regular users
|
||||
// holding management roles (DBA/DB_AUDIT_ADMIN/DB_POLICY_ADMIN) into the
|
||||
// matching category. Falls back to an empty map when the view is unreadable.
|
||||
const [result, roleGrantsResult] = await Promise.all([
|
||||
api.executeQuery(props.connection.id, executionDatabase.value, damengListUsersSql(), undefined, undefined, { maxRows: 5000 }),
|
||||
api.executeQuery(props.connection.id, executionDatabase.value, damengRoleGraphSql(), undefined, undefined, { maxRows: 5000 }).catch(() => undefined),
|
||||
]);
|
||||
users.value = parseDamengUsers(result);
|
||||
userRoleGrants.value = roleGrantsResult ? parseDamengRoleGrants(roleGrantsResult) : new Map();
|
||||
// Auto-select a user just created through the dialog.
|
||||
if (createdUsername.value) {
|
||||
const created = users.value.find((user) => user.username.toLowerCase() === createdUsername.value.toLowerCase());
|
||||
if (created) {
|
||||
selectedUsername.value = created.username;
|
||||
createdUsername.value = "";
|
||||
}
|
||||
}
|
||||
if (!selectedUser.value) selectedUsername.value = users.value[0]?.username || "";
|
||||
await loadDetails();
|
||||
} catch (error: any) {
|
||||
loadError.value = error?.message || String(error);
|
||||
users.value = [];
|
||||
grantedRoles.value = [];
|
||||
grantedPrivileges.value = [];
|
||||
} finally {
|
||||
loadingUsers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRolesAndTablespaces() {
|
||||
if (!supported.value) return;
|
||||
loadingRoles.value = true;
|
||||
try {
|
||||
await ensureConnection();
|
||||
const [rolesResult, tablespaceResult, privilegeMapResult, ddlAnyPrivResult] = await Promise.all([
|
||||
api.executeQuery(props.connection.id, executionDatabase.value, damengListRolesSql(), undefined, undefined, { maxRows: 5000 }),
|
||||
api.executeQuery(props.connection.id, executionDatabase.value, damengListTablespacesSql(), undefined, undefined, { maxRows: 1000 }),
|
||||
// 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),
|
||||
]);
|
||||
allRoles.value = parseDamengRoles(rolesResult);
|
||||
tablespaces.value = parseDamengTablespaces(tablespaceResult);
|
||||
systemPrivilegeMap.value = privilegeMapResult ? parseDamengSystemPrivilegeMap(privilegeMapResult) : null;
|
||||
enableDdlAnyPriv.value = ddlAnyPrivResult ? parseDamengEnableDdlAnyPriv(ddlAnyPrivResult) : true;
|
||||
} catch {
|
||||
allRoles.value = [];
|
||||
tablespaces.value = [];
|
||||
systemPrivilegeMap.value = null;
|
||||
} finally {
|
||||
loadingRoles.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let detailRequestSeq = 0;
|
||||
|
||||
async function loadDetails() {
|
||||
const user = selectedUser.value;
|
||||
if (!user) {
|
||||
grantedRoles.value = [];
|
||||
grantedPrivileges.value = [];
|
||||
return;
|
||||
}
|
||||
const seq = ++detailRequestSeq;
|
||||
loadingDetails.value = true;
|
||||
detailError.value = "";
|
||||
try {
|
||||
await ensureConnection();
|
||||
const [rolesResult, privsResult, graphResult] = await Promise.all([
|
||||
api.executeQuery(props.connection.id, executionDatabase.value, damengListUserRolesSql(user.username), undefined, undefined, { maxRows: 1000 }),
|
||||
api.executeQuery(props.connection.id, executionDatabase.value, damengListUserSysPrivsSql(user.username), undefined, undefined, { maxRows: 1000 }),
|
||||
api.executeQuery(props.connection.id, executionDatabase.value, damengRoleGraphSql(), undefined, undefined, { maxRows: 5000 }),
|
||||
]);
|
||||
if (seq !== detailRequestSeq) return; // stale response for a previous selection
|
||||
const directRoles = parseDamengRolePrivs(rolesResult);
|
||||
const graph = parseDamengRoleGraph(graphResult);
|
||||
// Official DM tool shows the transitive closure of roles (e.g. SYSDBA also
|
||||
// has SVI via PUBLIC and VTI via DBA), excluding hidden internal roles.
|
||||
grantedRoles.value = damengRolesClosure(user.username, graph).map((entry) => {
|
||||
const direct = directRoles.find((grant) => grant.grantedRole.toUpperCase() === entry.role);
|
||||
return direct ?? { grantee: user.username, grantedRole: entry.role, adminOption: false, defaultRole: false, inherited: !entry.direct, via: entry.via };
|
||||
});
|
||||
grantedPrivileges.value = parseDamengSysPrivs(privsResult);
|
||||
} catch (error: any) {
|
||||
if (seq !== detailRequestSeq) return; // stale response for a previous selection
|
||||
detailError.value = error?.message || String(error);
|
||||
grantedRoles.value = [];
|
||||
grantedPrivileges.value = [];
|
||||
} finally {
|
||||
if (seq === detailRequestSeq) loadingDetails.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectUser(user: DamengUser) {
|
||||
selectedUsername.value = user.username;
|
||||
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("damengUserAdmin.applyFailed", { message: formatApplyFailedMessage(translateBackendError(t, failed.error)) }), 5000);
|
||||
// Statements before the error may already be committed, so refresh regardless.
|
||||
await loadUsers();
|
||||
// Some statements may already be committed (per-statement autocommit), so keep the sidebar tree in sync too.
|
||||
await refreshConnectionTree();
|
||||
return;
|
||||
}
|
||||
toast(t("damengUserAdmin.applySuccess"), 2500);
|
||||
previewDialogOpen.value = false;
|
||||
await (pendingAfterApply.value?.() ?? Promise.resolve());
|
||||
await loadUsers();
|
||||
// Users map 1:1 to schemas on DM; keep the sidebar connection tree in sync after create/drop/etc.
|
||||
await refreshConnectionTree();
|
||||
} catch (error: any) {
|
||||
toast(t("damengUserAdmin.applyFailed", { message: formatApplyFailedMessage(error?.message || String(error)) }), 5000);
|
||||
} finally {
|
||||
applying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
createUsername.value = "";
|
||||
createPassword.value = "";
|
||||
createTablespace.value = tablespaces.value[0] ?? "";
|
||||
createLocked.value = false;
|
||||
createDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function previewCreateUser() {
|
||||
if (!canCreateUser.value) return;
|
||||
const statements = [
|
||||
damengCreateUserSql({
|
||||
username: createUsername.value,
|
||||
password: createPassword.value,
|
||||
tablespace: createTablespace.value,
|
||||
locked: createLocked.value,
|
||||
}),
|
||||
...damengUserTypeGrantSqls(createUserType.value, createUsername.value),
|
||||
];
|
||||
previewSql(statements.join("\n"), {
|
||||
afterApply: () => {
|
||||
createDialogOpen.value = false;
|
||||
createdUsername.value = createUsername.value.trim();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openChangePasswordDialog() {
|
||||
passwordValue.value = "";
|
||||
passwordConfirm.value = "";
|
||||
passwordDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function previewChangePassword() {
|
||||
const user = selectedUser.value;
|
||||
if (!user || !canAlterPasswordForSelected.value || !canChangePassword.value) return;
|
||||
previewSql(damengAlterUserPasswordSql(user.username, passwordValue.value), {
|
||||
afterApply: () => {
|
||||
passwordDialogOpen.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function previewLockUser(locked: boolean) {
|
||||
const user = selectedUser.value;
|
||||
if (!user) return;
|
||||
previewSql(locked ? damengLockUserSql(user.username) : damengUnlockUserSql(user.username));
|
||||
}
|
||||
|
||||
function previewDropUser() {
|
||||
const user = selectedUser.value;
|
||||
if (!user) return;
|
||||
previewSql(damengDropUserSql(user.username), { danger: true });
|
||||
}
|
||||
|
||||
function openGrantRoleDialog() {
|
||||
grantRoleValue.value = "";
|
||||
grantRoleDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function previewGrantRole() {
|
||||
const user = selectedUser.value;
|
||||
if (!user || !grantRoleValue.value) return;
|
||||
previewSql(damengGrantRoleSql(user.username, grantRoleValue.value), {
|
||||
afterApply: () => {
|
||||
grantRoleDialogOpen.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function previewRevokeRole(grant: DamengGrant) {
|
||||
const user = selectedUser.value;
|
||||
if (!user || !canDamengRevokeRoleGrant(user.username, grant)) return;
|
||||
previewSql(damengRevokeRoleSql(user.username, grant.grantedRole), { danger: true });
|
||||
}
|
||||
|
||||
function openGrantPrivilegeDialog() {
|
||||
grantPrivilegeValue.value = "";
|
||||
grantPrivilegeDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function previewGrantPrivilege() {
|
||||
const user = selectedUser.value;
|
||||
if (!user || !grantPrivilegeValue.value) return;
|
||||
previewSql(damengGrantSystemPrivilegeSql(user.username, grantPrivilegeValue.value), {
|
||||
afterApply: () => {
|
||||
grantPrivilegeDialogOpen.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function previewRevokePrivilege(grant: DamengSysPrivilege) {
|
||||
const user = selectedUser.value;
|
||||
if (!user) return;
|
||||
previewSql(damengRevokeSystemPrivilegeSql(user.username, grant.privilege), { danger: true });
|
||||
}
|
||||
|
||||
function accountStatusBadgeVariant(user: DamengUser): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (!user.locked) return "default";
|
||||
return /EXPIRED/i.test(user.accountStatus) ? "destructive" : "secondary";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadUsers();
|
||||
void loadRolesAndTablespaces();
|
||||
});
|
||||
</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">
|
||||
<UsersRound class="h-4 w-4 text-primary" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-semibold">{{ t("damengUserAdmin.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="loadingUsers" @click="loadUsers">
|
||||
<Loader2 v-if="loadingUsers" 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("damengUserAdmin.newUser") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="!supported" class="m-4 rounded border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{{ t("damengUserAdmin.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('damengUserAdmin.searchUser')" />
|
||||
</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("damengUserAdmin.loadFailed") }}</div>
|
||||
<div class="break-all">{{ loadError }}</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-2">
|
||||
<template v-for="group in DAMENG_USER_GROUPS" :key="group">
|
||||
<div v-if="usersInGroup(group).length > 0" class="mb-0.5 mt-1 first:mt-0">
|
||||
<button type="button" class="flex w-full items-center gap-1 rounded px-1 py-1 text-[11px] font-medium text-muted-foreground hover:bg-accent" @click="toggleGroup(group)">
|
||||
<ChevronRight class="h-3 w-3 transition-transform" :class="collapsedGroups[group] ? '' : 'rotate-90'" />
|
||||
<Folder class="h-3 w-3" />
|
||||
<span class="truncate">{{ groupLabel(group) }}</span>
|
||||
<span class="ml-auto text-[10px] opacity-70">{{ usersInGroup(group).length }}</span>
|
||||
</button>
|
||||
<div v-if="!collapsedGroups[group]">
|
||||
<button
|
||||
v-for="user in usersInGroup(group)"
|
||||
:key="user.username"
|
||||
type="button"
|
||||
class="mb-1 w-full rounded border px-2 py-2 text-left text-xs transition hover:bg-accent"
|
||||
:class="selectedUsername === user.username ? 'border-primary bg-primary/10' : 'border-transparent'"
|
||||
@click="selectUser(user)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ user.username }}</span>
|
||||
<Badge v-if="userCategoryLabel(user.username)" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">{{ userCategoryLabel(user.username) }}</Badge>
|
||||
<Badge v-if="user.locked" :variant="accountStatusBadgeVariant(user)" class="h-5 px-1.5 text-[10px]">{{ user.accountStatus }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 truncate text-[11px] text-muted-foreground">{{ user.defaultTablespace || "-" }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!loadingUsers && filteredUsers.length === 0" class="p-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("damengUserAdmin.emptyUsers") }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex min-h-0 flex-col">
|
||||
<div v-if="selectedUser" class="flex shrink-0 flex-wrap items-center gap-2 border-b p-3">
|
||||
<UserRound 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">{{ selectedUser.username }}</span>
|
||||
<Badge v-if="selectedUser.locked" :variant="accountStatusBadgeVariant(selectedUser)" class="h-5 px-1.5 text-[10px]">{{ selectedUser.accountStatus }}</Badge>
|
||||
<Badge v-else variant="outline" class="h-5 px-1.5 text-[10px]">{{ t("damengUserAdmin.open") }}</Badge>
|
||||
<Badge v-if="selectedUserCategoryLabel" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">{{ selectedUserCategoryLabel }}</Badge>
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ t("damengUserAdmin.defaultTablespace") }}: {{ selectedUser.defaultTablespace || "-" }} · {{ t("damengUserAdmin.created") }}: {{ selectedUser.created || "-" }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="!canAlterPasswordForSelected" :title="canAlterPasswordForSelected ? '' : t('damengUserAdmin.cannotChangePassword')" @click="openChangePasswordDialog">
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
{{ t("damengUserAdmin.changePassword") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="selectedIsSystemUser" :title="selectedIsSystemUser ? t('damengUserAdmin.systemUserProtected') : ''" @click="previewLockUser(!selectedUser.locked)">
|
||||
<LockOpen v-if="selectedUser.locked" class="h-3.5 w-3.5" />
|
||||
<Lock v-else class="h-3.5 w-3.5" />
|
||||
{{ selectedUser.locked ? t("damengUserAdmin.unlock") : t("damengUserAdmin.lock") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" class="h-8 gap-1.5" :disabled="selectedIsSystemUser" :title="selectedIsSystemUser ? t('damengUserAdmin.systemUserProtected') : ''" @click="previewDropUser">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
{{ t("damengUserAdmin.dropUser") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedUser" class="flex h-10 shrink-0 items-center gap-1 border-b px-3">
|
||||
<Button size="sm" :variant="detailTab === 'roles' ? 'secondary' : 'ghost'" class="h-7 px-2 text-xs" @click="detailTab = 'roles'">{{ t("damengUserAdmin.roles") }}</Button>
|
||||
<Button size="sm" :variant="detailTab === 'privileges' ? 'secondary' : 'ghost'" class="h-7 px-2 text-xs" @click="detailTab = 'privileges'">{{ t("damengUserAdmin.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="!selectedUser" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("damengUserAdmin.selectUser") }}
|
||||
</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("damengUserAdmin.loadFailed") }}</div>
|
||||
<div class="break-all">{{ detailError }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detailTab === 'roles'">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="text-xs font-medium">{{ t("damengUserAdmin.grantedRoles") }} ({{ grantedRoles.length }})</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="ml-auto h-7 gap-1 px-2 text-xs"
|
||||
:disabled="selectedIsSystemUser || availableRolesForGrant.length === 0"
|
||||
:title="selectedIsSystemUser ? t('damengUserAdmin.systemUserProtected') : availableRolesForGrant.length === 0 ? t('damengUserAdmin.noRolesToGrant') : ''"
|
||||
@click="openGrantRoleDialog"
|
||||
>
|
||||
<UserRoundPlus class="h-3.5 w-3.5" />
|
||||
{{ t("damengUserAdmin.grantRole") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<div v-for="grant in grantedRoles" :key="grant.grantedRole" 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.grantedRole }}</span>
|
||||
<Badge v-if="grant.adminOption" variant="outline" class="h-5 px-1.5 text-[10px]">ADMIN OPTION</Badge>
|
||||
<Badge v-if="isDamengPredefinedRole(grant.grantedRole)" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">{{ t("damengUserAdmin.builtinRole") }}</Badge>
|
||||
<Badge v-if="grant.inherited" variant="secondary" class="h-5 px-1.5 text-[10px]" :title="grant.via">{{ t("damengUserAdmin.inheritedFrom", { via: grant.via ?? "" }) }}</Badge>
|
||||
<Button
|
||||
v-if="!grant.inherited"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-6 gap-1 px-2 text-xs text-destructive hover:text-destructive"
|
||||
:disabled="!canDamengRevokeRoleGrant(selectedUser.username, grant)"
|
||||
:title="selectedIsSystemUser ? t('damengUserAdmin.systemUserProtected') : ''"
|
||||
@click="previewRevokeRole(grant)"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
{{ t("damengUserAdmin.revokeRole") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="grantedRoles.length === 0" class="rounded border border-dashed p-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("damengUserAdmin.noGrantedRoles") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="text-xs font-medium">{{ t("damengUserAdmin.grantedPrivileges") }} ({{ grantedPrivileges.length }})</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="ml-auto h-7 gap-1 px-2 text-xs"
|
||||
:disabled="selectedIsSystemUser || availablePrivilegesForGrant.length === 0"
|
||||
:title="selectedIsSystemUser ? t('damengUserAdmin.systemUserProtected') : availablePrivilegesForGrant.length === 0 ? t('damengUserAdmin.noPrivilegesToGrant') : ''"
|
||||
@click="openGrantPrivilegeDialog"
|
||||
>
|
||||
<UserRoundPlus class="h-3.5 w-3.5" />
|
||||
{{ t("damengUserAdmin.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="selectedIsSystemUser || isPrivilegeGrantDisabled(grant.privilege)"
|
||||
:title="selectedIsSystemUser ? t('damengUserAdmin.systemUserProtected') : isPrivilegeGrantDisabled(grant.privilege) ? t('damengUserAdmin.anyPrivilegeDisabled') : ''"
|
||||
@click="previewRevokePrivilege(grant)"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
{{ t("damengUserAdmin.revokePrivilege") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="grantedPrivileges.length === 0" class="rounded border border-dashed p-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("damengUserAdmin.noGrantedPrivileges") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="createDialogOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("damengUserAdmin.newUser") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3 text-xs">
|
||||
<label class="grid gap-1">
|
||||
<span>{{ t("damengUserAdmin.username") }}</span>
|
||||
<Input v-model="createUsername" class="h-8 text-xs" :placeholder="t('damengUserAdmin.usernamePlaceholder')" />
|
||||
</label>
|
||||
<label class="grid gap-1">
|
||||
<span>{{ t("damengUserAdmin.userType") }}</span>
|
||||
<Select v-model="createUserType">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="group in DAMENG_USER_GROUPS.slice(0, 3)" :key="group" :value="group">{{ groupLabel(group) }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span class="text-[10px] text-muted-foreground">{{ t("damengUserAdmin.userTypeHint") }}</span>
|
||||
</label>
|
||||
<label class="grid gap-1">
|
||||
<span>{{ t("damengUserAdmin.password") }}</span>
|
||||
<PasswordInput v-model="createPassword" class="h-8 text-xs" />
|
||||
<span :class="createPassword.length > 0 && createPassword.length < 9 ? 'text-[10px] text-amber-600 dark:text-amber-400' : 'text-[10px] text-muted-foreground'">{{ t("damengUserAdmin.passwordPolicyHint") }}</span>
|
||||
</label>
|
||||
<label class="grid gap-1">
|
||||
<span>{{ t("damengUserAdmin.tablespace") }}</span>
|
||||
<Select v-if="tablespaces.length > 0" v-model="createTablespace">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="space in tablespaces" :key="space" :value="space">{{ space }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input v-else v-model="createTablespace" class="h-8 text-xs" :placeholder="t('damengUserAdmin.tablespacePlaceholder')" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="createLocked" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
|
||||
<span>{{ t("damengUserAdmin.locked") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="createDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="!canCreateUser" @click="previewCreateUser">{{ t("damengUserAdmin.previewSql") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="passwordDialogOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("damengUserAdmin.changePassword") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3 text-xs">
|
||||
<label class="grid gap-1">
|
||||
<span>{{ t("damengUserAdmin.newPassword") }}</span>
|
||||
<PasswordInput v-model="passwordValue" class="h-8 text-xs" />
|
||||
<span :class="passwordValue.length > 0 && passwordValue.length < 9 ? 'text-[10px] text-amber-600 dark:text-amber-400' : 'text-[10px] text-muted-foreground'">{{ t("damengUserAdmin.passwordPolicyHint") }}</span>
|
||||
</label>
|
||||
<label class="grid gap-1">
|
||||
<span>{{ t("damengUserAdmin.confirmPassword") }}</span>
|
||||
<PasswordInput v-model="passwordConfirm" class="h-8 text-xs" />
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="passwordDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="!canChangePassword" @click="previewChangePassword">{{ t("damengUserAdmin.previewSql") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="grantRoleDialogOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("damengUserAdmin.grantRole") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3 text-xs">
|
||||
<label class="grid gap-1">
|
||||
<span>{{ t("damengUserAdmin.roleToGrant") }}</span>
|
||||
<Select v-model="grantRoleValue">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('damengUserAdmin.selectRole')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="role in availableRolesForGrant" :key="role.role" :value="role.role">{{ role.role }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("damengUserAdmin.grantRoleHint", { user: selectedUser?.username ?? "" }) }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="grantRoleDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="!grantRoleValue" @click="previewGrantRole">{{ t("damengUserAdmin.previewSql") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="grantPrivilegeDialogOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("damengUserAdmin.grantPrivilege") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3 text-xs">
|
||||
<label class="grid gap-1">
|
||||
<span>{{ t("damengUserAdmin.privilegeToGrant") }}</span>
|
||||
<Select v-model="grantPrivilegeValue">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('damengUserAdmin.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("damengUserAdmin.anyPrivilegeDisabled") }}</p>
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("damengUserAdmin.grantPrivilegeHint", { user: selectedUser?.username ?? "" }) }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="grantPrivilegeDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="!grantPrivilegeValue" @click="previewGrantPrivilege">{{ t("damengUserAdmin.previewSql") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="previewDialogOpen">
|
||||
<DialogContent class="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("damengUserAdmin.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("damengUserAdmin.applySql") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user