feat(users): visual database and table level grants for MySQL
This commit is contained in:
15 files changed
+643
-271
No files matched your search
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, ChevronDown, Loader2, Search, Table2 } from "@lucide/vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { authorizationPrivileges, type AuthorizationPreset, type DatabaseAuthorizationSelection } from "@/lib/database/databaseAuthorizationPlan";
|
||||
import type { DatabaseUserAdminProvider } from "@/lib/database/databaseUserAdmin";
|
||||
|
||||
/**
|
||||
* 数据库授权范围编辑器:数据库多选 + 每库权限预设 + 表级(全部表/指定表)选择 + 自定义权限勾选。
|
||||
* 该组件同时被“新增用户”弹窗与“权限编辑”面板复用,保证两处交互一致。
|
||||
* 组件内部只负责收集选择结果(DatabaseAuthorizationSelection[]),不生成或执行 SQL,
|
||||
* 授权语句由调用方交给 databaseAuthorizationPlan 中的计划构造器生成。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 已选中的数据库授权配置,由调用方通过 v-model 持有 */
|
||||
modelValue: DatabaseAuthorizationSelection[];
|
||||
/** 当前连接的权限提供者,用于判断是否支持表级授权以及表级可用权限 */
|
||||
provider: DatabaseUserAdminProvider | null;
|
||||
/** 当前连接下的数据库名列表 */
|
||||
databases: string[];
|
||||
/** 数据库列表是否正在加载 */
|
||||
databasesLoading?: boolean;
|
||||
/** 连接 ID,用于按库懒加载表列表 */
|
||||
connectionId: string;
|
||||
/** 紧凑布局:用于右侧权限编辑面板,隐藏标题区、压缩列表高度并让表范围控件换行显示 */
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: DatabaseAuthorizationSelection[]): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
// 全局作用域标识:MySQL 的 GRANT/REVOKE 支持 ON *.*,权限编辑面板需要保留这一能力
|
||||
const GLOBAL_SCOPE = "*";
|
||||
const search = ref("");
|
||||
// 表列表按库懒加载并缓存,避免下拉展开时重复请求
|
||||
const tables = ref<Record<string, string[]>>({});
|
||||
const tablesLoading = ref<Record<string, boolean>>({});
|
||||
const tableErrors = ref<Record<string, string>>({});
|
||||
const tableSearch = ref<Record<string, string>>({});
|
||||
|
||||
const supportsTableGrants = computed(() => !!props.provider?.supportsTableGrantsOnCreate);
|
||||
const filteredDatabases = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
return query ? props.databases.filter((database) => database.toLowerCase().includes(query)) : props.databases;
|
||||
});
|
||||
// 紧凑模式(权限编辑面板)在列表顶部提供固定的「全局」入口,且不受库搜索影响;
|
||||
// 新增用户弹窗保持只勾选真实数据库,不改变原有产品行为。
|
||||
const visibleDatabases = computed(() => (props.compact ? [GLOBAL_SCOPE, ...filteredDatabases.value] : filteredDatabases.value));
|
||||
const selectedDatabaseSet = computed(() => new Set(props.modelValue.map((selection) => selection.database)));
|
||||
|
||||
// 同一 tick 内连续触发多次选择变更时(例如连续点击多张表),父组件回流的 props 尚未更新,
|
||||
// 这里用最近一次发出的值作为基准,避免后一次变更覆盖前一次导致选择丢失。
|
||||
let pendingSelections: DatabaseAuthorizationSelection[] | null = null;
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
pendingSelections = value;
|
||||
},
|
||||
);
|
||||
|
||||
function currentSelections(): DatabaseAuthorizationSelection[] {
|
||||
return pendingSelections ?? props.modelValue;
|
||||
}
|
||||
|
||||
function commitSelections(next: DatabaseAuthorizationSelection[]) {
|
||||
pendingSelections = next;
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
function selectionFor(database: string): DatabaseAuthorizationSelection | undefined {
|
||||
return props.modelValue.find((selection) => selection.database === database);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一以“不可变更新”方式回写选中项:始终替换为新的数组与对象,
|
||||
* 避免直接修改 props 中的对象引用(v-model 单向数据流的约束)。
|
||||
*/
|
||||
function patchSelection(database: string, patch: (selection: DatabaseAuthorizationSelection) => DatabaseAuthorizationSelection | undefined) {
|
||||
const next: DatabaseAuthorizationSelection[] = [];
|
||||
for (const selection of currentSelections()) {
|
||||
if (selection.database !== database) {
|
||||
next.push(selection);
|
||||
continue;
|
||||
}
|
||||
const updated = patch({ ...selection });
|
||||
if (updated) next.push({ ...updated, tables: updated.tables ? [...updated.tables] : undefined, privileges: updated.privileges ? [...updated.privileges] : undefined });
|
||||
}
|
||||
commitSelections(next);
|
||||
}
|
||||
|
||||
function toggleDatabase(database: string) {
|
||||
if (selectionFor(database)) {
|
||||
commitSelections(currentSelections().filter((selection) => selection.database !== database));
|
||||
return;
|
||||
}
|
||||
commitSelections([...currentSelections(), { database, preset: "readOnly", privileges: ["SELECT"] }]);
|
||||
}
|
||||
|
||||
// 表范围下 MySQL 不支持 CREATE ROUTINE / EVENT 等库级权限,需要按目标作用域过滤可选权限
|
||||
function privilegesForSelection(selection?: DatabaseAuthorizationSelection): string[] {
|
||||
const userProvider = props.provider;
|
||||
if (!userProvider) return [];
|
||||
return authorizationPrivileges(userProvider, selection?.tables === undefined ? "database" : "table");
|
||||
}
|
||||
|
||||
function usesSelectedTables(database: string): boolean {
|
||||
return selectionFor(database)?.tables !== undefined;
|
||||
}
|
||||
|
||||
function isTableSelected(database: string, table: string): boolean {
|
||||
return selectionFor(database)?.tables?.includes(table) ?? false;
|
||||
}
|
||||
|
||||
function allTablesSelected(database: string): boolean {
|
||||
const available = tables.value[database] ?? [];
|
||||
return available.length > 0 && selectionFor(database)?.tables?.length === available.length;
|
||||
}
|
||||
|
||||
function filteredTables(database: string): string[] {
|
||||
const query = (tableSearch.value[database] ?? "").trim().toLowerCase();
|
||||
const available = tables.value[database] ?? [];
|
||||
return query ? available.filter((table) => table.toLowerCase().includes(query)) : available;
|
||||
}
|
||||
|
||||
async function loadTables(database: string) {
|
||||
if (tables.value[database] || tablesLoading.value[database]) return;
|
||||
tablesLoading.value = { ...tablesLoading.value, [database]: true };
|
||||
tableErrors.value = { ...tableErrors.value, [database]: "" };
|
||||
try {
|
||||
const result = await api.listTables(props.connectionId, database, "");
|
||||
tables.value = {
|
||||
...tables.value,
|
||||
[database]: Array.from(new Set(result.map((table) => table.name.trim()).filter(Boolean))).sort((left, right) => left.localeCompare(right)),
|
||||
};
|
||||
} catch (error: any) {
|
||||
tableErrors.value = { ...tableErrors.value, [database]: error?.message || String(error) };
|
||||
} finally {
|
||||
tablesLoading.value = { ...tablesLoading.value, [database]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTableScope(database: string, selectTables: boolean) {
|
||||
if (!selectTables) {
|
||||
patchSelection(database, (selection) => {
|
||||
delete selection.tables;
|
||||
return selection;
|
||||
});
|
||||
return;
|
||||
}
|
||||
patchSelection(database, (selection) => {
|
||||
selection.tables = selection.tables ?? [];
|
||||
if (selection.preset === "custom") {
|
||||
const allowed = new Set(privilegesForSelection(selection));
|
||||
selection.privileges = (selection.privileges ?? []).filter((privilege) => allowed.has(privilege));
|
||||
}
|
||||
return selection;
|
||||
});
|
||||
await loadTables(database);
|
||||
}
|
||||
|
||||
function toggleTable(database: string, table: string) {
|
||||
patchSelection(database, (selection) => {
|
||||
if (!selection.tables) return selection;
|
||||
const next = new Set(selection.tables);
|
||||
if (next.has(table)) next.delete(table);
|
||||
else next.add(table);
|
||||
selection.tables = Array.from(next);
|
||||
return selection;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAllTables(database: string) {
|
||||
patchSelection(database, (selection) => {
|
||||
if (!selection.tables) return selection;
|
||||
const available = tables.value[database] ?? [];
|
||||
selection.tables = selection.tables.length === available.length ? [] : [...available];
|
||||
return selection;
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreset(database: string, preset: unknown) {
|
||||
if (typeof preset !== "string") return;
|
||||
patchSelection(database, (selection) => {
|
||||
selection.preset = preset as AuthorizationPreset;
|
||||
return selection;
|
||||
});
|
||||
}
|
||||
|
||||
function togglePrivilege(database: string, privilege: string) {
|
||||
patchSelection(database, (selection) => {
|
||||
const next = new Set(selection.privileges ?? []);
|
||||
if (next.has(privilege)) next.delete(privilege);
|
||||
else next.add(privilege);
|
||||
selection.privileges = Array.from(next);
|
||||
return selection;
|
||||
});
|
||||
}
|
||||
|
||||
function isPrivilegeSelected(database: string, privilege: string): boolean {
|
||||
return selectionFor(database)?.privileges?.includes(privilege) ?? false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2" :class="compact ? 'h-full min-h-0' : ''">
|
||||
<div v-if="!compact" class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs font-medium">{{ t("userAdmin.databaseAccess") }}</div>
|
||||
<div class="mt-1 text-[11px] text-muted-foreground">{{ t("userAdmin.databaseAccessHint") }}</div>
|
||||
</div>
|
||||
<Badge variant="outline">{{ t("userAdmin.selectedDatabaseCount", { count: modelValue.length }) }}</Badge>
|
||||
</div>
|
||||
<div class="flex h-8 shrink-0 items-center gap-2 rounded-md border px-2">
|
||||
<Search class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input v-model="search" class="min-w-0 flex-1 bg-transparent text-xs outline-none" :placeholder="t('userAdmin.searchDatabase')" />
|
||||
</div>
|
||||
<!-- 紧凑模式下面板高度有限,数据库列表占满剩余高度并自行滚动,避免多个库挤在一起 -->
|
||||
<div class="overflow-auto rounded-md border" :class="compact ? 'min-h-0 flex-1' : 'max-h-64'">
|
||||
<div v-if="databasesLoading" class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("userAdmin.loadingDatabases") }}
|
||||
</div>
|
||||
<div v-else-if="visibleDatabases.length === 0" class="p-3 text-center text-xs text-muted-foreground">{{ t("userAdmin.emptyDatabases") }}</div>
|
||||
<div v-for="database in visibleDatabases" :key="database" class="border-b p-2" :class="compact ? '' : 'last:border-b-0'">
|
||||
<div class="flex items-center gap-2">
|
||||
<input :checked="selectedDatabaseSet.has(database)" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleDatabase(database)" />
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left text-xs font-medium" @click="toggleDatabase(database)">
|
||||
{{ database === GLOBAL_SCOPE ? t("userAdmin.globalScope") : database }}
|
||||
</button>
|
||||
<Select v-if="selectedDatabaseSet.has(database)" :model-value="selectionFor(database)?.preset" @update:model-value="updatePreset(database, $event)">
|
||||
<SelectTrigger class="h-7 text-xs" :class="compact ? 'w-28' : 'w-32'"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="readWrite">{{ t("userAdmin.presetReadWrite") }}</SelectItem>
|
||||
<SelectItem value="readOnly">{{ t("userAdmin.presetReadOnly") }}</SelectItem>
|
||||
<SelectItem value="ddl">{{ t("userAdmin.presetDdl") }}</SelectItem>
|
||||
<SelectItem value="dml">{{ t("userAdmin.presetDml") }}</SelectItem>
|
||||
<SelectItem value="custom">{{ t("userAdmin.presetCustom") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="supportsTableGrants && database !== GLOBAL_SCOPE && selectedDatabaseSet.has(database)" class="mt-2 flex gap-2" :class="compact ? 'flex-col gap-1.5' : 'items-center pl-5'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="!compact" class="shrink-0 text-[11px] text-muted-foreground">{{ t("userAdmin.tableScope") }}</span>
|
||||
<div class="flex h-7 shrink-0 items-center rounded-md border bg-muted/30 p-0.5">
|
||||
<button type="button" class="h-5 rounded px-2 text-[10px]" :class="!usesSelectedTables(database) ? 'bg-background font-medium shadow-sm' : 'text-muted-foreground'" @click="updateTableScope(database, false)">
|
||||
{{ t("userAdmin.allTables") }}
|
||||
</button>
|
||||
<button type="button" class="h-5 rounded px-2 text-[10px]" :class="usesSelectedTables(database) ? 'bg-background font-medium shadow-sm' : 'text-muted-foreground'" @click="updateTableScope(database, true)">
|
||||
{{ t("userAdmin.specificTables") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Popover v-if="usesSelectedTables(database)">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 rounded-md border bg-background px-2 text-left hover:bg-accent" :class="compact ? 'h-9 text-xs' : 'h-7 text-[11px]'">
|
||||
<Table2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{{ selectionFor(database)?.tables?.length ? t("userAdmin.selectedTableCount", { count: selectionFor(database)?.tables?.length }) : t("userAdmin.chooseTables") }}
|
||||
</span>
|
||||
<Loader2 v-if="tablesLoading[database]" class="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
<ChevronDown v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-72 p-0">
|
||||
<div class="flex items-center gap-2 border-b p-2">
|
||||
<div class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border px-2">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input v-model="tableSearch[database]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t('userAdmin.searchTable')" />
|
||||
</div>
|
||||
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[database]?.length > 0)" @click="toggleAllTables(database)">
|
||||
{{ t(allTablesSelected(database) ? "userAdmin.clearAllTables" : "userAdmin.selectAllTables") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-auto p-1">
|
||||
<div v-if="tablesLoading[database]" class="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("userAdmin.loadingTables") }}
|
||||
</div>
|
||||
<div v-else-if="tableErrors[database]" class="px-3 py-4 text-center text-xs text-destructive">
|
||||
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: tableErrors[database] }) }}</p>
|
||||
<button type="button" class="mt-2 text-primary" @click="loadTables(database)">{{ t("userAdmin.retry") }}</button>
|
||||
</div>
|
||||
<div v-else-if="filteredTables(database).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("userAdmin.emptyTables") }}
|
||||
</div>
|
||||
<button v-for="table in filteredTables(database)" :key="table" type="button" class="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs hover:bg-accent" @click="toggleTable(database, table)">
|
||||
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border" :class="isTableSelected(database, table) ? 'border-primary bg-primary text-primary-foreground' : 'border-border'">
|
||||
<Check v-if="isTableSelected(database, table)" class="h-2.5 w-2.5" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate">{{ table }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p v-if="usesSelectedTables(database) && !selectionFor(database)?.tables?.length" class="mt-1 text-[10px] text-destructive" :class="compact ? '' : 'pl-5'">
|
||||
{{ t("userAdmin.tableSelectionRequired") }}
|
||||
</p>
|
||||
<div v-if="selectionFor(database)?.preset === 'custom'" class="mt-2 grid gap-1.5" :class="compact ? 'grid-cols-2' : 'grid-cols-3 pl-5'">
|
||||
<button
|
||||
v-for="privilege in privilegesForSelection(selectionFor(database))"
|
||||
:key="privilege"
|
||||
type="button"
|
||||
:title="privilege"
|
||||
class="flex h-7 min-w-0 items-center gap-1.5 rounded border px-2 text-[10px]"
|
||||
:class="isPrivilegeSelected(database, privilege) ? 'border-primary bg-primary/10 text-primary' : 'bg-background'"
|
||||
@click="togglePrivilege(database, privilege)"
|
||||
>
|
||||
<Check v-if="isPrivilegeSelected(database, privilege)" class="h-3 w-3 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-left">{{ privilege }}</span>
|
||||
</button>
|
||||
<p v-if="!selectionFor(database)?.privileges?.length" class="text-[10px] text-destructive" :class="compact ? 'col-span-2' : 'col-span-3'">
|
||||
{{ t("userAdmin.customPrivilegeRequired") }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="provider?.dialect === 'postgres' && selectedDatabaseSet.has(database) && selectionFor(database)?.preset === 'ddl'" class="mt-2 text-[10px] text-muted-foreground" :class="compact ? '' : 'pl-5'">
|
||||
{{ t("userAdmin.postgresDdlHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, Check, ChevronDown, Globe2, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Table2, Trash2, Unlock, UserRound } from "@lucide/vue";
|
||||
import { AlertTriangle, Check, Globe2, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Unlock, UserRound } 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import AuthorizationScopeEditor from "@/components/admin/AuthorizationScopeEditor.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
authorizationPlanStatus,
|
||||
authorizationPrivileges,
|
||||
buildCreateUserAuthorizationPlan,
|
||||
buildGrantAuthorizationPlan,
|
||||
executeAuthorizationPlan,
|
||||
type AuthorizationAccountType,
|
||||
type AuthorizationPlan,
|
||||
type AuthorizationPreset,
|
||||
type AuthorizationStepResult,
|
||||
type DatabaseAuthorizationSelection,
|
||||
} from "@/lib/database/databaseAuthorizationPlan";
|
||||
@@ -90,12 +90,9 @@ const createCanLogin = ref(true);
|
||||
const createAccountType = ref<AuthorizationAccountType>("standard");
|
||||
const createDatabases = ref<string[]>([]);
|
||||
const createDatabasesLoading = ref(false);
|
||||
const createDatabaseSearch = ref("");
|
||||
const createDatabaseAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
|
||||
const createDatabaseTables = ref<Record<string, string[]>>({});
|
||||
const createDatabaseTablesLoading = ref<Record<string, boolean>>({});
|
||||
const createDatabaseTableErrors = ref<Record<string, string>>({});
|
||||
const createDatabaseTableSearch = ref<Record<string, string>>({});
|
||||
// 权限编辑面板的授权范围选择(MySQL 表级授权场景),与新增用户弹窗复用同一个范围编辑器
|
||||
const privilegeAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
|
||||
let createPlanRequestId = 0;
|
||||
let preserveRestoredPrivilegeSelection = restoredUiState.selectedPrivileges !== undefined || restoredUiState.grantOption !== undefined;
|
||||
|
||||
@@ -141,18 +138,10 @@ const selectedDetail = computed(() => {
|
||||
const grantsSqlText = computed(() => grants.value.join("\n") || t("userAdmin.noGrants"));
|
||||
const highlightedGrantsSql = computed(() => highlight(grantsSqlText.value));
|
||||
const highlightedPendingSql = computed(() => highlight(pendingSql.value));
|
||||
const filteredCreateDatabases = computed(() => {
|
||||
const query = createDatabaseSearch.value.trim().toLowerCase();
|
||||
return query ? createDatabases.value.filter((database) => database.toLowerCase().includes(query)) : createDatabases.value;
|
||||
});
|
||||
const selectedCreateDatabaseSet = computed(() => new Set(createDatabaseAuthorizations.value.map((selection) => selection.database)));
|
||||
const createDatabaseAuthorizationsValid = computed(() =>
|
||||
createDatabaseAuthorizations.value.every((selection) => {
|
||||
if (selection.tables !== undefined && selection.tables.length === 0) return false;
|
||||
if (selection.preset !== "custom") return true;
|
||||
return createPrivilegesForAuthorization(selection).some((privilege) => selection.privileges?.includes(privilege));
|
||||
}),
|
||||
);
|
||||
const createDatabaseAuthorizationsValid = computed(() => authorizationSelectionsValid(createDatabaseAuthorizations.value));
|
||||
// MySQL 表级授权场景下,权限编辑面板复用与新增用户一致的授权范围编辑器
|
||||
const usePrivilegeScopeEditor = computed(() => canEditPrivileges.value && supportsCreateTableGrants.value && privilegeScope.value !== "role");
|
||||
const privilegeAuthorizationsValid = computed(() => authorizationSelectionsValid(privilegeAuthorizations.value));
|
||||
const pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined));
|
||||
const canPreviewHostChange = computed(() => {
|
||||
const host = newHost.value.trim();
|
||||
@@ -267,17 +256,8 @@ function togglePrivilege(privilege: string) {
|
||||
selectedPrivileges.value = Array.from(set);
|
||||
}
|
||||
|
||||
async function openCreateUserDialog() {
|
||||
createDialogOpen.value = true;
|
||||
createAccountType.value = "standard";
|
||||
createCanLogin.value = true;
|
||||
createPassword.value = "";
|
||||
createDatabaseSearch.value = "";
|
||||
createDatabaseAuthorizations.value = [];
|
||||
createDatabaseTables.value = {};
|
||||
createDatabaseTablesLoading.value = {};
|
||||
createDatabaseTableErrors.value = {};
|
||||
createDatabaseTableSearch.value = {};
|
||||
// 加载当前连接下的数据库列表:新增用户弹窗与权限编辑面板的授权范围编辑器共用
|
||||
async function loadDatabases() {
|
||||
if (createDatabasesLoading.value) return;
|
||||
createDatabases.value = [];
|
||||
createDatabasesLoading.value = true;
|
||||
@@ -292,108 +272,27 @@ async function openCreateUserDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
function createAuthorization(database: string): DatabaseAuthorizationSelection | undefined {
|
||||
return createDatabaseAuthorizations.value.find((selection) => selection.database === database);
|
||||
async function openCreateUserDialog() {
|
||||
createDialogOpen.value = true;
|
||||
createAccountType.value = "standard";
|
||||
createCanLogin.value = true;
|
||||
createPassword.value = "";
|
||||
createDatabaseAuthorizations.value = [];
|
||||
await loadDatabases();
|
||||
}
|
||||
|
||||
function toggleCreateDatabase(database: string) {
|
||||
const existing = createAuthorization(database);
|
||||
if (existing) {
|
||||
createDatabaseAuthorizations.value = createDatabaseAuthorizations.value.filter((selection) => selection.database !== database);
|
||||
} else {
|
||||
createDatabaseAuthorizations.value = [...createDatabaseAuthorizations.value, { database, preset: "readOnly", privileges: ["SELECT"] }];
|
||||
}
|
||||
}
|
||||
|
||||
function createPrivilegesForAuthorization(selection?: DatabaseAuthorizationSelection): string[] {
|
||||
/**
|
||||
* 校验授权选择是否可生成语句:指定表时必须至少选一张表;自定义权限时必须命中至少一项可用权限。
|
||||
* 表级与库级可用权限不同(例如 MySQL 表级不支持 CREATE ROUTINE / EVENT),因此按选择的目标作用域取权限集合。
|
||||
*/
|
||||
function authorizationSelectionsValid(selections: DatabaseAuthorizationSelection[]): boolean {
|
||||
const userProvider = provider.value;
|
||||
if (!userProvider || !selection) return [];
|
||||
return authorizationPrivileges(userProvider, selection.tables === undefined ? "database" : "table");
|
||||
}
|
||||
|
||||
function createAuthorizationUsesSelectedTables(database: string): boolean {
|
||||
return createAuthorization(database)?.tables !== undefined;
|
||||
}
|
||||
|
||||
function createAuthorizationTableSelected(database: string, table: string): boolean {
|
||||
return createAuthorization(database)?.tables?.includes(table) ?? false;
|
||||
}
|
||||
|
||||
function allCreateDatabaseTablesSelected(database: string): boolean {
|
||||
const tables = createDatabaseTables.value[database] ?? [];
|
||||
return tables.length > 0 && createAuthorization(database)?.tables?.length === tables.length;
|
||||
}
|
||||
|
||||
function filteredCreateDatabaseTables(database: string): string[] {
|
||||
const query = (createDatabaseTableSearch.value[database] ?? "").trim().toLowerCase();
|
||||
const tables = createDatabaseTables.value[database] ?? [];
|
||||
return query ? tables.filter((table) => table.toLowerCase().includes(query)) : tables;
|
||||
}
|
||||
|
||||
async function updateCreateDatabaseTableScope(database: string, selectedTables: boolean) {
|
||||
const selection = createAuthorization(database);
|
||||
if (!selection) return;
|
||||
if (!selectedTables) {
|
||||
delete selection.tables;
|
||||
return;
|
||||
}
|
||||
selection.tables = selection.tables ?? [];
|
||||
if (selection.preset === "custom") {
|
||||
const allowed = new Set(createPrivilegesForAuthorization(selection));
|
||||
selection.privileges = (selection.privileges ?? []).filter((privilege) => allowed.has(privilege));
|
||||
}
|
||||
await loadCreateDatabaseTables(database);
|
||||
}
|
||||
|
||||
async function loadCreateDatabaseTables(database: string) {
|
||||
if (createDatabaseTables.value[database] || createDatabaseTablesLoading.value[database]) return;
|
||||
createDatabaseTablesLoading.value = { ...createDatabaseTablesLoading.value, [database]: true };
|
||||
createDatabaseTableErrors.value = { ...createDatabaseTableErrors.value, [database]: "" };
|
||||
try {
|
||||
const tables = await api.listTables(props.connection.id, database, "");
|
||||
createDatabaseTables.value = {
|
||||
...createDatabaseTables.value,
|
||||
[database]: Array.from(new Set(tables.map((table) => table.name.trim()).filter(Boolean))).sort((left, right) => left.localeCompare(right)),
|
||||
};
|
||||
} catch (error: any) {
|
||||
createDatabaseTableErrors.value = {
|
||||
...createDatabaseTableErrors.value,
|
||||
[database]: error?.message || String(error),
|
||||
};
|
||||
} finally {
|
||||
createDatabaseTablesLoading.value = { ...createDatabaseTablesLoading.value, [database]: false };
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCreateDatabaseTable(database: string, table: string) {
|
||||
const selection = createAuthorization(database);
|
||||
if (!selection?.tables) return;
|
||||
const tables = new Set(selection.tables);
|
||||
if (tables.has(table)) tables.delete(table);
|
||||
else tables.add(table);
|
||||
selection.tables = Array.from(tables);
|
||||
}
|
||||
|
||||
function toggleAllCreateDatabaseTables(database: string) {
|
||||
const selection = createAuthorization(database);
|
||||
if (!selection?.tables) return;
|
||||
const tables = createDatabaseTables.value[database] ?? [];
|
||||
selection.tables = selection.tables.length === tables.length ? [] : [...tables];
|
||||
}
|
||||
|
||||
function updateCreateDatabasePreset(database: string, preset: unknown) {
|
||||
const selection = createAuthorization(database);
|
||||
if (!selection || typeof preset !== "string") return;
|
||||
selection.preset = preset as AuthorizationPreset;
|
||||
}
|
||||
|
||||
function toggleCreateDatabasePrivilege(database: string, privilege: string) {
|
||||
const selection = createAuthorization(database);
|
||||
if (!selection) return;
|
||||
const privileges = new Set(selection.privileges ?? []);
|
||||
if (privileges.has(privilege)) privileges.delete(privilege);
|
||||
else privileges.add(privilege);
|
||||
selection.privileges = Array.from(privileges);
|
||||
if (!userProvider) return false;
|
||||
return selections.every((selection) => {
|
||||
if (selection.tables !== undefined && selection.tables.length === 0) return false;
|
||||
if (selection.preset !== "custom") return true;
|
||||
return authorizationPrivileges(userProvider, selection.tables === undefined ? "database" : "table").some((privilege) => selection.privileges?.includes(privilege));
|
||||
});
|
||||
}
|
||||
|
||||
function previewSql(sql: string, options: { danger?: boolean; afterApply?: () => Promise<void> } = {}) {
|
||||
@@ -495,6 +394,9 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
|
||||
if (step.operation === "grantDatabase") {
|
||||
return step.targetTable ? t("userAdmin.stepGrantTable", { user: step.subject, database: step.targetDatabase, table: step.targetTable }) : t("userAdmin.stepGrantDatabase", { user: step.subject, database: step.targetDatabase });
|
||||
}
|
||||
if (step.operation === "revokePrivileges") {
|
||||
return step.targetTable ? t("userAdmin.stepRevokeTable", { user: step.subject, database: step.targetDatabase, table: step.targetTable }) : t("userAdmin.stepRevokeDatabase", { user: step.subject, database: step.targetDatabase });
|
||||
}
|
||||
if (step.operation === "grantCurrentObjects") {
|
||||
return t("userAdmin.stepGrantCurrentObjects", {
|
||||
user: step.subject,
|
||||
@@ -564,11 +466,31 @@ function previewLoginChange(enabled: boolean) {
|
||||
previewSql(alterLoginSql(user, enabled), { danger: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开授权计划预览:多库/多表会展开为多条 GRANT / REVOKE,
|
||||
* 复用既有 SQL 预览弹窗的逐步执行结果与生产环境守卫,无需新增执行链路。
|
||||
*/
|
||||
function previewPlan(plan: AuthorizationPlan, options: { danger?: boolean } = {}) {
|
||||
if (plan.steps.length === 0) return;
|
||||
pendingPlan.value = plan;
|
||||
pendingSql.value = authorizationPlanSql(plan);
|
||||
pendingResults.value = [];
|
||||
pendingDanger.value = !!options.danger;
|
||||
pendingAfterApply.value = undefined;
|
||||
sqlDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function previewGrant() {
|
||||
const user = selectedUser.value;
|
||||
const userProvider = provider.value;
|
||||
const grantPrivilegesSql = userProvider?.grantPrivilegesSql;
|
||||
if (!user || !grantPrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
|
||||
if (!user || !userProvider || !grantPrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
|
||||
if (usePrivilegeScopeEditor.value) {
|
||||
// MySQL 表级授权:按所选库/表展开为多条 GRANT(仅追加,不回收该用户已有权限)
|
||||
if (!privilegeAuthorizationsValid.value) return;
|
||||
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases: privilegeAuthorizations.value, grantOption: grantOption.value }));
|
||||
return;
|
||||
}
|
||||
previewSql(
|
||||
grantPrivilegesSql({
|
||||
user,
|
||||
@@ -586,7 +508,13 @@ function previewRevoke() {
|
||||
const user = selectedUser.value;
|
||||
const userProvider = provider.value;
|
||||
const revokePrivilegesSql = userProvider?.revokePrivilegesSql;
|
||||
if (!user || !revokePrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
|
||||
if (!user || !userProvider || !revokePrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
|
||||
if (usePrivilegeScopeEditor.value) {
|
||||
// 与授权保持同一套选择:按所选库/表展开为多条 REVOKE
|
||||
if (!privilegeAuthorizationsValid.value) return;
|
||||
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases: privilegeAuthorizations.value, revoke: true }), { danger: true });
|
||||
return;
|
||||
}
|
||||
previewSql(
|
||||
revokePrivilegesSql({
|
||||
user,
|
||||
@@ -624,6 +552,8 @@ watch(
|
||||
() => selectedUserKey.value,
|
||||
() => {
|
||||
grantsLoaded.value = false;
|
||||
// 切换用户时清空上一位用户选择的授权范围,避免把权限误授给当前用户
|
||||
privilegeAuthorizations.value = [];
|
||||
void loadGrants();
|
||||
},
|
||||
);
|
||||
@@ -634,16 +564,14 @@ watch(
|
||||
users.value = [];
|
||||
createDatabases.value = [];
|
||||
createDatabaseAuthorizations.value = [];
|
||||
createDatabaseTables.value = {};
|
||||
createDatabaseTablesLoading.value = {};
|
||||
createDatabaseTableErrors.value = {};
|
||||
createDatabaseTableSearch.value = {};
|
||||
privilegeAuthorizations.value = [];
|
||||
selectedUserKey.value = "";
|
||||
grants.value = [];
|
||||
grantsLoaded.value = false;
|
||||
privilegeScope.value = provider.value?.defaultScope ?? "mysql";
|
||||
resetPrivilegeDefaults(privilegeScope.value);
|
||||
void loadUsers();
|
||||
if (usePrivilegeScopeEditor.value) void loadDatabases();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -671,7 +599,11 @@ watch(
|
||||
|
||||
watch([privilegeDatabase, privilegeTable], syncPrivilegeSelectionFromGrants);
|
||||
|
||||
onMounted(loadUsers);
|
||||
onMounted(() => {
|
||||
void loadUsers();
|
||||
// 权限编辑面板的授权范围选择需要数据库列表,进入页面即预加载(仅 MySQL 表级授权场景)
|
||||
if (usePrivilegeScopeEditor.value) void loadDatabases();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -789,7 +721,7 @@ onMounted(loadUsers);
|
||||
<aside v-if="canEditPrivileges" class="flex min-h-0 flex-col bg-muted/10">
|
||||
<div class="border-b p-3">
|
||||
<div class="text-xs font-semibold">{{ t("userAdmin.privilegeEditor") }}</div>
|
||||
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t("userAdmin.privilegeHint") }}</div>
|
||||
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t(usePrivilegeScopeEditor ? "userAdmin.privilegeAppendHint" : "userAdmin.privilegeHint") }}</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-3">
|
||||
<template v-if="isPostgres">
|
||||
@@ -811,6 +743,7 @@ onMounted(loadUsers);
|
||||
<label class="mb-2 block text-xs font-medium">{{ t("userAdmin.memberRole") }}</label>
|
||||
<Input v-model="privilegeRole" class="mb-3 h-8 text-xs" :placeholder="t('userAdmin.memberRole')" />
|
||||
</template>
|
||||
<AuthorizationScopeEditor v-else-if="usePrivilegeScopeEditor" v-model="privilegeAuthorizations" compact :provider="provider" :databases="createDatabases" :databases-loading="createDatabasesLoading" :connection-id="connection.id" />
|
||||
<template v-else>
|
||||
<label class="mb-2 block text-xs font-medium">
|
||||
{{ isPostgres && privilegeScope !== "database" ? t("userAdmin.schema") : t("userAdmin.database") }}
|
||||
@@ -822,8 +755,8 @@ onMounted(loadUsers);
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="hasPrivilegePicker" class="mb-2 text-xs font-medium">{{ t("userAdmin.privileges") }}</div>
|
||||
<div v-if="hasPrivilegePicker" class="grid grid-cols-2 gap-1.5">
|
||||
<div v-if="hasPrivilegePicker && !usePrivilegeScopeEditor" class="mb-2 text-xs font-medium">{{ t("userAdmin.privileges") }}</div>
|
||||
<div v-if="hasPrivilegePicker && !usePrivilegeScopeEditor" class="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
v-for="privilege in availablePrivileges"
|
||||
:key="privilege"
|
||||
@@ -838,16 +771,20 @@ onMounted(loadUsers);
|
||||
<span class="truncate">{{ privilege }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="mt-3 flex items-center gap-2 text-xs">
|
||||
<input v-model="grantOption" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
|
||||
{{ privilegeScope === "role" ? t("userAdmin.adminOption") : t("userAdmin.grantOption") }}
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex shrink-0 items-start gap-2 border-t px-3 py-2 text-xs leading-4">
|
||||
<input v-model="grantOption" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" />
|
||||
<span class="min-w-0 flex-1">
|
||||
{{ privilegeScope === "role" ? t("userAdmin.adminOption") : t("userAdmin.grantOption") }}
|
||||
<!-- 撤权语句本身不带 GRANT OPTION,明确说明避免误解 -->
|
||||
<span v-if="privilegeScope !== 'role'" class="mt-0.5 block text-[10px] text-muted-foreground">{{ t("userAdmin.grantOptionHint") }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 border-t p-3">
|
||||
<Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="previewRevoke">
|
||||
<Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewRevoke">
|
||||
{{ t("userAdmin.revoke") }}
|
||||
</Button>
|
||||
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" @click="previewGrant">
|
||||
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewGrant">
|
||||
{{ t("userAdmin.grant") }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -893,117 +830,7 @@ onMounted(loadUsers);
|
||||
{{ t("userAdmin.adminUserWarning") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs font-medium">{{ t("userAdmin.databaseAccess") }}</div>
|
||||
<div class="mt-1 text-[11px] text-muted-foreground">{{ t("userAdmin.databaseAccessHint") }}</div>
|
||||
</div>
|
||||
<Badge variant="outline">{{ t("userAdmin.selectedDatabaseCount", { count: createDatabaseAuthorizations.length }) }}</Badge>
|
||||
</div>
|
||||
<div class="flex h-8 items-center gap-2 rounded-md border px-2">
|
||||
<Search class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input v-model="createDatabaseSearch" class="min-w-0 flex-1 bg-transparent text-xs outline-none" :placeholder="t('userAdmin.searchDatabase')" />
|
||||
</div>
|
||||
<div class="max-h-64 overflow-auto rounded-md border">
|
||||
<div v-if="createDatabasesLoading" class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("userAdmin.loadingDatabases") }}
|
||||
</div>
|
||||
<div v-else-if="filteredCreateDatabases.length === 0" class="p-3 text-center text-xs text-muted-foreground">{{ t("userAdmin.emptyDatabases") }}</div>
|
||||
<div v-for="database in filteredCreateDatabases" :key="database" class="border-b p-2 last:border-b-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<input :checked="selectedCreateDatabaseSet.has(database)" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleCreateDatabase(database)" />
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left text-xs font-medium" @click="toggleCreateDatabase(database)">{{ database }}</button>
|
||||
<Select v-if="selectedCreateDatabaseSet.has(database)" :model-value="createAuthorization(database)?.preset" @update:model-value="updateCreateDatabasePreset(database, $event)">
|
||||
<SelectTrigger class="h-7 w-32 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="readWrite">{{ t("userAdmin.presetReadWrite") }}</SelectItem>
|
||||
<SelectItem value="readOnly">{{ t("userAdmin.presetReadOnly") }}</SelectItem>
|
||||
<SelectItem value="ddl">{{ t("userAdmin.presetDdl") }}</SelectItem>
|
||||
<SelectItem value="dml">{{ t("userAdmin.presetDml") }}</SelectItem>
|
||||
<SelectItem value="custom">{{ t("userAdmin.presetCustom") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="supportsCreateTableGrants && selectedCreateDatabaseSet.has(database)" class="mt-2 flex items-center gap-2 pl-5">
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground">{{ t("userAdmin.tableScope") }}</span>
|
||||
<div class="flex h-7 shrink-0 items-center rounded-md border bg-muted/30 p-0.5">
|
||||
<button type="button" class="h-5 rounded px-2 text-[10px]" :class="!createAuthorizationUsesSelectedTables(database) ? 'bg-background font-medium shadow-sm' : 'text-muted-foreground'" @click="updateCreateDatabaseTableScope(database, false)">
|
||||
{{ t("userAdmin.allTables") }}
|
||||
</button>
|
||||
<button type="button" class="h-5 rounded px-2 text-[10px]" :class="createAuthorizationUsesSelectedTables(database) ? 'bg-background font-medium shadow-sm' : 'text-muted-foreground'" @click="updateCreateDatabaseTableScope(database, true)">
|
||||
{{ t("userAdmin.specificTables") }}
|
||||
</button>
|
||||
</div>
|
||||
<Popover v-if="createAuthorizationUsesSelectedTables(database)">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border bg-background px-2 text-left text-[11px] hover:bg-accent">
|
||||
<Table2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{{ createAuthorization(database)?.tables?.length ? t("userAdmin.selectedTableCount", { count: createAuthorization(database)?.tables?.length }) : t("userAdmin.chooseTables") }}
|
||||
</span>
|
||||
<Loader2 v-if="createDatabaseTablesLoading[database]" class="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
<ChevronDown v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-72 p-0">
|
||||
<div class="flex items-center gap-2 border-b p-2">
|
||||
<div class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border px-2">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input v-model="createDatabaseTableSearch[database]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t('userAdmin.searchTable')" />
|
||||
</div>
|
||||
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(createDatabaseTables[database]?.length > 0)" @click="toggleAllCreateDatabaseTables(database)">
|
||||
{{ t(allCreateDatabaseTablesSelected(database) ? "userAdmin.clearAllTables" : "userAdmin.selectAllTables") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-auto p-1">
|
||||
<div v-if="createDatabaseTablesLoading[database]" class="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("userAdmin.loadingTables") }}
|
||||
</div>
|
||||
<div v-else-if="createDatabaseTableErrors[database]" class="px-3 py-4 text-center text-xs text-destructive">
|
||||
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: createDatabaseTableErrors[database] }) }}</p>
|
||||
<button type="button" class="mt-2 text-primary" @click="loadCreateDatabaseTables(database)">{{ t("userAdmin.retry") }}</button>
|
||||
</div>
|
||||
<div v-else-if="filteredCreateDatabaseTables(database).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("userAdmin.emptyTables") }}
|
||||
</div>
|
||||
<button v-for="table in filteredCreateDatabaseTables(database)" :key="table" type="button" class="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs hover:bg-accent" @click="toggleCreateDatabaseTable(database, table)">
|
||||
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border" :class="createAuthorizationTableSelected(database, table) ? 'border-primary bg-primary text-primary-foreground' : 'border-border'">
|
||||
<Check v-if="createAuthorizationTableSelected(database, table)" class="h-2.5 w-2.5" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate">{{ table }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p v-if="createAuthorizationUsesSelectedTables(database) && !createAuthorization(database)?.tables?.length" class="mt-1 pl-5 text-[10px] text-destructive">
|
||||
{{ t("userAdmin.tableSelectionRequired") }}
|
||||
</p>
|
||||
<div v-if="createAuthorization(database)?.preset === 'custom'" class="mt-2 grid grid-cols-3 gap-1.5 pl-5">
|
||||
<button
|
||||
v-for="privilege in createPrivilegesForAuthorization(createAuthorization(database))"
|
||||
:key="privilege"
|
||||
type="button"
|
||||
class="flex h-7 items-center gap-1.5 rounded border px-2 text-[10px]"
|
||||
:class="createAuthorization(database)?.privileges?.includes(privilege) ? 'border-primary bg-primary/10 text-primary' : 'bg-background'"
|
||||
@click="toggleCreateDatabasePrivilege(database, privilege)"
|
||||
>
|
||||
<Check v-if="createAuthorization(database)?.privileges?.includes(privilege)" class="h-3 w-3" />
|
||||
<span class="truncate">{{ privilege }}</span>
|
||||
</button>
|
||||
<p v-if="!createAuthorization(database)?.privileges?.length" class="col-span-3 text-[10px] text-destructive">
|
||||
{{ t("userAdmin.customPrivilegeRequired") }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="isPostgres && selectedCreateDatabaseSet.has(database) && createAuthorization(database)?.preset === 'ddl'" class="mt-2 pl-5 text-[10px] text-muted-foreground">
|
||||
{{ t("userAdmin.postgresDdlHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AuthorizationScopeEditor v-else v-model="createDatabaseAuthorizations" :provider="provider" :databases="createDatabases" :databases-loading="createDatabasesLoading" :connection-id="connection.id" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="createDialogOpen = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
|
||||
@@ -3993,6 +3993,8 @@ export default withEnglishFallback({
|
||||
noGrants: "Verilmiş səlahiyyət tapılmadı",
|
||||
privilegeEditor: "Səlahiyyət redaktoru",
|
||||
privilegeHint: "Səlahiyyətləri, verilənlər bazasını və cədvəli seçin, sonra tətbiq etməzdən əvvəl SQL-ə önbaxış edin. * qlobal və ya bütün cədvəllər deməkdir.",
|
||||
privilegeAppendHint: "Verilənlər bazalarını seçin və cədvəl əhatəsini təyin edin, sonra hər verilənlər bazası üçün imtiyazları konfiqurasiya edin. İmtiyazlar əlavə qaydada verilir və mövcud imtiyazlar geri alınmır.",
|
||||
globalScope: "Qlobal (*.*)",
|
||||
database: "Verilənlər bazası",
|
||||
schema: "Sxem",
|
||||
table: "Cədvəl",
|
||||
@@ -4004,6 +4006,7 @@ export default withEnglishFallback({
|
||||
memberRole: "Üzv rol",
|
||||
privileges: "Səlahiyyətlər",
|
||||
grantOption: "Səlahiyyətlərin başqalarına verilməsinə icazə ver (WITH GRANT OPTION)",
|
||||
grantOptionHint: "Yalnızca GRANT ifadələri üçün tətbiq olunur; REVOKE bunu nəzərə almır.",
|
||||
adminOption: "Üzvlüyün idarə olunmasına icazə ver (WITH ADMIN OPTION)",
|
||||
grant: "Səlahiyyət ver",
|
||||
revoke: "Geri al",
|
||||
@@ -4068,6 +4071,8 @@ export default withEnglishFallback({
|
||||
stepGrantAdmin: "{user} istifadəçisinə inzibatçı səlahiyyətləri ver",
|
||||
stepGrantDatabase: '{user} istifadəçisinə "{database}" verilənlər bazasına giriş icazəsi ver',
|
||||
stepGrantTable: '{user} istifadəçisinə "{database}.{table}" cədvəlinə giriş icazəsi ver',
|
||||
stepRevokeDatabase: '{user} istifadəçisinin "{database}" verilənlər bazasına girişini geri al',
|
||||
stepRevokeTable: '{user} istifadəçisinin "{database}.{table}" cədvəlinə girişini geri al',
|
||||
stepGrantObjects: '{user} istifadəçisinə "{database}" verilənlər bazasında sxem və obyekt icazələri ver',
|
||||
stepGrantCurrentObjects: '"{database}" verilənlər bazasındakı "{schema}" sxemində mövcud olan {scope} üçün {user} istifadəçisinə icazə ver',
|
||||
stepGrantFutureObjects: '"{database}" verilənlər bazasında cari rolun gələcəkdə yaradacağı {scope} üçün {user} istifadəçisinə icazə ver',
|
||||
|
||||
@@ -4215,6 +4215,8 @@ export default {
|
||||
noGrants: "No grants found",
|
||||
privilegeEditor: "Privilege Editor",
|
||||
privilegeHint: "Choose privileges, database, and table, then preview SQL before applying. * means global or all tables.",
|
||||
privilegeAppendHint: "Choose databases and specify table scope, then configure privileges per database. Grants are additive and never revoke existing privileges.",
|
||||
globalScope: "Global (*.*)",
|
||||
database: "Database",
|
||||
schema: "Schema",
|
||||
table: "Table",
|
||||
@@ -4226,6 +4228,7 @@ export default {
|
||||
memberRole: "Member role",
|
||||
privileges: "Privileges",
|
||||
grantOption: "Allow further grants (WITH GRANT OPTION)",
|
||||
grantOptionHint: "Applies to GRANT statements only; REVOKE ignores it.",
|
||||
adminOption: "Allow membership management (WITH ADMIN OPTION)",
|
||||
grant: "Grant",
|
||||
revoke: "Revoke",
|
||||
@@ -4290,6 +4293,8 @@ export default {
|
||||
stepGrantAdmin: "Grant administrative privileges to {user}",
|
||||
stepGrantDatabase: 'Grant {user} access to database "{database}"',
|
||||
stepGrantTable: 'Grant {user} access to table "{database}.{table}"',
|
||||
stepRevokeDatabase: 'Revoke {user} access to database "{database}"',
|
||||
stepRevokeTable: 'Revoke {user} access to table "{database}.{table}"',
|
||||
stepGrantObjects: 'Grant {user} schema and object privileges in "{database}"',
|
||||
stepGrantCurrentObjects: 'Grant current {scope} in schema "{schema}" to {user} in "{database}"',
|
||||
stepGrantFutureObjects: 'Grant future {scope} created by the current role to {user} in "{database}"',
|
||||
|
||||
@@ -3912,6 +3912,8 @@ export default withEnglishFallback({
|
||||
noGrants: "No se encontraron permisos",
|
||||
privilegeEditor: "Editor de privilegios",
|
||||
privilegeHint: "Elige privilegios, base de datos y tabla, luego previsualiza el SQL antes de aplicar. * significa global o todas las tablas.",
|
||||
privilegeAppendHint: "Elige bases de datos y especifica el ámbito de tablas, luego configura los privilegios por base de datos. La concesión es acumulativa y no revoca los privilegios existentes.",
|
||||
globalScope: "Global (*.*)",
|
||||
database: "Base de datos",
|
||||
schema: "Esquema",
|
||||
table: "Tabla",
|
||||
@@ -3923,6 +3925,7 @@ export default withEnglishFallback({
|
||||
memberRole: "Rol miembro",
|
||||
privileges: "Privilegios",
|
||||
grantOption: "Permitir más concesiones (WITH GRANT OPTION)",
|
||||
grantOptionHint: "Solo se aplica a las sentencias GRANT; REVOKE lo ignora.",
|
||||
adminOption: "Permitir gestión de membresía (WITH ADMIN OPTION)",
|
||||
grant: "Conceder",
|
||||
revoke: "Revocar",
|
||||
@@ -3986,6 +3989,8 @@ export default withEnglishFallback({
|
||||
stepGrantAdmin: "Conceder privilegios administrativos a {user}",
|
||||
stepGrantDatabase: 'Conceder a {user} acceso a la base de datos "{database}"',
|
||||
stepGrantTable: 'Conceder a {user} acceso a la tabla "{database}.{table}"',
|
||||
stepRevokeDatabase: 'Revocar a {user} el acceso a la base de datos "{database}"',
|
||||
stepRevokeTable: 'Revocar a {user} el acceso a la tabla "{database}.{table}"',
|
||||
stepGrantObjects: 'Conceder a {user} privilegios de esquema y objetos en "{database}"',
|
||||
stepGrantCurrentObjects: 'Conceder a {user} privilegios sobre {scope} existentes del esquema "{schema}" en "{database}"',
|
||||
stepGrantFutureObjects: 'Conceder a {user} privilegios sobre futuros {scope} creados por el rol actual en "{database}"',
|
||||
|
||||
@@ -3909,6 +3909,8 @@ export default withEnglishFallback({
|
||||
noGrants: "Nessun privilegio trovato",
|
||||
privilegeEditor: "Editor Privilegi",
|
||||
privilegeHint: "Scegli privilegi, database e tabella, quindi visualizza l'anteprima SQL prima di applicare. * indica globale o tutte le tabelle.",
|
||||
privilegeAppendHint: "Seleziona i database e specifica l'ambito delle tabelle, poi configura i privilegi per ogni database. La concessione è aggiuntiva e non revoca i privilegi esistenti.",
|
||||
globalScope: "Globale (*.*)",
|
||||
database: "Database",
|
||||
schema: "Schema",
|
||||
table: "Tabella",
|
||||
@@ -3920,6 +3922,7 @@ export default withEnglishFallback({
|
||||
memberRole: "Ruolo membro",
|
||||
privileges: "Privilegi",
|
||||
grantOption: "Consenti ulteriori concessioni (WITH GRANT OPTION)",
|
||||
grantOptionHint: "Si applica solo alle istruzioni GRANT; REVOKE lo ignora.",
|
||||
adminOption: "Consenti gestione appartenenza (WITH ADMIN OPTION)",
|
||||
grant: "Concedi",
|
||||
revoke: "Revoca",
|
||||
@@ -3983,6 +3986,8 @@ export default withEnglishFallback({
|
||||
stepGrantAdmin: "Concedi privilegi amministrativi a {user}",
|
||||
stepGrantDatabase: 'Concedi a {user} l’accesso al database "{database}"',
|
||||
stepGrantTable: 'Concedi a {user} l’accesso alla tabella "{database}.{table}"',
|
||||
stepRevokeDatabase: 'Revoca a {user} l’accesso al database "{database}"',
|
||||
stepRevokeTable: 'Revoca a {user} l’accesso alla tabella "{database}.{table}"',
|
||||
stepGrantObjects: 'Concedi a {user} privilegi su schema e oggetti in "{database}"',
|
||||
stepGrantCurrentObjects: 'Concedi a {user} i privilegi sui {scope} esistenti nello schema "{schema}" di "{database}"',
|
||||
stepGrantFutureObjects: 'Concedi a {user} i privilegi sui futuri {scope} creati dal ruolo corrente in "{database}"',
|
||||
|
||||
@@ -3972,6 +3972,8 @@ export default withEnglishFallback({
|
||||
noGrants: "権限が見つかりません",
|
||||
privilegeEditor: "権限エディタ",
|
||||
privilegeHint: "権限、データベース、テーブルを選択し、適用前にSQLをプレビューしてください。* はグローバルまたは全テーブルを意味します。",
|
||||
privilegeAppendHint: "データベースを選択してテーブル範囲を指定し、データベースごとに権限を設定します。権限の付与は追加式で、既存の権限は取り消されません。",
|
||||
globalScope: "グローバル (*.*)",
|
||||
database: "データベース",
|
||||
schema: "スキーマ",
|
||||
table: "テーブル",
|
||||
@@ -3983,6 +3985,7 @@ export default withEnglishFallback({
|
||||
memberRole: "メンバーロール",
|
||||
privileges: "権限",
|
||||
grantOption: "さらなる権限付与を許可 (WITH GRANT OPTION)",
|
||||
grantOptionHint: "GRANT 文にのみ適用されます(REVOKE では無視されます)。",
|
||||
adminOption: "メンバーシップ管理を許可 (WITH ADMIN OPTION)",
|
||||
grant: "付与",
|
||||
revoke: "取り消し",
|
||||
@@ -4046,6 +4049,8 @@ export default withEnglishFallback({
|
||||
stepGrantAdmin: "{user} に管理権限を付与",
|
||||
stepGrantDatabase: "{user} にデータベース「{database}」の権限を付与",
|
||||
stepGrantTable: "{user} にテーブル「{database}.{table}」の権限を付与",
|
||||
stepRevokeDatabase: "{user} のデータベース「{database}」の権限をはく奪",
|
||||
stepRevokeTable: "{user} のテーブル「{database}.{table}」の権限をはく奪",
|
||||
stepGrantObjects: "{user} にデータベース「{database}」のスキーマとオブジェクト権限を付与",
|
||||
stepGrantCurrentObjects: "{user} にデータベース「{database}」のスキーマ「{schema}」にある既存の{scope}権限を付与",
|
||||
stepGrantFutureObjects: "現在のロールがデータベース「{database}」で今後作成する{scope}の権限を {user} に付与",
|
||||
|
||||
@@ -3910,6 +3910,8 @@ export default withEnglishFallback({
|
||||
noGrants: "권한이 없습니다",
|
||||
privilegeEditor: "권한 편집기",
|
||||
privilegeHint: "권한, 데이터베이스, 테이블을 선택한 다음 적용 전에 SQL을 미리보세요. *는 전역 또는 모든 테이블을 의미합니다.",
|
||||
privilegeAppendHint: "데이터베이스를 선택하고 테이블 범위를 지정한 뒤 데이터베이스별로 권한을 설정합니다. 권한 부여는 추가 방식이며 기존 권한은 회수하지 않습니다.",
|
||||
globalScope: "전역 (*.*)",
|
||||
database: "데이터베이스",
|
||||
schema: "스키마",
|
||||
table: "테이블",
|
||||
@@ -3921,6 +3923,7 @@ export default withEnglishFallback({
|
||||
memberRole: "멤버 역할",
|
||||
privileges: "권한",
|
||||
grantOption: "추가 권한 부여 허용 (WITH GRANT OPTION)",
|
||||
grantOptionHint: "GRANT 문에만 적용되며 REVOKE에는 적용되지 않습니다.",
|
||||
adminOption: "멤버십 관리 허용 (WITH ADMIN OPTION)",
|
||||
grant: "권한 부여",
|
||||
revoke: "권한 회수",
|
||||
@@ -3984,6 +3987,8 @@ export default withEnglishFallback({
|
||||
stepGrantAdmin: "{user}에게 관리자 권한 부여",
|
||||
stepGrantDatabase: '{user}에게 데이터베이스 "{database}" 접근 권한 부여',
|
||||
stepGrantTable: '{user}에게 테이블 "{database}.{table}" 접근 권한 부여',
|
||||
stepRevokeDatabase: '{user}의 데이터베이스 "{database}" 접근 권한 회수',
|
||||
stepRevokeTable: '{user}의 테이블 "{database}.{table}" 접근 권한 회수',
|
||||
stepGrantObjects: '"{database}"에서 {user}에게 스키마 및 객체 권한 부여',
|
||||
stepGrantCurrentObjects: '"{database}"에서 "{schema}" 스키마의 현재 {scope}을(를) {user}에게 부여',
|
||||
stepGrantFutureObjects: '"{database}"에서 현재 역할이 생성할 향후 {scope}을(를) {user}에게 부여',
|
||||
|
||||
@@ -3911,6 +3911,8 @@ export default withEnglishFallback({
|
||||
noGrants: "Nenhuma permissão encontrada",
|
||||
privilegeEditor: "Editor de Privilégios",
|
||||
privilegeHint: "Escolha privilégios, banco de dados e tabela, então visualize o SQL antes de aplicar. * significa global ou todas as tabelas.",
|
||||
privilegeAppendHint: "Escolha os bancos de dados e especifique o escopo das tabelas, depois configure os privilégios por banco de dados. A concessão é aditiva e não revoga privilégios existentes.",
|
||||
globalScope: "Global (*.*)",
|
||||
database: "Banco de dados",
|
||||
schema: "Schema",
|
||||
table: "Tabela",
|
||||
@@ -3922,6 +3924,7 @@ export default withEnglishFallback({
|
||||
memberRole: "Função membro",
|
||||
privileges: "Privilégios",
|
||||
grantOption: "Permitir mais concessões (WITH GRANT OPTION)",
|
||||
grantOptionHint: "Aplica-se apenas às instruções GRANT; o REVOKE o ignora.",
|
||||
adminOption: "Permitir gerenciamento de associação (WITH ADMIN OPTION)",
|
||||
grant: "Conceder",
|
||||
revoke: "Revogar",
|
||||
@@ -3985,6 +3988,8 @@ export default withEnglishFallback({
|
||||
stepGrantAdmin: "Conceder privilégios administrativos a {user}",
|
||||
stepGrantDatabase: 'Conceder a {user} acesso ao banco de dados "{database}"',
|
||||
stepGrantTable: 'Conceder a {user} acesso à tabela "{database}.{table}"',
|
||||
stepRevokeDatabase: 'Revogar o acesso de {user} ao banco de dados "{database}"',
|
||||
stepRevokeTable: 'Revogar o acesso de {user} à tabela "{database}.{table}"',
|
||||
stepGrantObjects: 'Conceder a {user} privilégios de schema e objeto em "{database}"',
|
||||
stepGrantCurrentObjects: 'Conceder a {user} privilégios sobre {scope} existentes no schema "{schema}" de "{database}"',
|
||||
stepGrantFutureObjects: 'Conceder a {user} privilégios sobre futuros {scope} criados pela função atual em "{database}"',
|
||||
|
||||
@@ -3897,6 +3897,8 @@ export default withEnglishFallback({
|
||||
noGrants: "Yetki bulunamadı",
|
||||
privilegeEditor: "Yetki Düzenleyici",
|
||||
privilegeHint: "Yetkileri, veritabanını ve tabloyu seçin, ardından uygulamadan önce SQL'i önizleyin. * genel ya da tüm tablolar anlamına gelir.",
|
||||
privilegeAppendHint: "Veritabanlarını seçin ve tablo kapsamını belirleyin, ardından her veritabanı için yetkileri yapılandırın. Yetkilendirme eklemeli olarak yapılır ve mevcut yetkileri geri almaz.",
|
||||
globalScope: "Genel (*.*)",
|
||||
database: "Veritabanı",
|
||||
schema: "Şema",
|
||||
table: "Tablo",
|
||||
@@ -3908,6 +3910,7 @@ export default withEnglishFallback({
|
||||
memberRole: "Üye rol",
|
||||
privileges: "Yetkiler",
|
||||
grantOption: "Yetki devrine izin ver (WITH GRANT OPTION)",
|
||||
grantOptionHint: "Yalnızca GRANT ifadeleri için geçerlidir; REVOKE bunu yok sayar.",
|
||||
adminOption: "Üyelik yönetimine izin ver (WITH ADMIN OPTION)",
|
||||
grant: "Yetki ver",
|
||||
revoke: "Yetkiyi geri al",
|
||||
@@ -3972,6 +3975,8 @@ export default withEnglishFallback({
|
||||
stepGrantAdmin: "{user} kullanıcısına yönetici yetkileri ver",
|
||||
stepGrantDatabase: '{user} kullanıcısına "{database}" veritabanına erişim ver',
|
||||
stepGrantTable: '{user} kullanıcısına "{database}.{table}" tablosuna erişim ver',
|
||||
stepRevokeDatabase: '{user} kullanıcısının "{database}" veritabanına erişimini geri al',
|
||||
stepRevokeTable: '{user} kullanıcısının "{database}.{table}" tablosuna erişimini geri al',
|
||||
stepGrantObjects: '{user} kullanıcısına "{database}" içinde şema ve nesne yetkileri ver',
|
||||
stepGrantCurrentObjects: '"{database}" içindeki "{schema}" şemasında mevcut {scope} üzerinde {user} kullanıcısına yetki ver',
|
||||
stepGrantFutureObjects: '"{database}" içinde geçerli rolün ileride oluşturacağı {scope} üzerinde {user} kullanıcısına yetki ver',
|
||||
|
||||
@@ -4191,6 +4191,8 @@ export default withEnglishFallback({
|
||||
noGrants: "暂无授权信息",
|
||||
privilegeEditor: "权限编辑",
|
||||
privilegeHint: "选择权限、数据库和表,预览 SQL 后执行。* 表示全局或全部表。",
|
||||
privilegeAppendHint: "选择数据库并指定表范围,按每个数据库配置权限;授权为追加式,不会回收该用户已有的权限。",
|
||||
globalScope: "全局 (*.*)",
|
||||
database: "数据库",
|
||||
schema: "Schema",
|
||||
table: "表",
|
||||
@@ -4202,6 +4204,7 @@ export default withEnglishFallback({
|
||||
memberRole: "成员角色",
|
||||
privileges: "权限",
|
||||
grantOption: "允许继续授权 (WITH GRANT OPTION)",
|
||||
grantOptionHint: "仅对授权语句生效,撤权时不生效。",
|
||||
adminOption: "允许管理成员关系 (WITH ADMIN OPTION)",
|
||||
grant: "授权",
|
||||
revoke: "撤权",
|
||||
@@ -4265,6 +4268,8 @@ export default withEnglishFallback({
|
||||
stepGrantAdmin: "为 {user} 授予管理权限",
|
||||
stepGrantDatabase: "为 {user} 授予数据库「{database}」权限",
|
||||
stepGrantTable: "为 {user} 授予表「{database}.{table}」权限",
|
||||
stepRevokeDatabase: "回收 {user} 在数据库「{database}」的权限",
|
||||
stepRevokeTable: "回收 {user} 在表「{database}.{table}」的权限",
|
||||
stepGrantObjects: "为 {user} 授予数据库「{database}」的 Schema 与对象权限",
|
||||
stepGrantCurrentObjects: "为 {user} 授予数据库「{database}」中 Schema「{schema}」的现有{scope}权限",
|
||||
stepGrantFutureObjects: "为 {user} 授予当前角色在数据库「{database}」中未来创建的{scope}权限",
|
||||
|
||||
@@ -7950,6 +7950,8 @@ export default withEnglishFallback({
|
||||
noGrants: "未找到授權",
|
||||
privilegeEditor: "權限編輯器",
|
||||
privilegeHint: "選擇權限、資料庫和資料表,然後在套用前預覽 SQL。* 表示全域或所有資料表。",
|
||||
privilegeAppendHint: "選擇資料庫並指定資料表範圍,為每個資料庫個別設定權限;授權為累加式,不會回收該使用者既有的權限。",
|
||||
globalScope: "全域 (*.*)",
|
||||
database: "資料庫",
|
||||
schema: "Schema",
|
||||
table: "資料表",
|
||||
@@ -7961,6 +7963,7 @@ export default withEnglishFallback({
|
||||
memberRole: "成員角色",
|
||||
privileges: "權限",
|
||||
grantOption: "允許進一步授權(WITH GRANT OPTION)",
|
||||
grantOptionHint: "僅對授權語句生效,撤權時不生效。",
|
||||
adminOption: "允許成員管理(WITH ADMIN OPTION)",
|
||||
grant: "授予",
|
||||
revoke: "撤銷",
|
||||
@@ -8024,6 +8027,8 @@ export default withEnglishFallback({
|
||||
stepGrantAdmin: "為 {user} 授予管理權限",
|
||||
stepGrantDatabase: "為 {user} 授予資料庫「{database}」權限",
|
||||
stepGrantTable: "為 {user} 授予資料表「{database}.{table}」權限",
|
||||
stepRevokeDatabase: "回收 {user} 在資料庫「{database}」的權限",
|
||||
stepRevokeTable: "回收 {user} 在資料表「{database}.{table}」的權限",
|
||||
stepGrantObjects: "為 {user} 授予資料庫「{database}」的 Schema 與物件權限",
|
||||
stepGrantCurrentObjects: "為 {user} 授予資料庫「{database}」中 Schema「{schema}」的既有{scope}權限",
|
||||
stepGrantFutureObjects: "為 {user} 授予目前角色在資料庫「{database}」中未來建立的{scope}權限",
|
||||
|
||||
+76
-11
@@ -42,7 +42,7 @@ function modelDialog(): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => (attrs.open ? h("div", attrs, slots.default?.()) : null);
|
||||
return () => (attrs.open ? h("div", { ...attrs, "data-test-dialog": "true" }, slots.default?.()) : null);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -163,7 +163,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("DatabaseUserAdmin MySQL grant loading", () => {
|
||||
it("syncs privilege buttons and grant option from loaded SHOW GRANTS rows", async () => {
|
||||
it("syncs the grant option from loaded SHOW GRANTS rows and renders the database scope editor", async () => {
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "mysql_native_password"]] }).mockResolvedValueOnce({ columns: ["Grants for root@%"], rows: [["GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION"]] });
|
||||
|
||||
@@ -175,12 +175,12 @@ describe("DatabaseUserAdmin MySQL grant loading", () => {
|
||||
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
|
||||
await nextTick();
|
||||
|
||||
const privilegeButton = Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.trim() === "INSERT");
|
||||
const grantOptionLabel = Array.from(root.querySelectorAll("label")).find((label) => label.textContent?.includes("userAdmin.grantOption"));
|
||||
const grantOptionInput = grantOptionLabel?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
|
||||
expect(privilegeButton?.className).toContain("border-primary");
|
||||
expect(grantOptionInput?.checked).toBe(true);
|
||||
// MySQL 的权限编辑改为按数据库/表配置授权范围(与新增用户一致),固定的权限按钮由范围编辑器取代
|
||||
expect(root.querySelector('input[placeholder="userAdmin.searchDatabase"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the current Doris user when SHOW ALL GRANTS requires GRANT_PRIV", async () => {
|
||||
@@ -219,6 +219,12 @@ function findButton(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(root?.querySelectorAll("button") ?? []).find((button) => button.textContent?.trim() === text);
|
||||
}
|
||||
|
||||
// 权限编辑面板与新增用户弹窗复用同一个授权范围编辑器,断言弹窗内的控件时需要限定在弹窗范围内
|
||||
function findDialogButton(text: string): HTMLButtonElement | undefined {
|
||||
const dialog = root?.querySelector('[data-test-dialog="true"]');
|
||||
return Array.from(dialog?.querySelectorAll("button") ?? []).find((button) => button.textContent?.trim() === text);
|
||||
}
|
||||
|
||||
describe("DatabaseUserAdmin MySQL create-user table grants", () => {
|
||||
it("loads tables, requires a selection, and previews only the selected table grant", async () => {
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
@@ -237,22 +243,22 @@ describe("DatabaseUserAdmin MySQL create-user table grants", () => {
|
||||
|
||||
findButton("userAdmin.newUser")?.click();
|
||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("native-mysql"));
|
||||
await nextTick();
|
||||
findButton("scope_test")?.click();
|
||||
await vi.waitFor(() => expect(findDialogButton("scope_test")).toBeDefined());
|
||||
findDialogButton("scope_test")?.click();
|
||||
const password = root.querySelector<HTMLInputElement>('[data-password-input="true"]');
|
||||
password!.value = "test-password";
|
||||
password!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
findButton("userAdmin.specificTables")?.click();
|
||||
findDialogButton("userAdmin.specificTables")?.click();
|
||||
await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalledWith("native-mysql", "scope_test", ""));
|
||||
await nextTick();
|
||||
expect(findButton("userAdmin.previewSql")?.disabled).toBe(true);
|
||||
expect(findDialogButton("userAdmin.previewSql")?.disabled).toBe(true);
|
||||
|
||||
findButton("allowed_table")?.click();
|
||||
findDialogButton("allowed_table")?.click();
|
||||
await nextTick();
|
||||
expect(findButton("userAdmin.previewSql")?.disabled).toBe(false);
|
||||
findButton("userAdmin.previewSql")?.click();
|
||||
expect(findDialogButton("userAdmin.previewSql")?.disabled).toBe(false);
|
||||
findDialogButton("userAdmin.previewSql")?.click();
|
||||
|
||||
await vi.waitFor(() => expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON `scope_test`.`allowed_table` TO 'app_user'@'%';"));
|
||||
expect(root.textContent).not.toContain("ON `scope_test`.*");
|
||||
@@ -260,6 +266,65 @@ describe("DatabaseUserAdmin MySQL create-user table grants", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DatabaseUserAdmin MySQL privilege editing", () => {
|
||||
it("previews one grant per selected table for the selected user", async () => {
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'root'@'%'"]] });
|
||||
mocks.listDatabases.mockResolvedValue([{ name: "scope_test" }]);
|
||||
mocks.listTables.mockResolvedValue([
|
||||
{ name: "allowed_table", table_type: "BASE TABLE" },
|
||||
{ name: "blocked_table", table_type: "BASE TABLE" },
|
||||
]);
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(DatabaseUserAdmin, { connection: nativeMysqlConnection });
|
||||
app.mount(root);
|
||||
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(findButton("scope_test")).toBeDefined());
|
||||
|
||||
findButton("scope_test")?.click();
|
||||
await nextTick();
|
||||
findButton("userAdmin.specificTables")?.click();
|
||||
await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalledWith("native-mysql", "scope_test", ""));
|
||||
await nextTick();
|
||||
|
||||
findButton("allowed_table")?.click();
|
||||
findButton("blocked_table")?.click();
|
||||
await nextTick();
|
||||
findButton("userAdmin.grant")?.click();
|
||||
|
||||
await vi.waitFor(() => expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON `scope_test`.`allowed_table` TO 'root'@'%';"));
|
||||
expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON `scope_test`.`blocked_table` TO 'root'@'%';");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DatabaseUserAdmin MySQL global scope", () => {
|
||||
it("grants the global scope through the fixed entry and hides table scope", async () => {
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'root'@'%'"]] });
|
||||
mocks.listDatabases.mockResolvedValue([{ name: "scope_test" }]);
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(DatabaseUserAdmin, { connection: nativeMysqlConnection });
|
||||
app.mount(root);
|
||||
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(findButton("userAdmin.globalScope")).toBeDefined());
|
||||
|
||||
findButton("userAdmin.globalScope")?.click();
|
||||
await nextTick();
|
||||
|
||||
// 全局作用域没有表级粒度,不应出现表范围控件
|
||||
expect(findButton("userAdmin.specificTables")).toBeUndefined();
|
||||
|
||||
findButton("userAdmin.grant")?.click();
|
||||
|
||||
await vi.waitFor(() => expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON *.* TO 'root'@'%';"));
|
||||
expect(root?.textContent).not.toContain("ON `scope_test`.*");
|
||||
});
|
||||
});
|
||||
|
||||
async function mountNativeMysqlUserAdmin() {
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["same-user", "old-host", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'same-user'@'old-host'"]] });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mysqlUserAdminProvider, postgresUserAdminProvider } from "@/lib/database/databaseUserAdmin";
|
||||
import { authorizationPlanSql, buildCreateDatabaseAuthorizationPlan, buildCreateUserAuthorizationPlan, executeAuthorizationPlan } from "@/lib/database/databaseAuthorizationPlan";
|
||||
import { dorisUserAdminProvider, mysqlUserAdminProvider, postgresUserAdminProvider, starrocksUserAdminProvider } from "@/lib/database/databaseUserAdmin";
|
||||
import { authorizationPlanSql, buildCreateDatabaseAuthorizationPlan, buildCreateUserAuthorizationPlan, buildGrantAuthorizationPlan, executeAuthorizationPlan } from "@/lib/database/databaseAuthorizationPlan";
|
||||
|
||||
describe("database authorization plans", () => {
|
||||
it("grants PostgreSQL presets across user schemas and future objects", () => {
|
||||
@@ -184,4 +184,55 @@ describe("database authorization plans", () => {
|
||||
|
||||
expect(results.map((result) => result.status)).toEqual(["failed", "skipped"]);
|
||||
});
|
||||
|
||||
it("expands an existing user's privilege change into one statement per selected table", () => {
|
||||
const plan = buildGrantAuthorizationPlan({
|
||||
provider: mysqlUserAdminProvider,
|
||||
user: { user: "app_user", host: "%" },
|
||||
databases: [{ database: "app_db", preset: "readOnly", tables: ["orders", "audit_log"] }],
|
||||
grantOption: true,
|
||||
});
|
||||
const sql = authorizationPlanSql(plan);
|
||||
|
||||
expect(plan.steps).toHaveLength(2);
|
||||
expect(plan.steps.map((step) => step.operation)).toEqual(["grantDatabase", "grantDatabase"]);
|
||||
expect(plan.steps.map((step) => step.targetTable)).toEqual(["orders", "audit_log"]);
|
||||
expect(sql).toContain("GRANT SELECT, SHOW VIEW ON `app_db`.`orders` TO 'app_user'@'%' WITH GRANT OPTION;");
|
||||
expect(sql).toContain("GRANT SELECT, SHOW VIEW ON `app_db`.`audit_log` TO 'app_user'@'%' WITH GRANT OPTION;");
|
||||
expect(sql).not.toContain("CREATE ROUTINE");
|
||||
});
|
||||
|
||||
it("keeps the global scope as *.* for grant and revoke", () => {
|
||||
const selected = [{ database: "*", preset: "readOnly" as const }];
|
||||
const grant = buildGrantAuthorizationPlan({ provider: mysqlUserAdminProvider, user: { user: "local_admin", host: "%" }, databases: selected });
|
||||
const revoke = buildGrantAuthorizationPlan({ provider: mysqlUserAdminProvider, user: { user: "local_admin", host: "%" }, databases: selected, revoke: true });
|
||||
|
||||
expect(authorizationPlanSql(grant)).toContain("GRANT SELECT, SHOW VIEW ON *.* TO 'local_admin'@'%';");
|
||||
expect(authorizationPlanSql(revoke)).toContain("REVOKE SELECT, SHOW VIEW ON *.* FROM 'local_admin'@'%';");
|
||||
});
|
||||
|
||||
it("builds REVOKE steps without grant options", () => {
|
||||
const plan = buildGrantAuthorizationPlan({
|
||||
provider: mysqlUserAdminProvider,
|
||||
user: { user: "app_user", host: "%" },
|
||||
databases: [{ database: "app_db", preset: "readWrite", tables: ["orders"] }],
|
||||
grantOption: true,
|
||||
revoke: true,
|
||||
});
|
||||
|
||||
expect(plan.steps.map((step) => step.operation)).toEqual(["revokePrivileges"]);
|
||||
expect(plan.steps[0].targetTable).toBe("orders");
|
||||
expect(authorizationPlanSql(plan)).toBe("-- revoke app_user@% app_db.orders (connection scope)\nREVOKE SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, REFERENCES, SHOW VIEW, CREATE VIEW, TRIGGER ON `app_db`.`orders` FROM 'app_user'@'%';");
|
||||
expect(authorizationPlanSql(plan)).not.toContain("GRANT OPTION");
|
||||
});
|
||||
|
||||
it("leaves Doris and StarRocks to their own privilege names", () => {
|
||||
for (const provider of [dorisUserAdminProvider, starrocksUserAdminProvider]) {
|
||||
// 两者都能生成 GRANT SQL,但权限名与 MySQL 预设不同,因此不应由本计划构造语句
|
||||
expect(provider.grantPrivilegesSql).toBeTypeOf("function");
|
||||
const plan = buildGrantAuthorizationPlan({ provider, user: { user: "app_user", host: "%" }, databases: [{ database: "app_db", preset: "readOnly" }] });
|
||||
|
||||
expect(plan.steps).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { mysqlUserAccount, quoteMySqlIdentifier, quotePostgresIdentifier, type C
|
||||
export type AuthorizationAccountType = "standard" | "admin";
|
||||
export type AuthorizationPreset = "readWrite" | "readOnly" | "ddl" | "dml" | "custom";
|
||||
export type AuthorizationTargetScope = "database" | "table";
|
||||
export type AuthorizationStepOperation = "createUser" | "grantAdmin" | "createDatabase" | "grantDatabase" | "grantCurrentObjects" | "grantFutureObjects";
|
||||
export type AuthorizationStepOperation = "createUser" | "grantAdmin" | "createDatabase" | "grantDatabase" | "revokePrivileges" | "grantCurrentObjects" | "grantFutureObjects";
|
||||
export type AuthorizationObjectScope = "schemas" | "tables" | "sequences" | "functions";
|
||||
|
||||
export interface DatabaseAuthorizationSelection {
|
||||
@@ -149,6 +149,57 @@ export function buildCreateUserAuthorizationPlan(input: CreateUserAuthorizationP
|
||||
return { steps };
|
||||
}
|
||||
|
||||
export interface GrantAuthorizationPlanInput {
|
||||
provider: DatabaseUserAdminProvider;
|
||||
/** 目标用户身份 */
|
||||
user: DatabaseUserIdentity;
|
||||
/** 需要授权(或撤权)的库/表选择 */
|
||||
databases: DatabaseAuthorizationSelection[];
|
||||
/** 是否携带 WITH GRANT OPTION,仅对授权语句生效 */
|
||||
grantOption?: boolean;
|
||||
/** 为 true 时生成 REVOKE,默认生成 GRANT */
|
||||
revoke?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为“已存在的用户”构造授权计划:把多库/多表选择展开为多条独立的 GRANT / REVOKE。
|
||||
* 与 buildCreateUserAuthorizationPlan 的差异:
|
||||
* 1. 不包含创建用户步骤,因此各步骤之间没有依赖关系;
|
||||
* 2. 采用追加式语义,只对勾选的库/表生成语句,不做差异比对,也不会回收用户已有的多余权限;
|
||||
* 3. 目前仅用于 MySQL 表级/库级授权,其它方言返回空计划,由调用方沿用原有的单条 SQL 逻辑。
|
||||
*/
|
||||
export function buildGrantAuthorizationPlan(input: GrantAuthorizationPlanInput): AuthorizationPlan {
|
||||
const changePrivilegesSql = input.revoke ? input.provider.revokePrivilegesSql : input.provider.grantPrivilegesSql;
|
||||
if (!changePrivilegesSql) return { steps: [] };
|
||||
// 以 provider 的库/表级授权能力作为判据:Doris / StarRocks 的 dialect 同为 "mysql",
|
||||
// 但它们的权限名与 MySQL 预设不同(SELECT_PRIV 等),仅比较 dialect 会生成无效语句。
|
||||
if (!input.provider.supportsTableGrantsOnCreate) return { steps: [] };
|
||||
const steps: AuthorizationPlanStep[] = [];
|
||||
for (const selection of input.databases) {
|
||||
const database = selection.database.trim();
|
||||
if (!database) continue;
|
||||
// 指定了表则按表级生效,否则作用于该库下的全部表
|
||||
const targetScope = input.provider.supportsTableGrantsOnCreate && selection.tables !== undefined ? "table" : "database";
|
||||
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges, targetScope);
|
||||
if (privileges.length === 0) continue;
|
||||
const tables = targetScope === "database" ? ["*"] : uniqueNames(selection.tables ?? []);
|
||||
for (const table of tables) {
|
||||
const targetTable = table === "*" ? undefined : table;
|
||||
steps.push({
|
||||
id: `change-${steps.length}`,
|
||||
label: `${input.revoke ? "revoke" : "grant"} ${input.provider.label(input.user)} ${targetTable ? `${database}.${targetTable}` : database}`,
|
||||
database: "",
|
||||
sql: changePrivilegesSql({ user: input.user, privileges, database, table, grantOption: input.grantOption, scope: "mysql" }),
|
||||
operation: input.revoke ? "revokePrivileges" : "grantDatabase",
|
||||
subject: input.provider.label(input.user),
|
||||
targetDatabase: database,
|
||||
targetTable,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { steps };
|
||||
}
|
||||
|
||||
export function buildCreateDatabaseAuthorizationPlan(input: CreateDatabaseAuthorizationPlanInput): AuthorizationPlan {
|
||||
const createStepId = "create-database";
|
||||
const steps: AuthorizationPlanStep[] = [
|
||||
|
||||
Reference in new issue
Block a user