diff --git a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue new file mode 100644 index 0000000000..77889b3d05 --- /dev/null +++ b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue @@ -0,0 +1,625 @@ + + + diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 1ce9722f84..6feffd8796 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -49,6 +49,7 @@ const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/Etcd const MongoDocBrowser = defineAsyncComponent(() => import("@/components/mongo/MongoDocBrowser.vue")); const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue")); const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue")); +const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue")); const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue")); const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue")); import { useQueryStore } from "@/stores/queryStore"; @@ -949,5 +950,9 @@ defineExpose({ focusSearch, refreshData, handleModRTarget }); @close="emit('structureEditorClose')" /> + + diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 8c49ad48fb..52f5e6bfb8 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -47,6 +47,7 @@ import { ListFilter, Package, Clipboard, + UsersRound, } from "@lucide/vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -132,6 +133,7 @@ import { treeSelectionRangeIds, } from "@/lib/sidebarTreeSelection"; import { selectedConnectionDeleteTargets } from "@/lib/sidebarConnectionSelection"; +import { supportsDatabaseUserAdmin } from "@/lib/databaseUserAdmin"; import { sidebarTreeContextKey } from "@/lib/sidebarTreeContext"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue"; @@ -242,6 +244,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: Zap, colorClass: "text-orange-400" }; case "object-browser": return { icon: TableProperties, colorClass: "text-primary" }; + case "user-admin": + return { icon: UsersRound, colorClass: "text-primary" }; case "saved-sql-root": return { icon: FolderOpen, colorClass: "text-blue-500" }; case "saved-sql-folder": @@ -318,6 +322,7 @@ function isGroupLabel(node: TreeNode): boolean { function displayLabel(node: TreeNode): string { if (node.type === "object-browser") return t(node.label, { count: node.objectCount ?? 0 }); + if (node.type === "user-admin") return t(node.label); if (node.label === "tree.defaultDatabase") return t(node.label); return isGroupLabel(node) ? t(node.label) : node.label; } @@ -394,6 +399,8 @@ async function toggle() { } else if (node.type === "etcd-root" && node.connectionId) { const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:keys`; queryStore.createTab(node.connectionId, "", tabTitle, "etcd"); + } else if (node.type === "user-admin" && node.connectionId) { + queryStore.openUserAdmin(node.connectionId); } else if (node.type === "mongo-db" && node.connectionId && node.database) { await connectionStore.loadMongoCollections(node.connectionId, node.database); } else if (node.type === "mongo-collection" && node.connectionId && node.database) { @@ -747,6 +754,18 @@ async function openObjectBrowser() { } } +async function openUserAdmin() { + const node = props.node; + if (!node.connectionId) return; + try { + await connectionStore.ensureConnected(node.connectionId); + connectionStore.activeConnectionId = node.connectionId; + queryStore.openUserAdmin(node.connectionId); + } catch (e: any) { + toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + } +} + function openSavedSqlFile() { const id = props.node.savedSqlId; if (!id) return; @@ -2913,6 +2932,9 @@ function treeItemMenuItems(): ContextMenuItem[] { items.push({ label: t("contextMenu.closeConnection"), action: disconnectConnection, icon: Unplug }); } items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); + if (supportsDatabaseUserAdmin(currentDatabaseType())) { + items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound }); + } if (canCopyFinalProxyPort.value) { items.push({ label: t("contextMenu.copyFinalProxyPort"), action: copyFinalProxyPort, icon: Network }); } @@ -3064,6 +3086,11 @@ function treeItemMenuItems(): ContextMenuItem[] { return items; } + if (node.type === "user-admin") { + items.push({ label: t("contextMenu.openUserAdmin"), action: openUserAdmin, icon: UsersRound }); + return items; + } + if (node.type === "redis-db" || node.type === "mongo-db") { items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); if (!isNodeDefaultDatabase.value) { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b5534e992c..748b304854 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -392,6 +392,7 @@ export default { etcd: "etcd", mongo: "Mongo", objects: "Objects", + users: "Users & Privileges", tooltipTitle: "Title:", tooltipConnection: "Connection:", tooltipDatabase: "Database:", @@ -961,6 +962,8 @@ export default { "Are you sure you want to delete the selected {count} connections? This cannot be undone.", editConnection: "Edit Connection", selectVisibleDatabases: "Select Visible Databases", + userAdmin: "Users & Privileges", + openUserAdmin: "Open Users & Privileges", duplicateConnection: "Duplicate Connection", newQuery: "New Query", openObjectBrowser: "Browse Objects", @@ -1122,6 +1125,7 @@ export default { }, tree: { savedSql: "SQL Library", + userAdmin: "Users & Privileges", defaultDatabase: "Default DB", columns: "Columns", indexes: "Indexes", @@ -1135,6 +1139,53 @@ export default { partitions: "Partitions", objectBrowser: "Browse in Object Browser ({count})", }, + userAdmin: { + title: "Users & Privileges", + unsupported: + "MySQL-compatible and PostgreSQL-compatible connections are supported. SQL Server, Oracle, and other permission models can be added next.", + newUser: "New User", + anonymous: "Anonymous", + roleName: "Role name", + allowLogin: "Allow login", + searchUser: "Search user or host", + loadingUsers: "Loading users...", + loadingGrants: "Loading grants...", + emptyUsers: "No users found, or the current account cannot read the user list.", + pluginUnknown: "Authentication plugin unknown", + grants: "Grants", + noGrants: "No grants found", + privilegeEditor: "Privilege Editor", + privilegeHint: + "Choose privileges, database, and table, then preview SQL before applying. * means global or all tables.", + database: "Database", + schema: "Schema", + table: "Table", + scope: "Grant scope", + scopeDatabase: "Database", + scopeSchema: "Schema", + scopeTable: "Table", + scopeRole: "Role membership", + memberRole: "Member role", + privileges: "Privileges", + grantOption: "Allow further grants (WITH GRANT OPTION)", + adminOption: "Allow membership management (WITH ADMIN OPTION)", + grant: "Grant", + revoke: "Revoke", + username: "Username", + host: "Host", + changePassword: "Change Password", + newPassword: "New password", + lock: "Lock", + unlock: "Unlock", + disableLogin: "Disable Login", + enableLogin: "Enable Login", + dropUser: "Drop User", + previewSql: "Preview SQL", + sqlPreview: "SQL Preview", + applySql: "Apply SQL", + applySuccess: "User privileges updated", + applyFailed: "Execution failed: {message}", + }, objects: { all: "All", tables: "Tables", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2384227415..e697693146 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -388,6 +388,7 @@ export default { etcd: "etcd", mongo: "Mongo", objects: "对象", + users: "用户与权限", tooltipTitle: "标题:", tooltipConnection: "连接:", tooltipDatabase: "数据库:", @@ -941,6 +942,8 @@ export default { editConnection: "编辑连接", selectVisibleDatabases: "选择显示数据库", duplicateConnection: "复制连接", + userAdmin: "用户与权限", + openUserAdmin: "打开用户与权限", newQuery: "新建查询", openObjectBrowser: "浏览对象", viewData: "查看数据", @@ -1096,6 +1099,7 @@ export default { }, tree: { savedSql: "SQL 库", + userAdmin: "用户与权限", defaultDatabase: "默认库", columns: "字段", indexes: "索引", @@ -1109,6 +1113,51 @@ export default { partitions: "分区", objectBrowser: "在对象浏览器中查看 ({count})", }, + userAdmin: { + title: "用户与权限", + unsupported: "当前支持 MySQL 兼容与 PostgreSQL 兼容连接。SQL Server、Oracle 等会按各自权限模型继续扩展。", + newUser: "新建用户", + anonymous: "匿名用户", + roleName: "角色名", + allowLogin: "允许登录", + searchUser: "搜索用户或 Host", + loadingUsers: "正在加载用户...", + loadingGrants: "正在加载授权...", + emptyUsers: "暂无用户,或当前账号没有读取用户列表的权限。", + pluginUnknown: "认证插件未知", + grants: "授权", + noGrants: "暂无授权信息", + privilegeEditor: "权限编辑", + privilegeHint: "选择权限、数据库和表,预览 SQL 后执行。* 表示全局或全部表。", + database: "数据库", + schema: "Schema", + table: "表", + scope: "授权范围", + scopeDatabase: "数据库", + scopeSchema: "Schema", + scopeTable: "表", + scopeRole: "角色成员", + memberRole: "成员角色", + privileges: "权限", + grantOption: "允许继续授权 (WITH GRANT OPTION)", + adminOption: "允许管理成员关系 (WITH ADMIN OPTION)", + grant: "授权", + revoke: "撤权", + username: "用户名", + host: "Host", + changePassword: "修改密码", + newPassword: "新密码", + lock: "锁定", + unlock: "解锁", + disableLogin: "禁止登录", + enableLogin: "允许登录", + dropUser: "删除用户", + previewSql: "预览 SQL", + sqlPreview: "SQL 预览", + applySql: "执行 SQL", + applySuccess: "用户权限已更新", + applyFailed: "执行失败:{message}", + }, objects: { all: "全部", tables: "表", diff --git a/apps/desktop/src/lib/databaseUserAdmin.ts b/apps/desktop/src/lib/databaseUserAdmin.ts new file mode 100644 index 0000000000..6cbf91f903 --- /dev/null +++ b/apps/desktop/src/lib/databaseUserAdmin.ts @@ -0,0 +1,407 @@ +import type { DatabaseType, QueryResult } from "@/types/database"; + +export type UserAdminDialect = "mysql" | "postgres"; +export type PrivilegeScope = "mysql" | "database" | "schema" | "table" | "role"; + +export interface DatabaseUserIdentity { + user: string; + host: string; + plugin?: string; +} + +export interface CreatePrincipalInput extends DatabaseUserIdentity { + password: string; + canLogin?: boolean; +} + +export interface PrivilegeChangeInput { + user: DatabaseUserIdentity; + privileges: string[]; + database: string; + table?: string; + grantOption?: boolean; + scope?: PrivilegeScope; + role?: string; +} + +export interface DatabaseUserAdminProvider { + dialect: UserAdminDialect; + defaultScope: PrivilegeScope; + listUsersSql(): string; + fallbackListUsersSql?: () => string; + parseUsers(result: QueryResult): DatabaseUserIdentity[]; + parseFallbackUsers?: (result: QueryResult) => DatabaseUserIdentity[]; + showGrantsSql(user: DatabaseUserIdentity): string; + createUserSql(input: CreatePrincipalInput): string; + alterPasswordSql(user: DatabaseUserIdentity, password: string): string; + alterLoginSql(user: DatabaseUserIdentity, enabled: boolean): string; + dropUserSql(user: DatabaseUserIdentity): string; + grantPrivilegesSql(input: PrivilegeChangeInput): string; + revokePrivilegesSql(input: PrivilegeChangeInput): string; + label(user: DatabaseUserIdentity): string; + detail(user: DatabaseUserIdentity): string | undefined; + privilegesForScope(scope: PrivilegeScope): readonly string[]; + defaultPrivilegesForScope(scope: PrivilegeScope): string[]; +} + +export const MYSQL_USER_ADMIN_TYPES = new Set(["mysql", "goldendb"]); +export const POSTGRES_USER_ADMIN_TYPES = new Set([ + "postgres", + "gaussdb", + "highgo", + "kingbase", + "kwdb", + "opengauss", + "vastbase", +]); + +export const MYSQL_COMMON_PRIVILEGES = [ + "SELECT", + "INSERT", + "UPDATE", + "DELETE", + "CREATE", + "DROP", + "ALTER", + "INDEX", + "REFERENCES", + "EXECUTE", + "SHOW VIEW", + "TRIGGER", + "EVENT", + "CREATE TEMPORARY TABLES", +] as const; + +export const POSTGRES_DATABASE_PRIVILEGES = ["CONNECT", "CREATE", "TEMPORARY"] as const; +export const POSTGRES_SCHEMA_PRIVILEGES = ["USAGE", "CREATE"] as const; +export const POSTGRES_TABLE_PRIVILEGES = [ + "SELECT", + "INSERT", + "UPDATE", + "DELETE", + "TRUNCATE", + "REFERENCES", + "TRIGGER", +] as const; + +export function supportsDatabaseUserAdmin(dbType: DatabaseType | undefined): boolean { + return !!getDatabaseUserAdminProvider(dbType); +} + +export function getDatabaseUserAdminProvider(dbType: DatabaseType | undefined): DatabaseUserAdminProvider | null { + if (!dbType) return null; + if (MYSQL_USER_ADMIN_TYPES.has(dbType)) return mysqlUserAdminProvider; + if (POSTGRES_USER_ADMIN_TYPES.has(dbType)) return postgresUserAdminProvider; + return null; +} + +export function quoteSqlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +export function quoteMySqlString(value: string): string { + return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`; +} + +export function quoteMySqlIdentifier(value: string): string { + return `\`${value.replace(/`/g, "``")}\``; +} + +export function quotePostgresIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +export function mysqlUserAccount(user: DatabaseUserIdentity): string { + return `${quoteMySqlString(user.user)}@${quoteMySqlString(user.host)}`; +} + +export function mysqlUserLabel(user: DatabaseUserIdentity): string { + return `${user.user}@${user.host}`; +} + +export function postgresRoleLabel(user: DatabaseUserIdentity): string { + return user.user; +} + +export function mysqlListUsersSql(): string { + return "SELECT User AS user, Host AS host, plugin AS plugin FROM mysql.user ORDER BY User, Host;"; +} + +export function mysqlListUsersFallbackSql(): string { + return "SELECT DISTINCT GRANTEE AS grantee FROM information_schema.USER_PRIVILEGES ORDER BY GRANTEE;"; +} + +export function mysqlShowGrantsSql(user: DatabaseUserIdentity): string { + return `SHOW GRANTS FOR ${mysqlUserAccount(user)};`; +} + +export function mysqlCreateUserSql(input: CreatePrincipalInput): string { + return `CREATE USER ${mysqlUserAccount(input)} IDENTIFIED BY ${quoteMySqlString(input.password)};`; +} + +export function mysqlAlterUserPasswordSql(user: DatabaseUserIdentity, password: string): string { + return `ALTER USER ${mysqlUserAccount(user)} IDENTIFIED BY ${quoteMySqlString(password)};`; +} + +export function mysqlAlterUserAccountLockSql(user: DatabaseUserIdentity, locked: boolean): string { + return `ALTER USER ${mysqlUserAccount(user)} ACCOUNT ${locked ? "LOCK" : "UNLOCK"};`; +} + +export function mysqlDropUserSql(user: DatabaseUserIdentity): string { + return `DROP USER ${mysqlUserAccount(user)};`; +} + +export function mysqlPrivilegeTargetSql(database: string, table = "*"): string { + const db = database.trim() || "*"; + const tbl = table.trim() || "*"; + const dbSql = db === "*" ? "*" : quoteMySqlIdentifier(db); + const tableSql = tbl === "*" ? "*" : quoteMySqlIdentifier(tbl); + return `${dbSql}.${tableSql}`; +} + +export function mysqlGrantPrivilegesSql(input: PrivilegeChangeInput): string { + const privileges = normalizePrivileges(input.privileges).join(", "); + const grantOption = input.grantOption ? " WITH GRANT OPTION" : ""; + return `GRANT ${privileges} ON ${mysqlPrivilegeTargetSql(input.database, input.table)} TO ${mysqlUserAccount( + input.user, + )}${grantOption};`; +} + +export function mysqlRevokePrivilegesSql(input: PrivilegeChangeInput): string { + const privileges = normalizePrivileges(input.privileges).join(", "); + return `REVOKE ${privileges} ON ${mysqlPrivilegeTargetSql(input.database, input.table)} FROM ${mysqlUserAccount( + input.user, + )};`; +} + +export function normalizePrivileges(privileges: string[], fallback = "SELECT"): string[] { + const normalized = privileges.map((privilege) => privilege.trim().toUpperCase()).filter(Boolean); + return Array.from(new Set(normalized.length > 0 ? normalized : [fallback])); +} + +export const normalizeMySqlPrivileges = normalizePrivileges; + +export function usersFromMySqlUserResult(result: QueryResult): DatabaseUserIdentity[] { + const userIndex = columnIndex(result, "user", "User"); + const hostIndex = columnIndex(result, "host", "Host"); + const pluginIndex = columnIndex(result, "plugin", "Plugin"); + if (userIndex < 0 || hostIndex < 0) return []; + return result.rows + .map((row) => ({ + user: String(row[userIndex] ?? ""), + host: String(row[hostIndex] ?? ""), + plugin: pluginIndex >= 0 && row[pluginIndex] != null ? String(row[pluginIndex]) : undefined, + })) + .filter((user) => user.user || user.host); +} + +export function usersFromMySqlGranteeResult(result: QueryResult): DatabaseUserIdentity[] { + const granteeIndex = columnIndex(result, "grantee", "GRANTEE"); + if (granteeIndex < 0) return []; + return result.rows.flatMap((row) => { + const parsed = parseMySqlGrantee(String(row[granteeIndex] ?? "")); + return parsed ? [parsed] : []; + }); +} + +export function postgresListRolesSql(): string { + return ` +SELECT + r.rolname AS user, + CASE WHEN r.rolcanlogin THEN 'LOGIN' ELSE 'ROLE' END AS host, + concat_ws(', ', + CASE WHEN r.rolsuper THEN 'SUPERUSER' END, + CASE WHEN r.rolcreatedb THEN 'CREATEDB' END, + CASE WHEN r.rolcreaterole THEN 'CREATEROLE' END, + CASE WHEN r.rolreplication THEN 'REPLICATION' END, + CASE WHEN r.rolbypassrls THEN 'BYPASSRLS' END + ) AS plugin +FROM pg_catalog.pg_roles r +ORDER BY r.rolname;`.trim(); +} + +export function usersFromPostgresRolesResult(result: QueryResult): DatabaseUserIdentity[] { + return usersFromMySqlUserResult(result); +} + +export function postgresShowGrantsSql(user: DatabaseUserIdentity): string { + const role = quoteSqlString(user.user); + return ` +WITH target AS ( + SELECT oid, rolname, rolsuper, rolcreatedb, rolcreaterole, rolcanlogin, rolreplication, rolbypassrls + FROM pg_catalog.pg_roles + WHERE rolname = ${role} +) +SELECT line +FROM ( + SELECT 1 AS sort, 'Role: ' || quote_ident(rolname) AS line + FROM target + UNION ALL + SELECT 2, 'Attributes: ' || COALESCE(NULLIF(concat_ws(', ', + CASE WHEN rolsuper THEN 'SUPERUSER' END, + CASE WHEN rolcreatedb THEN 'CREATEDB' END, + CASE WHEN rolcreaterole THEN 'CREATEROLE' END, + CASE WHEN rolcanlogin THEN 'LOGIN' ELSE 'NOLOGIN' END, + CASE WHEN rolreplication THEN 'REPLICATION' END, + CASE WHEN rolbypassrls THEN 'BYPASSRLS' END + ), ''), 'none') + FROM target + UNION ALL + SELECT 10, 'Member of: ' || quote_ident(parent.rolname) || CASE WHEN m.admin_option THEN ' WITH ADMIN OPTION' ELSE '' END + FROM pg_catalog.pg_auth_members m + JOIN target t ON t.oid = m.member + JOIN pg_catalog.pg_roles parent ON parent.oid = m.roleid + UNION ALL + SELECT 20, 'Has member: ' || quote_ident(member.rolname) || CASE WHEN m.admin_option THEN ' WITH ADMIN OPTION' ELSE '' END + FROM pg_catalog.pg_auth_members m + JOIN target t ON t.oid = m.roleid + JOIN pg_catalog.pg_roles member ON member.oid = m.member + UNION ALL + SELECT 30, 'Database: ' || quote_ident(d.datname) || ' = ' || + concat_ws(', ', + CASE WHEN has_database_privilege(t.rolname, d.oid, 'CONNECT') THEN 'CONNECT' END, + CASE WHEN has_database_privilege(t.rolname, d.oid, 'CREATE') THEN 'CREATE' END, + CASE WHEN has_database_privilege(t.rolname, d.oid, 'TEMPORARY') THEN 'TEMPORARY' END + ) + FROM target t + CROSS JOIN pg_catalog.pg_database d + WHERE has_database_privilege(t.rolname, d.oid, 'CONNECT') + OR has_database_privilege(t.rolname, d.oid, 'CREATE') + OR has_database_privilege(t.rolname, d.oid, 'TEMPORARY') + UNION ALL + SELECT 40, 'Schema: ' || quote_ident(n.nspname) || ' = ' || + concat_ws(', ', + CASE WHEN has_schema_privilege(t.rolname, n.oid, 'USAGE') THEN 'USAGE' END, + CASE WHEN has_schema_privilege(t.rolname, n.oid, 'CREATE') THEN 'CREATE' END + ) + FROM target t + CROSS JOIN pg_catalog.pg_namespace n + WHERE n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\' + AND n.nspname <> 'information_schema' + AND (has_schema_privilege(t.rolname, n.oid, 'USAGE') OR has_schema_privilege(t.rolname, n.oid, 'CREATE')) + UNION ALL + SELECT 50, 'Table: ' || quote_ident(table_schema) || '.' || quote_ident(table_name) || ' = ' || + string_agg(privilege_type || CASE WHEN is_grantable = 'YES' THEN ' WITH GRANT OPTION' ELSE '' END, ', ' ORDER BY privilege_type) + FROM information_schema.role_table_grants + WHERE grantee = ${role} + GROUP BY table_schema, table_name +) grants +ORDER BY sort, line;`.trim(); +} + +export function postgresCreateRoleSql(input: CreatePrincipalInput): string { + const login = input.canLogin === false ? "NOLOGIN" : "LOGIN"; + return `CREATE ROLE ${quotePostgresIdentifier(input.user)} ${login} PASSWORD ${quoteSqlString(input.password)};`; +} + +export function postgresAlterRolePasswordSql(user: DatabaseUserIdentity, password: string): string { + return `ALTER ROLE ${quotePostgresIdentifier(user.user)} PASSWORD ${quoteSqlString(password)};`; +} + +export function postgresAlterRoleLoginSql(user: DatabaseUserIdentity, enabled: boolean): string { + return `ALTER ROLE ${quotePostgresIdentifier(user.user)} ${enabled ? "LOGIN" : "NOLOGIN"};`; +} + +export function postgresDropRoleSql(user: DatabaseUserIdentity): string { + return `DROP ROLE ${quotePostgresIdentifier(user.user)};`; +} + +export function postgresPrivilegeTargetSql(input: Pick): string { + const scope = input.scope || "database"; + const name = input.database.trim(); + if (scope === "database") return `DATABASE ${quotePostgresIdentifier(name || "postgres")}`; + if (scope === "schema") return `SCHEMA ${quotePostgresIdentifier(name || "public")}`; + const schema = name || "public"; + const table = input.table?.trim() || "*"; + if (table === "*") return `ALL TABLES IN SCHEMA ${quotePostgresIdentifier(schema)}`; + return `TABLE ${quotePostgresIdentifier(schema)}.${quotePostgresIdentifier(table)}`; +} + +export function postgresGrantPrivilegesSql(input: PrivilegeChangeInput): string { + if (input.scope === "role") { + return `GRANT ${quotePostgresIdentifier(input.role?.trim() || "")} TO ${quotePostgresIdentifier(input.user.user)}${ + input.grantOption ? " WITH ADMIN OPTION" : "" + };`; + } + const privileges = normalizePrivileges(input.privileges, postgresDefaultPrivilege(input.scope)).join(", "); + return `GRANT ${privileges} ON ${postgresPrivilegeTargetSql(input)} TO ${quotePostgresIdentifier(input.user.user)}${ + input.grantOption ? " WITH GRANT OPTION" : "" + };`; +} + +export function postgresRevokePrivilegesSql(input: PrivilegeChangeInput): string { + if (input.scope === "role") { + return `REVOKE ${quotePostgresIdentifier(input.role?.trim() || "")} FROM ${quotePostgresIdentifier(input.user.user)};`; + } + const privileges = normalizePrivileges(input.privileges, postgresDefaultPrivilege(input.scope)).join(", "); + return `REVOKE ${privileges} ON ${postgresPrivilegeTargetSql(input)} FROM ${quotePostgresIdentifier(input.user.user)};`; +} + +export function grantsFromQueryResult(result: QueryResult): string[] { + return result.rows.map((row) => String(row[0] ?? "")).filter(Boolean); +} + +function columnIndex(result: QueryResult, ...names: string[]): number { + const wanted = new Set(names.map((name) => name.toLowerCase())); + return result.columns.findIndex((column) => wanted.has(column.toLowerCase())); +} + +function parseMySqlGrantee(value: string): DatabaseUserIdentity | null { + const match = /^'((?:''|[^'])*)'@'((?:''|[^'])*)'$/.exec(value.trim()); + if (!match) return null; + return { + user: match[1].replace(/''/g, "'"), + host: match[2].replace(/''/g, "'"), + }; +} + +function postgresDefaultPrivilege(scope: PrivilegeScope | undefined): string { + if (scope === "schema") return "USAGE"; + if (scope === "database") return "CONNECT"; + return "SELECT"; +} + +function postgresPrivilegesForScope(scope: PrivilegeScope): readonly string[] { + if (scope === "database") return POSTGRES_DATABASE_PRIVILEGES; + if (scope === "schema") return POSTGRES_SCHEMA_PRIVILEGES; + if (scope === "table") return POSTGRES_TABLE_PRIVILEGES; + return []; +} + +export const mysqlUserAdminProvider: DatabaseUserAdminProvider = { + dialect: "mysql", + defaultScope: "mysql", + listUsersSql: mysqlListUsersSql, + fallbackListUsersSql: mysqlListUsersFallbackSql, + parseUsers: usersFromMySqlUserResult, + parseFallbackUsers: usersFromMySqlGranteeResult, + showGrantsSql: mysqlShowGrantsSql, + createUserSql: mysqlCreateUserSql, + alterPasswordSql: mysqlAlterUserPasswordSql, + alterLoginSql: (user, enabled) => mysqlAlterUserAccountLockSql(user, !enabled), + dropUserSql: mysqlDropUserSql, + grantPrivilegesSql: mysqlGrantPrivilegesSql, + revokePrivilegesSql: mysqlRevokePrivilegesSql, + label: mysqlUserLabel, + detail: (user) => user.plugin, + privilegesForScope: () => MYSQL_COMMON_PRIVILEGES, + defaultPrivilegesForScope: () => ["SELECT"], +}; + +export const postgresUserAdminProvider: DatabaseUserAdminProvider = { + dialect: "postgres", + defaultScope: "database", + listUsersSql: postgresListRolesSql, + parseUsers: usersFromPostgresRolesResult, + showGrantsSql: postgresShowGrantsSql, + createUserSql: postgresCreateRoleSql, + alterPasswordSql: postgresAlterRolePasswordSql, + alterLoginSql: postgresAlterRoleLoginSql, + dropUserSql: postgresDropRoleSql, + grantPrivilegesSql: postgresGrantPrivilegesSql, + revokePrivilegesSql: postgresRevokePrivilegesSql, + label: postgresRoleLabel, + detail: (user) => [user.host, user.plugin].filter(Boolean).join(" · ") || undefined, + privilegesForScope: postgresPrivilegesForScope, + defaultPrivilegesForScope: (scope) => (scope === "role" ? [] : [postgresDefaultPrivilege(scope)]), +}; diff --git a/apps/desktop/src/lib/sidebarNodeOrdering.ts b/apps/desktop/src/lib/sidebarNodeOrdering.ts index ed9853940a..45939b7c32 100644 --- a/apps/desktop/src/lib/sidebarNodeOrdering.ts +++ b/apps/desktop/src/lib/sidebarNodeOrdering.ts @@ -31,24 +31,33 @@ export function sortSidebarTreeChildrenForParent( } if (parent.type === "connection") { + const savedSqlRoots = normalized.filter((child) => child.type === "saved-sql-root"); + const userAdminNodes = normalized.filter((child) => child.type === "user-admin"); + const regularChildren = normalized.filter( + (child) => child.type !== "saved-sql-root" && child.type !== "user-admin", + ); + const withConnectionUtilityOrder = (children: TreeNode[]) => [...savedSqlRoots, ...children, ...userAdminNodes]; + if (databaseType === "mongodb" || databaseType === "elasticsearch") { - return sortByLabel(normalized); + return withConnectionUtilityOrder(sortByLabel(regularChildren)); } if (databaseType === "duckdb") { - const schemas = sortByLabel(normalized.filter((child) => child.type === "schema")); - const databases = sortByLabel(normalized.filter((child) => child.type === "database")); - const rest = normalized.filter((child) => child.type !== "schema" && child.type !== "database"); - return [...schemas, ...databases, ...rest]; + const schemas = sortByLabel(regularChildren.filter((child) => child.type === "schema")); + const databases = sortByLabel(regularChildren.filter((child) => child.type === "database")); + const rest = regularChildren.filter((child) => child.type !== "schema" && child.type !== "database"); + return withConnectionUtilityOrder([...schemas, ...databases, ...rest]); } - if (normalized.every((child) => child.type === "database")) { - return sortByLabel(normalized); + if (regularChildren.every((child) => child.type === "database")) { + return withConnectionUtilityOrder(sortByLabel(regularChildren)); } - if (normalized.every((child) => child.type === "schema")) { - return sortByLabel(normalized); + if (regularChildren.every((child) => child.type === "schema")) { + return withConnectionUtilityOrder(sortByLabel(regularChildren)); } + + return withConnectionUtilityOrder(regularChildren); } if (parent.type === "database") { diff --git a/apps/desktop/src/lib/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebarTreeItemLayout.ts index 40addb9e15..2023e68834 100644 --- a/apps/desktop/src/lib/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebarTreeItemLayout.ts @@ -12,6 +12,7 @@ const leafTypes: Set = new Set([ "object-browser", "redis-db", "mongo-collection", + "user-admin", "saved-sql-file", ]); diff --git a/apps/desktop/src/lib/tabPresentation.ts b/apps/desktop/src/lib/tabPresentation.ts index 25f26197ee..b2398f43c0 100644 --- a/apps/desktop/src/lib/tabPresentation.ts +++ b/apps/desktop/src/lib/tabPresentation.ts @@ -68,6 +68,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string { if (compact) return schema || tab.title; return schema ? `${schema}@${database}` : `${tab.title}@${database}`; } + if (tab.mode === "users") { + if (compact) return t("tabs.users"); + return `${t("tabs.users")}@${connectionDisplayName(tab.connectionId)}`; + } return tab.title; } @@ -109,5 +113,6 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string { if (tab.mode === "redis") return t("tabs.redis"); if (tab.mode === "etcd") return t("tabs.etcd"); if (tab.mode === "objects") return t("tabs.objects"); + if (tab.mode === "users") return t("tabs.users"); return tab.mode; } diff --git a/apps/desktop/src/lib/treeNodeClick.ts b/apps/desktop/src/lib/treeNodeClick.ts index a198df8cb2..c374c5e0ee 100644 --- a/apps/desktop/src/lib/treeNodeClick.ts +++ b/apps/desktop/src/lib/treeNodeClick.ts @@ -14,7 +14,7 @@ export type SidebarSelectionCopyAction = "copy-name" | "none"; export type SidebarActivation = "single" | "double"; const dataNodeTypes = new Set(["table", "view"]); -const toggleLeafNodeTypes = new Set(["redis-db", "mongo-collection"]); +const toggleLeafNodeTypes = new Set(["redis-db", "mongo-collection", "user-admin"]); const objectBrowserNodeTypes = new Set(["database", "schema", "object-browser"]); const sourceNodeTypes = new Set(["procedure", "function", "package", "package-body"]); const tableChildGroupNodeTypes = new Set([ diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 0d2ab622e8..0af9458526 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -68,6 +68,7 @@ import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/schemaTreeCa import { sortSidebarTreeChildrenForParent } from "@/lib/sidebarNodeOrdering"; import { prunePinnedTreeNodeIdsForConnection } from "@/lib/pinnedTreeNodeIds"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; +import { supportsDatabaseUserAdmin } from "@/lib/databaseUserAdmin"; import { useSettingsStore } from "@/stores/settingsStore"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; @@ -423,11 +424,36 @@ export const useConnectionStore = defineStore("connection", () => { }; } - function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] { + function buildUserAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined { + const config = getConfig(connectionId); + if (!supportsDatabaseUserAdmin(effectiveDatabaseTypeForConnection(config))) return undefined; + const existing = existingConnectionNode?.children?.find((child) => child.type === "user-admin"); + return { + id: `${connectionId}:__user_admin`, + label: "tree.userAdmin", + type: "user-admin", + connectionId, + database: "", + isExpanded: existing?.isExpanded ?? false, + }; + } + + function withConnectionUtilityNodes( + connectionId: string, + children: TreeNode[], + existingConnectionNode?: TreeNode, + ): TreeNode[] { const existingRoot = existingConnectionNode?.children?.find((child) => child.type === "saved-sql-root"); - const nonSavedChildren = children.filter((child) => child.type !== "saved-sql-root"); + const nonUtilityChildren = children.filter( + (child) => child.type !== "saved-sql-root" && child.type !== "user-admin", + ); + const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode); const savedSqlRoot = buildSavedSqlRootNode(connectionId, existingRoot); - return savedSqlRoot ? [savedSqlRoot, ...nonSavedChildren] : nonSavedChildren; + return [savedSqlRoot, ...nonUtilityChildren, userAdminNode].filter(Boolean) as TreeNode[]; + } + + function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] { + return withConnectionUtilityNodes(connectionId, children, existingConnectionNode); } function refreshSavedSqlTree(connectionId?: string) { @@ -436,7 +462,7 @@ export const useConnectionStore = defineStore("connection", () => { if (node.type === "connection" && node.connectionId && (!connectionId || node.connectionId === connectionId)) { node.children = withSavedSqlRoot( node.connectionId, - (node.children || []).filter((child) => child.type !== "saved-sql-root"), + (node.children || []).filter((child) => child.type !== "saved-sql-root" && child.type !== "user-admin"), node, ); } @@ -490,6 +516,14 @@ export const useConnectionStore = defineStore("connection", () => { function useCachedChildren(node: TreeNode, options?: LoadTreeOptions): boolean { if (options?.force || !loadedTreeNodeChildrenIds.value.has(node.id)) return false; + if (node.type === "connection" && node.connectionId) { + const normalizedChildren = sortSidebarTreeChildrenForParent( + node, + withSavedSqlRoot(node.connectionId, node.children || [], node), + getConfig(node.connectionId)?.db_type, + ); + setChildren(node, normalizedChildren); + } node.isExpanded = true; return true; } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index a09f945ab4..98c91501ec 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -357,6 +357,31 @@ export const useQueryStore = defineStore("query", () => { return id; } + function openUserAdmin(connectionId: string) { + const existing = tabs.value.find((tab) => tab.mode === "users" && tab.connectionId === connectionId); + if (existing) { + activeTabId.value = existing.id; + return existing.id; + } + + const conn = useConnectionStore().getConfig(connectionId); + const id = uuid(); + const tab: QueryTab = { + id, + title: t("userAdmin.title"), + connectionId, + database: conn?.database || "", + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "users", + }; + tabs.value.push(tab); + activeTabId.value = id; + return id; + } + function openTableStructure(connectionId: string, database: string, schema?: string, tableName?: string) { const resolvedTableName = tableName || ""; if (resolvedTableName) { @@ -1704,6 +1729,7 @@ export const useQueryStore = defineStore("query", () => { updateSql, renameTab, openObjectBrowser, + openUserAdmin, openTableStructure, linkSavedSql, openSavedSql, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index c3c7b7260b..eb9db36f4c 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -301,6 +301,7 @@ export type TreeNodeType = | "group-packages" | "group-partitions" | "object-browser" + | "user-admin" | "saved-sql-root" | "saved-sql-folder" | "saved-sql-file" @@ -389,7 +390,7 @@ export interface QueryTab { executionId?: string; isExplaining?: boolean; explainExecutionId?: string; - mode: "data" | "query" | "redis" | "mongo" | "etcd" | "objects" | "structure"; + mode: "data" | "query" | "redis" | "mongo" | "etcd" | "objects" | "structure" | "users"; structureTableName?: string; objectBrowser?: { schema?: string; diff --git a/packages/app-tests/databaseUserAdmin.test.ts b/packages/app-tests/databaseUserAdmin.test.ts new file mode 100644 index 0000000000..3a71b4b13d --- /dev/null +++ b/packages/app-tests/databaseUserAdmin.test.ts @@ -0,0 +1,202 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { + grantsFromQueryResult, + mysqlAlterUserAccountLockSql, + mysqlAlterUserPasswordSql, + mysqlCreateUserSql, + mysqlDropUserSql, + mysqlGrantPrivilegesSql, + mysqlPrivilegeTargetSql, + mysqlRevokePrivilegesSql, + mysqlShowGrantsSql, + mysqlUserAccount, + normalizeMySqlPrivileges, + postgresAlterRoleLoginSql, + postgresAlterRolePasswordSql, + postgresCreateRoleSql, + postgresDropRoleSql, + postgresGrantPrivilegesSql, + postgresPrivilegeTargetSql, + postgresRevokePrivilegesSql, + postgresRoleLabel, + quoteMySqlIdentifier, + quoteMySqlString, + quotePostgresIdentifier, + supportsDatabaseUserAdmin, + usersFromMySqlGranteeResult, + usersFromMySqlUserResult, + usersFromPostgresRolesResult, +} from "../../apps/desktop/src/lib/databaseUserAdmin.ts"; +import type { QueryResult } from "../../apps/desktop/src/types/database.ts"; + +function result(columns: string[], rows: QueryResult["rows"]): QueryResult { + return { + columns, + rows, + affected_rows: 0, + execution_time_ms: 1, + }; +} + +test("detects supported user administration dialects", () => { + assert.equal(supportsDatabaseUserAdmin("mysql"), true); + assert.equal(supportsDatabaseUserAdmin("goldendb"), true); + assert.equal(supportsDatabaseUserAdmin("postgres"), true); + assert.equal(supportsDatabaseUserAdmin("opengauss"), true); + assert.equal(supportsDatabaseUserAdmin("sqlserver"), false); + assert.equal(supportsDatabaseUserAdmin(undefined), false); +}); + +test("quotes MySQL account parts and identifiers", () => { + assert.equal(quoteMySqlString("app'user\\"), "'app''user\\\\'"); + assert.equal(quoteMySqlIdentifier("tenant`01"), "`tenant``01`"); + assert.equal(mysqlUserAccount({ user: "app'user", host: "10.0.0.%" }), "'app''user'@'10.0.0.%'"); +}); + +test("builds MySQL user lifecycle SQL", () => { + const user = { user: "app", host: "%" }; + + assert.equal(mysqlShowGrantsSql(user), "SHOW GRANTS FOR 'app'@'%';"); + assert.equal(mysqlCreateUserSql({ ...user, password: "secret" }), "CREATE USER 'app'@'%' IDENTIFIED BY 'secret';"); + assert.equal(mysqlAlterUserPasswordSql(user, "new'secret"), "ALTER USER 'app'@'%' IDENTIFIED BY 'new''secret';"); + assert.equal(mysqlAlterUserAccountLockSql(user, true), "ALTER USER 'app'@'%' ACCOUNT LOCK;"); + assert.equal(mysqlAlterUserAccountLockSql(user, false), "ALTER USER 'app'@'%' ACCOUNT UNLOCK;"); + assert.equal(mysqlDropUserSql(user), "DROP USER 'app'@'%';"); +}); + +test("builds MySQL grant and revoke SQL with normalized privileges", () => { + const input = { + user: { user: "reporter", host: "localhost" }, + privileges: ["select", " SELECT ", "show view"], + database: "analytics", + table: "daily`rollup", + grantOption: true, + }; + + assert.deepEqual(normalizeMySqlPrivileges(input.privileges), ["SELECT", "SHOW VIEW"]); + assert.equal(mysqlPrivilegeTargetSql("analytics", "daily`rollup"), "`analytics`.`daily``rollup`"); + assert.equal( + mysqlGrantPrivilegesSql(input), + "GRANT SELECT, SHOW VIEW ON `analytics`.`daily``rollup` TO 'reporter'@'localhost' WITH GRANT OPTION;", + ); + assert.equal( + mysqlRevokePrivilegesSql(input), + "REVOKE SELECT, SHOW VIEW ON `analytics`.`daily``rollup` FROM 'reporter'@'localhost';", + ); + assert.equal(mysqlPrivilegeTargetSql("", ""), "*.*"); +}); + +test("parses MySQL user result variants", () => { + assert.deepEqual( + usersFromMySqlUserResult( + result( + ["User", "Host", "plugin"], + [ + ["root", "localhost", "caching_sha2_password"], + ["app", "%", null], + ], + ), + ), + [ + { user: "root", host: "localhost", plugin: "caching_sha2_password" }, + { user: "app", host: "%", plugin: undefined }, + ], + ); + + assert.deepEqual( + usersFromMySqlGranteeResult(result(["GRANTEE"], [["'app'@'%'"], ["'o''brien'@'localhost'"], ["CURRENT_USER"]])), + [ + { user: "app", host: "%" }, + { user: "o'brien", host: "localhost" }, + ], + ); +}); + +test("extracts grants from show grants result", () => { + assert.deepEqual( + grantsFromQueryResult(result(["Grants for app@%"], [["GRANT SELECT ON `app`.* TO 'app'@'%'"], [null]])), + ["GRANT SELECT ON `app`.* TO 'app'@'%'"], + ); +}); + +test("builds PostgreSQL role lifecycle SQL", () => { + const role = { user: 'report"reader', host: "LOGIN" }; + + assert.equal(quotePostgresIdentifier('report"reader'), '"report""reader"'); + assert.equal(postgresRoleLabel(role), 'report"reader'); + assert.equal( + postgresCreateRoleSql({ ...role, password: "it'works", canLogin: true }), + "CREATE ROLE \"report\"\"reader\" LOGIN PASSWORD 'it''works';", + ); + assert.equal( + postgresCreateRoleSql({ ...role, password: "secret", canLogin: false }), + 'CREATE ROLE "report""reader" NOLOGIN PASSWORD \'secret\';', + ); + assert.equal(postgresAlterRolePasswordSql(role, "next"), 'ALTER ROLE "report""reader" PASSWORD \'next\';'); + assert.equal(postgresAlterRoleLoginSql(role, false), 'ALTER ROLE "report""reader" NOLOGIN;'); + assert.equal(postgresAlterRoleLoginSql(role, true), 'ALTER ROLE "report""reader" LOGIN;'); + assert.equal(postgresDropRoleSql(role), 'DROP ROLE "report""reader";'); +}); + +test("builds PostgreSQL privilege and membership SQL", () => { + const user = { user: "reporter", host: "LOGIN" }; + + assert.equal(postgresPrivilegeTargetSql({ scope: "database", database: "analytics" }), 'DATABASE "analytics"'); + assert.equal(postgresPrivilegeTargetSql({ scope: "schema", database: "mart" }), 'SCHEMA "mart"'); + assert.equal( + postgresPrivilegeTargetSql({ scope: "table", database: "mart", table: 'daily"rollup' }), + 'TABLE "mart"."daily""rollup"', + ); + assert.equal( + postgresPrivilegeTargetSql({ scope: "table", database: "mart", table: "*" }), + 'ALL TABLES IN SCHEMA "mart"', + ); + assert.equal( + postgresGrantPrivilegesSql({ + user, + scope: "database", + database: "analytics", + privileges: ["connect", "CREATE"], + grantOption: true, + }), + 'GRANT CONNECT, CREATE ON DATABASE "analytics" TO "reporter" WITH GRANT OPTION;', + ); + assert.equal( + postgresRevokePrivilegesSql({ user, scope: "schema", database: "mart", privileges: ["usage"] }), + 'REVOKE USAGE ON SCHEMA "mart" FROM "reporter";', + ); + assert.equal( + postgresGrantPrivilegesSql({ + user, + scope: "role", + database: "", + privileges: [], + role: "readonly", + grantOption: true, + }), + 'GRANT "readonly" TO "reporter" WITH ADMIN OPTION;', + ); + assert.equal( + postgresRevokePrivilegesSql({ user, scope: "role", database: "", privileges: [], role: "readonly" }), + 'REVOKE "readonly" FROM "reporter";', + ); +}); + +test("parses PostgreSQL role rows", () => { + assert.deepEqual( + usersFromPostgresRolesResult( + result( + ["user", "host", "plugin"], + [ + ["postgres", "LOGIN", "SUPERUSER, CREATEDB"], + ["readonly", "ROLE", ""], + ], + ), + ), + [ + { user: "postgres", host: "LOGIN", plugin: "SUPERUSER, CREATEDB" }, + { user: "readonly", host: "ROLE", plugin: "" }, + ], + ); +}); diff --git a/packages/app-tests/sidebarNodeOrdering.test.ts b/packages/app-tests/sidebarNodeOrdering.test.ts index caf9eb4966..ce55a3a5d5 100644 --- a/packages/app-tests/sidebarNodeOrdering.test.ts +++ b/packages/app-tests/sidebarNodeOrdering.test.ts @@ -60,3 +60,25 @@ test("keeps DuckDB schemas before attached catalogs while sorting both", () => { ], ); }); + +test("keeps connection utility nodes in fixed positions", () => { + const parent: Pick = { type: "connection" }; + const children: TreeNode[] = [ + { id: "conn:__user_admin", label: "tree.userAdmin", type: "user-admin" }, + { id: "conn:z", label: "z", type: "database" }, + { id: "conn:__saved_sql", label: "tree.savedSql", type: "saved-sql-root" }, + { id: "conn:a", label: "a", type: "database" }, + ]; + + const sorted = sortSidebarTreeChildrenForParent(parent, children, "postgres"); + + assert.deepEqual( + sorted.map((child) => [child.type, child.label]), + [ + ["saved-sql-root", "tree.savedSql"], + ["database", "a"], + ["database", "z"], + ["user-admin", "tree.userAdmin"], + ], + ); +}); diff --git a/packages/app-tests/sidebarTreeItemLayout.test.ts b/packages/app-tests/sidebarTreeItemLayout.test.ts index d439e245e6..a34855cb83 100644 --- a/packages/app-tests/sidebarTreeItemLayout.test.ts +++ b/packages/app-tests/sidebarTreeItemLayout.test.ts @@ -28,6 +28,7 @@ test("canTreeNodeExpand only returns true for expandable sidebar node types", () assert.equal(canTreeNodeExpand("index"), false); assert.equal(canTreeNodeExpand("redis-db"), false); assert.equal(canTreeNodeExpand("mongo-collection"), false); + assert.equal(canTreeNodeExpand("user-admin"), false); }); test("canTreeNodeShowExpander hides empty saved SQL containers", () => {