From cc518e8753d20fe802ab67c8e1d46a8992203891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Thu, 16 Jul 2026 18:48:29 +0800 Subject: [PATCH] feat(database): add creation and authorization flows --- .../components/admin/DatabaseUserAdmin.vue | 300 +++++++++++-- .../sidebar/SidebarTreeItemDialogs.vue | 94 +++- .../src/components/sidebar/TreeItem.vue | 161 ++++++- .../sidebar/sidebarTreeDialogState.ts | 18 + apps/desktop/src/i18n/locales/en.ts | 54 +++ apps/desktop/src/i18n/locales/es.ts | 54 +++ apps/desktop/src/i18n/locales/it.ts | 54 +++ apps/desktop/src/i18n/locales/ja.ts | 54 +++ apps/desktop/src/i18n/locales/pt-BR.ts | 54 +++ apps/desktop/src/i18n/locales/zh-CN.ts | 54 +++ apps/desktop/src/i18n/locales/zh-TW.ts | 54 +++ .../databaseAuthorizationPlan.spec.ts | 156 +++++++ .../lib/database/databaseAuthorizationPlan.ts | 423 ++++++++++++++++++ .../src/lib/database/databaseUserAdmin.ts | 6 +- 14 files changed, 1500 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/database/databaseAuthorizationPlan.spec.ts create mode 100644 apps/desktop/src/lib/database/databaseAuthorizationPlan.ts diff --git a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue index 75567a9dff..12ccdbde57 100644 --- a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue +++ b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue @@ -15,6 +15,18 @@ import type { ConnectionConfig } from "@/types/database"; import * as api from "@/lib/backend/api"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { grantsFromQueryResult, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin"; +import { + authorizationPlanSql, + authorizationPlanStatus, + authorizationPrivileges, + buildCreateUserAuthorizationPlan, + executeAuthorizationPlan, + type AuthorizationAccountType, + type AuthorizationPlan, + type AuthorizationPreset, + type AuthorizationStepResult, + type DatabaseAuthorizationSelection, +} from "@/lib/database/databaseAuthorizationPlan"; const props = defineProps<{ connection: ConnectionConfig; @@ -32,6 +44,7 @@ const search = ref(""); const loadingUsers = ref(false); const loadingGrants = ref(false); const applying = ref(false); +const preparingCreatePlan = ref(false); const loadError = ref(""); const grantError = ref(""); @@ -39,6 +52,8 @@ const createDialogOpen = ref(false); const passwordDialogOpen = ref(false); const sqlDialogOpen = ref(false); const pendingSql = ref(""); +const pendingPlan = ref(); +const pendingResults = ref([]); const pendingDanger = ref(false); const pendingAfterApply = ref<(() => Promise) | undefined>(); @@ -53,6 +68,12 @@ const privilegeRole = ref(""); const grantOption = ref(false); const selectedPrivileges = ref(["SELECT"]); const createCanLogin = ref(true); +const createAccountType = ref("standard"); +const createDatabases = ref([]); +const createDatabasesLoading = ref(false); +const createDatabaseSearch = ref(""); +const createDatabaseAuthorizations = ref([]); +let createPlanRequestId = 0; const provider = computed(() => resolveDatabaseUserAdminProviderForConnection(props.connection)); const supported = computed(() => provider.value !== null); @@ -83,6 +104,14 @@ 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 pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined)); function userKey(user: DatabaseUserIdentity): string { return `${user.user}\u0000${user.host}`; @@ -162,8 +191,58 @@ 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 = []; + if (createDatabasesLoading.value) return; + createDatabases.value = []; + createDatabasesLoading.value = true; + try { + await ensureConnection(); + createDatabases.value = (await api.listDatabases(props.connection.id)).map((database) => database.name); + } catch (error: any) { + toast(t("userAdmin.loadDatabasesFailed", { message: error?.message || String(error) }), 5000); + } finally { + createDatabasesLoading.value = false; + } +} + +function createAuthorization(database: string): DatabaseAuthorizationSelection | undefined { + return createDatabaseAuthorizations.value.find((selection) => selection.database === database); +} + +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 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); +} + function previewSql(sql: string, options: { danger?: boolean; afterApply?: () => Promise } = {}) { pendingSql.value = sql; + pendingPlan.value = undefined; + pendingResults.value = []; pendingDanger.value = !!options.danger; pendingAfterApply.value = options.afterApply; sqlDialogOpen.value = true; @@ -178,12 +257,23 @@ async function applyPendingSql() { database: "", sql: pendingSql.value, source: t("production.sourceAdmin"), - execute: () => api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 }), + execute: async () => { + if (pendingPlan.value) { + return executeAuthorizationPlan(pendingPlan.value, (step) => api.executeMulti(props.connection.id, step.database, step.sql, undefined, undefined, { maxRows: 1000, continueOnError: true })); + } + const queryResults = await api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000, continueOnError: true }); + const failed = queryResults.find((item) => item.execution_error === true); + if (failed) throw new Error(String(failed.rows[0]?.[0] ?? t("userAdmin.applyFailedUnknown"))); + return [] as AuthorizationStepResult[]; + }, }); if (!result) return; - toast(t("userAdmin.applySuccess"), 2500); - sqlDialogOpen.value = false; - await (pendingAfterApply.value?.() ?? Promise.resolve()); + pendingResults.value = result; + const status = result.length > 0 ? authorizationPlanStatus(result) : "success"; + toast(t(status === "success" ? "userAdmin.applySuccess" : status === "partial" ? "userAdmin.applyPartial" : "userAdmin.applyFailedSummary"), status === "success" ? 2500 : 5000); + const createSucceeded = !pendingPlan.value || result.some((item) => item.step.id === "create-user" && item.status === "success"); + if (createSucceeded) await (pendingAfterApply.value?.() ?? Promise.resolve()); + if (!pendingPlan.value) sqlDialogOpen.value = false; await loadUsers(); await loadGrants(); } catch (error: any) { @@ -193,25 +283,81 @@ async function applyPendingSql() { } } -function previewCreateUser() { +async function previewCreateUser() { const userProvider = provider.value; - const createUserSql = userProvider?.createUserSql; - if (!createUserSql) return; - if (!createUser.value.trim() || !createPassword.value) return; - previewSql( - createUserSql({ + if (!userProvider?.createUserSql || preparingCreatePlan.value) return; + if (!createUser.value.trim() || !createPassword.value || !createDatabaseAuthorizationsValid.value) return; + const requestId = ++createPlanRequestId; + preparingCreatePlan.value = true; + try { + const principal = { user: createUser.value.trim(), host: createHost.value.trim() || "%", password: createPassword.value, canLogin: createCanLogin.value, - }), - { - afterApply: async () => { - createDialogOpen.value = false; - createPassword.value = ""; - }, - }, - ); + }; + const databases = + createAccountType.value === "standard" && userProvider.dialect === "postgres" + ? await Promise.all( + createDatabaseAuthorizations.value.map(async (selection) => ({ + ...selection, + schemas: await api.listSchemas(props.connection.id, selection.database), + })), + ) + : createAccountType.value === "standard" + ? createDatabaseAuthorizations.value + : []; + if (requestId !== createPlanRequestId || !createDialogOpen.value) return; + const plan = buildCreateUserAuthorizationPlan({ + provider: userProvider, + principal, + accountType: createAccountType.value, + databases, + }); + pendingPlan.value = plan; + pendingSql.value = authorizationPlanSql(plan); + pendingResults.value = []; + pendingDanger.value = createAccountType.value === "admin"; + pendingAfterApply.value = async () => { + createDialogOpen.value = false; + createPassword.value = ""; + selectedUserKey.value = userKey({ user: principal.user, host: isPostgres.value ? (principal.canLogin ? "LOGIN" : "ROLE") : principal.host }); + }; + sqlDialogOpen.value = true; + } catch (error: any) { + if (requestId === createPlanRequestId) toast(t("userAdmin.prepareAuthorizationFailed", { message: error?.message || String(error) }), 5000); + } finally { + if (requestId === createPlanRequestId) preparingCreatePlan.value = false; + } +} + +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 === "grantCurrentObjects") { + return t("userAdmin.stepGrantCurrentObjects", { + user: step.subject, + database: step.targetDatabase, + schema: step.schema, + scope: authorizationObjectScopeLabel(step.objectScope), + }); + } + if (step.operation === "grantFutureObjects") { + return step.owner + ? t("userAdmin.stepGrantFutureObjectsForOwner", { user: step.subject, database: step.targetDatabase, owner: step.owner, scope: authorizationObjectScopeLabel(step.objectScope) }) + : t("userAdmin.stepGrantFutureObjects", { user: step.subject, database: step.targetDatabase, scope: authorizationObjectScopeLabel(step.objectScope) }); + } + return step.label; +} + +function authorizationObjectScopeLabel(scope: AuthorizationStepResult["step"]["objectScope"]): string { + if (scope === "schemas") return t("userAdmin.scopeSchemas"); + if (scope === "tables") return t("userAdmin.scopeTables"); + if (scope === "sequences") return t("userAdmin.scopeSequences"); + if (scope === "functions") return t("userAdmin.scopeFunctions"); + return ""; } function previewPasswordChange() { @@ -291,6 +437,15 @@ function resetPrivilegeDefaults(scope: PrivilegeScope) { } } +watch( + () => createDialogOpen.value, + (open) => { + if (open) return; + createPlanRequestId += 1; + preparingCreatePlan.value = false; + }, +); + watch( () => selectedUserKey.value, () => void loadGrants(), @@ -300,6 +455,8 @@ watch( () => props.connection.id, () => { users.value = []; + createDatabases.value = []; + createDatabaseAuthorizations.value = []; selectedUserKey.value = ""; grants.value = []; privilegeScope.value = provider.value?.defaultScope ?? "mysql"; @@ -339,7 +496,7 @@ onMounted(loadUsers); {{ t("grid.refresh") }} - @@ -508,11 +665,21 @@ onMounted(loadUsers); - + {{ t("userAdmin.newUser") }} -
+
+
+ + +