feat(mysql): support table-scoped grants for new users

This commit is contained in:
zipg authored and GitHub committed 2026-08-18 14:50:48 +08:00
1 parent 2eee29c0bf
commit 61103523de
13 files changed
+407 -24

No files matched your search

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Check, Globe2, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Unlock, UserRound } from "@lucide/vue";
import { AlertTriangle, Check, ChevronDown, Globe2, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Table2, 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 { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
@@ -77,6 +78,10 @@ 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>>({});
let createPlanRequestId = 0;
const provider = computed(() => resolveDatabaseUserAdminProviderForConnection(props.connection));
@@ -90,6 +95,7 @@ const canDropUser = computed(() => !!provider.value?.dropUserSql);
const canGrantPrivileges = computed(() => !!provider.value?.grantPrivilegesSql);
const canRevokePrivileges = computed(() => !!provider.value?.revokePrivilegesSql);
const canEditPrivileges = computed(() => canGrantPrivileges.value || canRevokePrivileges.value);
const supportsCreateTableGrants = computed(() => !!provider.value?.supportsTableGrantsOnCreate);
const selectedUser = computed(() => users.value.find((user) => userKey(user) === selectedUserKey.value));
const filteredUsers = computed(() => {
const query = search.value.trim().toLowerCase();
@@ -109,13 +115,18 @@ 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 createAuthorizationPrivileges = computed(() => (provider.value ? authorizationPrivileges(provider.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) => selection.preset !== "custom" || (selection.privileges?.length ?? 0) > 0));
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 pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined));
const canPreviewHostChange = computed(() => {
const host = newHost.value.trim();
@@ -233,6 +244,10 @@ async function openCreateUserDialog() {
createPassword.value = "";
createDatabaseSearch.value = "";
createDatabaseAuthorizations.value = [];
createDatabaseTables.value = {};
createDatabaseTablesLoading.value = {};
createDatabaseTableErrors.value = {};
createDatabaseTableSearch.value = {};
if (createDatabasesLoading.value) return;
createDatabases.value = [];
createDatabasesLoading.value = true;
@@ -260,6 +275,82 @@ function toggleCreateDatabase(database: string) {
}
}
function createPrivilegesForAuthorization(selection?: DatabaseAuthorizationSelection): string[] {
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;
@@ -371,7 +462,9 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
const step = result.step;
if (step.operation === "createUser") return t("userAdmin.stepCreateUser");
if (step.operation === "grantAdmin") return t("userAdmin.stepGrantAdmin", { user: step.subject });
if (step.operation === "grantDatabase") return t("userAdmin.stepGrantDatabase", { user: step.subject, database: step.targetDatabase });
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 === "grantCurrentObjects") {
return t("userAdmin.stepGrantCurrentObjects", {
user: step.subject,
@@ -511,6 +604,10 @@ watch(
users.value = [];
createDatabases.value = [];
createDatabaseAuthorizations.value = [];
createDatabaseTables.value = {};
createDatabaseTablesLoading.value = {};
createDatabaseTableErrors.value = {};
createDatabaseTableSearch.value = {};
selectedUserKey.value = "";
grants.value = [];
grantsLoaded.value = false;
@@ -794,9 +891,65 @@ onMounted(loadUsers);
</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 createAuthorizationPrivileges"
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]"