feat(mysql): support changing user account host

Closes #4677
This commit is contained in:
t8y2 committed 2026-08-14 00:41:17 +00:00
1 parent 2ecbac4a6f
commit c892c8d1b0
13 files changed
+279 -6

No files matched your search

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Check, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, 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";
@@ -51,6 +51,7 @@ const loadError = ref("");
const grantError = ref("");
const createDialogOpen = ref(false);
const hostDialogOpen = ref(false);
const passwordDialogOpen = ref(false);
const sqlDialogOpen = ref(false);
const pendingSql = ref("");
@@ -62,6 +63,7 @@ const pendingAfterApply = ref<(() => Promise<void>) | undefined>();
const createUser = ref("app_user");
const createHost = ref("%");
const createPassword = ref("");
const newHost = ref("");
const newPassword = ref("");
const privilegeDatabase = ref(props.connection.database || "*");
const privilegeTable = ref("*");
@@ -81,6 +83,7 @@ const provider = computed(() => resolveDatabaseUserAdminProviderForConnection(pr
const supported = computed(() => provider.value !== null);
const isPostgres = computed(() => provider.value?.dialect === "postgres");
const canCreateUser = computed(() => !!provider.value?.createUserSql);
const canRenameUser = computed(() => !!provider.value?.renameUserSql);
const canAlterPassword = computed(() => !!provider.value?.alterPasswordSql);
const canAlterLogin = computed(() => !!provider.value?.alterLoginSql);
const canDropUser = computed(() => !!provider.value?.dropUserSql);
@@ -114,6 +117,10 @@ const filteredCreateDatabases = computed(() => {
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));
const canPreviewHostChange = computed(() => {
const host = newHost.value.trim();
return !!host && host !== selectedUser.value?.host;
});
function syncPrivilegeSelectionFromGrants() {
const selectionFromGrants = provider.value?.privilegeSelectionFromGrants;
@@ -205,6 +212,13 @@ function selectUser(user: DatabaseUserIdentity) {
selectedUserKey.value = userKey(user);
}
function openHostDialog() {
const user = selectedUser.value;
if (!user || !provider.value?.renameUserSql) return;
newHost.value = user.host;
hostDialogOpen.value = true;
}
function togglePrivilege(privilege: string) {
const set = new Set(selectedPrivileges.value);
if (set.has(privilege)) set.delete(privilege);
@@ -396,6 +410,21 @@ function previewPasswordChange() {
});
}
function previewHostChange() {
const user = selectedUser.value;
const renameUserSql = provider.value?.renameUserSql;
const host = newHost.value.trim();
if (!user || !renameUserSql || !host || host === user.host) return;
previewSql(renameUserSql(user, host), {
danger: true,
afterApply: async () => {
hostDialogOpen.value = false;
newHost.value = "";
selectedUserKey.value = userKey({ ...user, host });
},
});
}
function previewDropUser() {
const user = selectedUser.value;
const userProvider = provider.value;
@@ -586,6 +615,10 @@ onMounted(loadUsers);
</Badge>
</div>
<div class="ml-auto flex items-center gap-1.5">
<Button v-if="canRenameUser" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="openHostDialog">
<Globe2 class="h-3.5 w-3.5" />
{{ t("userAdmin.changeHost") }}
</Button>
<Button v-if="canAlterPassword" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="passwordDialogOpen = true">
<KeyRound class="h-3.5 w-3.5" />
{{ t("userAdmin.changePassword") }}
@@ -807,6 +840,19 @@ onMounted(loadUsers);
</DialogContent>
</Dialog>
<Dialog v-model:open="hostDialogOpen">
<DialogContent class="max-w-sm">
<DialogHeader>
<DialogTitle>{{ t("userAdmin.changeHost") }}</DialogTitle>
</DialogHeader>
<Input v-model="newHost" :placeholder="t('userAdmin.newHost')" />
<DialogFooter>
<Button variant="outline" @click="hostDialogOpen = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!canPreviewHostChange" @click="previewHostChange">{{ t("userAdmin.previewSql") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="sqlDialogOpen">
<DialogContent class="max-w-2xl">
<DialogHeader>
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import en from "@/i18n/locales/en";
import es from "@/i18n/locales/es";
import itLocale from "@/i18n/locales/it";
import ja from "@/i18n/locales/ja";
import ko from "@/i18n/locales/ko";
import ptBR from "@/i18n/locales/pt-BR";
import zhCN from "@/i18n/locales/zh-CN";
import zhTW from "@/i18n/locales/zh-TW";
describe("user administration Host translations", () => {
it("provides the Host-change labels in English", () => {
expect(en.userAdmin.changeHost).toEqual(expect.any(String));
expect(en.userAdmin.newHost).toEqual(expect.any(String));
});
it("provides localized Host-change labels in every non-English locale", () => {
const locales = { es, it: itLocale, ja, ko, "pt-BR": ptBR, "zh-CN": zhCN, "zh-TW": zhTW };
for (const [locale, messages] of Object.entries(locales)) {
expect(messages.userAdmin.changeHost, locale).not.toBe(en.userAdmin.changeHost);
expect(messages.userAdmin.newHost, locale).not.toBe(en.userAdmin.newHost);
}
});
});
+2
View File
@@ -3066,6 +3066,8 @@ export default {
username: "Username",
host: "Host",
addHost: "Add Host",
changeHost: "Change Host",
newHost: "New Host",
changePassword: "Change Password",
newPassword: "New password",
lock: "Lock",
+2
View File
@@ -2910,6 +2910,8 @@ export default withEnglishFallback({
revoke: "Revocar",
username: "Nombre de usuario",
host: "Host",
changeHost: "Cambiar host",
newHost: "Nuevo host",
changePassword: "Cambiar contraseña",
newPassword: "Nueva contraseña",
lock: "Bloquear",
+2
View File
@@ -2908,6 +2908,8 @@ export default withEnglishFallback({
revoke: "Revoca",
username: "Nome utente",
host: "Host",
changeHost: "Modifica host",
newHost: "Nuovo host",
changePassword: "Cambia Password",
newPassword: "Nuova password",
lock: "Blocca",
+2
View File
@@ -2971,6 +2971,8 @@ export default withEnglishFallback({
revoke: "取り消し",
username: "ユーザー名",
host: "ホスト",
changeHost: "ホストを変更",
newHost: "新しいホスト",
changePassword: "パスワードを変更",
newPassword: "新しいパスワード",
lock: "ロック",
+2
View File
@@ -2912,6 +2912,8 @@ export default withEnglishFallback({
revoke: "권한 회수",
username: "사용자 이름",
host: "호스트",
changeHost: "호스트 변경",
newHost: "새 호스트",
changePassword: "비밀번호 변경",
newPassword: "새 비밀번호",
lock: "잠금",
+2
View File
@@ -2910,6 +2910,8 @@ export default withEnglishFallback({
revoke: "Revogar",
username: "Nome de usuário",
host: "Host",
changeHost: "Alterar host",
newHost: "Novo host",
changePassword: "Alterar Senha",
newPassword: "Nova senha",
lock: "Bloquear",
+2
View File
@@ -3055,6 +3055,8 @@ export default withEnglishFallback({
revoke: "撤权",
username: "用户名",
host: "Host",
changeHost: "修改主机",
newHost: "新主机",
changePassword: "修改密码",
newPassword: "新密码",
lock: "锁定",
+2
View File
@@ -5837,6 +5837,8 @@ export default withEnglishFallback({
revoke: "撤銷",
username: "使用者名稱",
host: "主機",
changeHost: "變更主機",
newHost: "新主機",
changePassword: "變更密碼",
newPassword: "新密碼",
lock: "鎖定",
@@ -7,6 +7,9 @@ import type { ConnectionConfig } from "@/types/database";
const mocks = vi.hoisted(() => ({
ensureConnected: vi.fn(),
executeQuery: vi.fn(),
executeMulti: vi.fn(),
productionGuard: vi.fn(),
toast: vi.fn(),
}));
function passthrough(tag: string): Component {
@@ -18,12 +21,40 @@ function passthrough(tag: string): Component {
});
}
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
function modelInput(): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs }) {
return () =>
h("input", {
...attrs,
value: attrs.modelValue as string,
onInput: (event: Event) => (attrs["onUpdate:modelValue"] as ((value: string) => void) | undefined)?.((event.target as HTMLInputElement).value),
});
},
});
}
function modelDialog(): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => (attrs.open ? h("div", attrs, slots.default?.()) : null);
},
});
}
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string, values?: Record<string, unknown>) => (values?.message ? `${key}: ${values.message}` : key),
}),
}));
vi.mock("@lucide/vue", () => {
const Icon = passthrough("span");
return {
AlertTriangle: Icon,
Check: Icon,
Globe2: Icon,
KeyRound: Icon,
Lock: Icon,
Loader2: Icon,
@@ -39,13 +70,13 @@ vi.mock("@lucide/vue", () => {
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
vi.mock("@/components/ui/dialog", () => ({
Dialog: passthrough("div"),
Dialog: modelDialog(),
DialogContent: passthrough("div"),
DialogFooter: passthrough("div"),
DialogHeader: passthrough("div"),
DialogTitle: passthrough("div"),
}));
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
vi.mock("@/components/ui/input", () => ({ Input: modelInput() }));
vi.mock("@/components/ui/PasswordInput.vue", () => ({ default: passthrough("input") }));
vi.mock("@/components/ui/select", () => ({
Select: passthrough("div"),
@@ -57,14 +88,20 @@ vi.mock("@/components/ui/select", () => ({
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }),
}));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
executeMulti: vi.fn(),
executeMulti: mocks.executeMulti,
listDatabases: vi.fn(),
listSchemas: vi.fn(),
}));
vi.mock("@/lib/database/productionExecutionGuard", () => ({
executeWithProductionSqlGuard: (options: { execute: () => Promise<unknown> }) => {
mocks.productionGuard(options);
return options.execute();
},
}));
import DatabaseUserAdmin from "@/components/admin/DatabaseUserAdmin.vue";
@@ -79,6 +116,15 @@ const connection: ConnectionConfig = {
password: "",
};
const nativeMysqlConnection: ConnectionConfig = {
...connection,
id: "native-mysql",
name: "Native MySQL",
db_type: "mysql",
driver_profile: "mysql",
port: 3306,
};
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
@@ -142,3 +188,101 @@ describe("DatabaseUserAdmin MySQL grant loading", () => {
expect(root.textContent).not.toContain("Access denied");
});
});
function findButton(text: string): HTMLButtonElement | undefined {
return Array.from(root?.querySelectorAll("button") ?? []).find((button) => button.textContent?.trim() === text);
}
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'"]] });
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 nextTick();
}
describe("DatabaseUserAdmin MySQL account Host changes", () => {
it("shows the action only for native MySQL and validates the replacement Host", async () => {
await mountNativeMysqlUserAdmin();
findButton("userAdmin.changeHost")?.click();
await nextTick();
const hostInput = root?.querySelector<HTMLInputElement>('input[placeholder="userAdmin.newHost"]');
const previewButton = findButton("userAdmin.previewSql");
expect(hostInput?.value).toBe("old-host");
expect(previewButton?.disabled).toBe(true);
hostInput!.value = " ";
hostInput!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
expect(previewButton?.disabled).toBe(true);
hostInput!.value = " old-host ";
hostInput!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
expect(previewButton?.disabled).toBe(true);
const nonNativeRoot = document.createElement("div");
document.body.append(nonNativeRoot);
app?.unmount();
mocks.executeQuery.mockReset();
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["same-user", "old-host", "mysql_native_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [[]] });
app = createApp(DatabaseUserAdmin, { connection });
app.mount(nonNativeRoot);
root?.remove();
root = nonNativeRoot;
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
expect(findButton("userAdmin.changeHost")).toBeUndefined();
});
it("previews the exact RENAME USER SQL and reloads the renamed identity after success", async () => {
await mountNativeMysqlUserAdmin();
findButton("userAdmin.changeHost")?.click();
await nextTick();
const hostInput = root?.querySelector<HTMLInputElement>('input[placeholder="userAdmin.newHost"]');
hostInput!.value = "new-host";
hostInput!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
findButton("userAdmin.previewSql")?.click();
await nextTick();
expect(root?.textContent).toContain("RENAME USER 'same-user'@'old-host' TO 'same-user'@'new-host';");
expect(findButton("userAdmin.applySql")?.getAttribute("variant")).toBe("destructive");
mocks.executeMulti.mockResolvedValueOnce([]);
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["same-user", "new-host", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'same-user'@'new-host'"]] });
findButton("userAdmin.applySql")?.click();
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(mocks.executeQuery.mock.calls.some((call) => call[2] === "SHOW GRANTS FOR 'same-user'@'new-host';")).toBe(true));
expect(mocks.executeMulti.mock.calls[0][2]).toBe("RENAME USER 'same-user'@'old-host' TO 'same-user'@'new-host';");
expect(mocks.productionGuard).toHaveBeenCalledWith(expect.objectContaining({ connection: nativeMysqlConnection, sql: "RENAME USER 'same-user'@'old-host' TO 'same-user'@'new-host';" }));
expect(mocks.executeQuery.mock.calls.slice(2).map((call) => call[2])).toEqual([
"SELECT User AS user, Host AS host, plugin AS plugin FROM mysql.user ORDER BY User, Host;",
"SHOW GRANTS FOR 'same-user'@'new-host';",
]);
expect(root?.textContent).toContain("same-user@new-host");
expect(root?.querySelector('input[placeholder="userAdmin.newHost"]')).toBeNull();
});
it("keeps the old identity selected and surfaces execution errors", async () => {
await mountNativeMysqlUserAdmin();
findButton("userAdmin.changeHost")?.click();
await nextTick();
const hostInput = root?.querySelector<HTMLInputElement>('input[placeholder="userAdmin.newHost"]');
hostInput!.value = "existing-host";
hostInput!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
findButton("userAdmin.previewSql")?.click();
await nextTick();
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["error"], rows: [["Operation RENAME USER failed"]], execution_error: true }]);
findButton("userAdmin.applySql")?.click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("userAdmin.applyFailed: Operation RENAME USER failed", 5000));
expect(root?.textContent).toContain("same-user@old-host");
expect(root?.querySelector<HTMLInputElement>('input[placeholder="userAdmin.newHost"]')?.value).toBe("existing-host");
expect(mocks.executeQuery).toHaveBeenCalledTimes(2);
});
});
@@ -9,6 +9,7 @@ import {
kingbaseShowGrantsSql,
kingbaseUserAdminProvider,
mysqlPrivilegeSelectionFromGrants,
nativeMysqlUserAdminProvider,
postgresUserAdminProvider,
resolveDatabaseUserAdminProviderForConnection,
} from "@/lib/database/databaseUserAdmin";
@@ -80,6 +81,35 @@ describe("MySQL grant privilege selection", () => {
});
describe("database user admin providers", () => {
it("opts only native MySQL connections into account Host changes", () => {
const legacyNative = resolveDatabaseUserAdminProviderForConnection(connection("mysql"));
const blankLegacyNative = resolveDatabaseUserAdminProviderForConnection(connection("mysql", " "));
const native = resolveDatabaseUserAdminProviderForConnection(connection("mysql", "mysql"));
const normalizedNative = resolveDatabaseUserAdminProviderForConnection(connection("mysql", " MySQL "));
expect(legacyNative).toBe(nativeMysqlUserAdminProvider);
expect(blankLegacyNative).toBe(nativeMysqlUserAdminProvider);
expect(native).toBe(nativeMysqlUserAdminProvider);
expect(normalizedNative).toBe(nativeMysqlUserAdminProvider);
expect(native?.renameUserSql?.({ user: "app'user", host: "old\\host" }, "new'\\host")).toBe(
"RENAME USER 'app''user'@'old\\\\host' TO 'app''user'@'new''\\\\host';",
);
});
it("does not expose account Host changes to MySQL-compatible providers", () => {
const jdbcMysql = { ...connection("jdbc", "mysql"), connection_string: "jdbc:mysql://localhost:3306/app" };
expect(getDatabaseUserAdminProvider("mysql")?.renameUserSql).toBeUndefined();
expect(resolveDatabaseUserAdminProviderForConnection(connection("goldendb", "goldendb"))?.renameUserSql).toBeUndefined();
expect(resolveDatabaseUserAdminProviderForConnection(jdbcMysql)?.renameUserSql).toBeUndefined();
for (const profile of ["mariadb", "oceanbase", "tidb", "tdsql", "polardb", "greatsql", "goldendb", "doris", "selectdb", "starrocks"]) {
expect(resolveDatabaseUserAdminProviderForConnection(connection("mysql", profile))?.renameUserSql, profile).toBeUndefined();
}
for (const dbType of ["gbase", "doris", "starrocks", "postgres"] as const) {
expect(resolveDatabaseUserAdminProviderForConnection(connection(dbType, dbType))?.renameUserSql, dbType).toBeUndefined();
}
});
it("selects the dedicated provider for GaussDB M-mode connections", () => {
const provider = resolveDatabaseUserAdminProviderForConnection(connection("gaussdb", "GAUSSDB-M"));
@@ -48,6 +48,7 @@ export interface DatabaseUserAdminProvider {
showGrantsSql(user: DatabaseUserIdentity): string;
parseGrants?(result: QueryResult): string[];
createUserSql?(input: CreatePrincipalInput): string;
renameUserSql?(user: DatabaseUserIdentity, newHost: string): string;
alterPasswordSql?(user: DatabaseUserIdentity, password: string): string;
alterLoginSql?(user: DatabaseUserIdentity, enabled: boolean): string;
dropUserSql?(user: DatabaseUserIdentity): string;
@@ -163,6 +164,10 @@ export function mysqlCreateUserSql(input: CreatePrincipalInput): string {
return `CREATE USER ${mysqlUserAccount(input)} IDENTIFIED BY ${quoteMySqlString(input.password)};`;
}
export function mysqlRenameUserHostSql(user: DatabaseUserIdentity, newHost: string): string {
return `RENAME USER ${mysqlUserAccount(user)} TO ${mysqlUserAccount({ ...user, host: newHost })};`;
}
export function mysqlAlterUserPasswordSql(user: DatabaseUserIdentity, password: string): string {
return `ALTER USER ${mysqlUserAccount(user)} IDENTIFIED BY ${quoteMySqlString(password)};`;
}
@@ -702,6 +707,11 @@ export const mysqlUserAdminProvider: DatabaseUserAdminProvider = {
privilegeSelectionFromGrants: mysqlPrivilegeSelectionFromGrants,
};
export const nativeMysqlUserAdminProvider: DatabaseUserAdminProvider = {
...mysqlUserAdminProvider,
renameUserSql: mysqlRenameUserHostSql,
};
export const postgresUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "postgres",
defaultScope: "database",
@@ -787,6 +797,9 @@ const DATABASE_USER_ADMIN_PROVIDER_BY_TYPE = new Map<DatabaseType, DatabaseUserA
export function getDatabaseUserAdminProvider(dbType: DatabaseType | undefined, connection?: ConnectionConfig): DatabaseUserAdminProvider | null {
if (!dbType) return null;
if (dbType === "mysql" && connection?.db_type === "mysql" && (!connection.driver_profile?.trim() || connection.driver_profile.trim().toLowerCase() === "mysql")) {
return nativeMysqlUserAdminProvider;
}
// GaussDB M mode (MySQL-compatible) uses backtick quoting and treats "user"
// as a reserved keyword, so a dedicated provider with backtick-quoted aliases
// is required.