diff --git a/src-tauri/src/commands/data.rs b/src-tauri/src/commands/data.rs index 83057c0..a46eb73 100644 --- a/src-tauri/src/commands/data.rs +++ b/src-tauri/src/commands/data.rs @@ -2,15 +2,12 @@ use crate::commands::queries::pg_value_to_json; use crate::error::{TuskError, TuskResult}; use crate::models::query_result::PaginatedQueryResult; use crate::state::AppState; +use crate::utils::escape_ident; use serde_json::Value; use sqlx::{Column, Row, TypeInfo}; use std::time::Instant; use tauri::State; -fn escape_ident(name: &str) -> String { - format!("\"{}\"", name.replace('"', "\"\"")) -} - #[tauri::command] pub async fn get_table_data( state: State<'_, AppState>, diff --git a/src-tauri/src/commands/management.rs b/src-tauri/src/commands/management.rs new file mode 100644 index 0000000..f458f89 --- /dev/null +++ b/src-tauri/src/commands/management.rs @@ -0,0 +1,509 @@ +use crate::error::{TuskError, TuskResult}; +use crate::models::management::*; +use crate::state::AppState; +use crate::utils::escape_ident; +use sqlx::Row; +use tauri::State; + +#[tauri::command] +pub async fn get_database_info( + state: State<'_, AppState>, + connection_id: String, +) -> TuskResult> { + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let rows = sqlx::query( + "SELECT d.datname, \ + pg_catalog.pg_get_userbyid(d.datdba) AS owner, \ + pg_catalog.pg_encoding_to_char(d.encoding) AS encoding, \ + d.datcollate, \ + d.datctype, \ + COALESCE(t.spcname, 'pg_default') AS tablespace, \ + d.datconnlimit, \ + pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(d.datname)) AS size, \ + pg_catalog.shobj_description(d.oid, 'pg_database') AS description \ + FROM pg_catalog.pg_database d \ + LEFT JOIN pg_catalog.pg_tablespace t ON d.dattablespace = t.oid \ + WHERE NOT d.datistemplate \ + ORDER BY d.datname", + ) + .fetch_all(pool) + .await + .map_err(TuskError::Database)?; + + let databases = rows + .iter() + .map(|row| DatabaseInfo { + name: row.get("datname"), + owner: row.get("owner"), + encoding: row.get("encoding"), + collation: row.get("datcollate"), + ctype: row.get("datctype"), + tablespace: row.get("tablespace"), + connection_limit: row.get("datconnlimit"), + size: row.get("size"), + description: row.get("description"), + }) + .collect(); + + Ok(databases) +} + +#[tauri::command] +pub async fn create_database( + state: State<'_, AppState>, + connection_id: String, + params: CreateDatabaseParams, +) -> TuskResult<()> { + if state.is_read_only(&connection_id).await { + return Err(TuskError::ReadOnly); + } + + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let mut sql = format!("CREATE DATABASE {}", escape_ident(¶ms.name)); + + if let Some(ref owner) = params.owner { + sql.push_str(&format!(" OWNER {}", escape_ident(owner))); + } + if let Some(ref template) = params.template { + sql.push_str(&format!(" TEMPLATE {}", escape_ident(template))); + } + if let Some(ref encoding) = params.encoding { + sql.push_str(&format!(" ENCODING '{}'", encoding.replace('\'', "''"))); + } + if let Some(ref tablespace) = params.tablespace { + sql.push_str(&format!(" TABLESPACE {}", escape_ident(tablespace))); + } + if let Some(limit) = params.connection_limit { + sql.push_str(&format!(" CONNECTION LIMIT {}", limit)); + } + + sqlx::query(&sql) + .execute(pool) + .await + .map_err(TuskError::Database)?; + + Ok(()) +} + +#[tauri::command] +pub async fn drop_database( + state: State<'_, AppState>, + connection_id: String, + name: String, +) -> TuskResult<()> { + if state.is_read_only(&connection_id).await { + return Err(TuskError::ReadOnly); + } + + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + // Terminate active connections to the target database + let terminate_sql = format!( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()", + name.replace('\'', "''") + ); + sqlx::query(&terminate_sql) + .execute(pool) + .await + .map_err(TuskError::Database)?; + + let drop_sql = format!("DROP DATABASE {}", escape_ident(&name)); + sqlx::query(&drop_sql) + .execute(pool) + .await + .map_err(TuskError::Database)?; + + Ok(()) +} + +#[tauri::command] +pub async fn list_roles( + state: State<'_, AppState>, + connection_id: String, +) -> TuskResult> { + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let rows = sqlx::query( + "SELECT r.rolname, \ + r.rolsuper, \ + r.rolcanlogin, \ + r.rolcreatedb, \ + r.rolcreaterole, \ + r.rolinherit, \ + r.rolreplication, \ + r.rolconnlimit, \ + r.rolpassword IS NOT NULL AS password_set, \ + r.rolvaliduntil::text, \ + COALESCE(( \ + SELECT array_agg(g.rolname ORDER BY g.rolname) \ + FROM pg_catalog.pg_auth_members m \ + JOIN pg_catalog.pg_roles g ON m.roleid = g.oid \ + WHERE m.member = r.oid \ + ), ARRAY[]::text[]) AS member_of, \ + COALESCE(( \ + SELECT array_agg(m2.rolname ORDER BY m2.rolname) \ + FROM pg_catalog.pg_auth_members am \ + JOIN pg_catalog.pg_roles m2 ON am.member = m2.oid \ + WHERE am.roleid = r.oid \ + ), ARRAY[]::text[]) AS members, \ + pg_catalog.shobj_description(r.oid, 'pg_authid') AS description \ + FROM pg_catalog.pg_roles r \ + WHERE r.rolname !~ '^pg_' \ + ORDER BY r.rolname", + ) + .fetch_all(pool) + .await + .map_err(TuskError::Database)?; + + let roles = rows + .iter() + .map(|row| RoleInfo { + name: row.get("rolname"), + is_superuser: row.get("rolsuper"), + can_login: row.get("rolcanlogin"), + can_create_db: row.get("rolcreatedb"), + can_create_role: row.get("rolcreaterole"), + inherit: row.get("rolinherit"), + is_replication: row.get("rolreplication"), + connection_limit: row.get("rolconnlimit"), + password_set: row.get("password_set"), + valid_until: row.get("rolvaliduntil"), + member_of: row.get("member_of"), + members: row.get("members"), + description: row.get("description"), + }) + .collect(); + + Ok(roles) +} + +#[tauri::command] +pub async fn create_role( + state: State<'_, AppState>, + connection_id: String, + params: CreateRoleParams, +) -> TuskResult<()> { + if state.is_read_only(&connection_id).await { + return Err(TuskError::ReadOnly); + } + + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let mut sql = format!("CREATE ROLE {}", escape_ident(¶ms.name)); + + let mut options = Vec::new(); + options.push(if params.login { "LOGIN" } else { "NOLOGIN" }); + options.push(if params.superuser { + "SUPERUSER" + } else { + "NOSUPERUSER" + }); + options.push(if params.createdb { + "CREATEDB" + } else { + "NOCREATEDB" + }); + options.push(if params.createrole { + "CREATEROLE" + } else { + "NOCREATEROLE" + }); + options.push(if params.inherit { + "INHERIT" + } else { + "NOINHERIT" + }); + options.push(if params.replication { + "REPLICATION" + } else { + "NOREPLICATION" + }); + + if let Some(ref password) = params.password { + options.push("PASSWORD"); + // Will be appended separately + sql.push_str(&format!(" {}", options.join(" "))); + sql.push_str(&format!(" '{}'", password.replace('\'', "''"))); + } else { + sql.push_str(&format!(" {}", options.join(" "))); + } + + if let Some(limit) = params.connection_limit { + sql.push_str(&format!(" CONNECTION LIMIT {}", limit)); + } + + if let Some(ref valid_until) = params.valid_until { + sql.push_str(&format!( + " VALID UNTIL '{}'", + valid_until.replace('\'', "''") + )); + } + + if !params.in_roles.is_empty() { + let roles: Vec = params.in_roles.iter().map(|r| escape_ident(r)).collect(); + sql.push_str(&format!(" IN ROLE {}", roles.join(", "))); + } + + sqlx::query(&sql) + .execute(pool) + .await + .map_err(TuskError::Database)?; + + Ok(()) +} + +#[tauri::command] +pub async fn alter_role( + state: State<'_, AppState>, + connection_id: String, + params: AlterRoleParams, +) -> TuskResult<()> { + if state.is_read_only(&connection_id).await { + return Err(TuskError::ReadOnly); + } + + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let mut options = Vec::new(); + + if let Some(login) = params.login { + options.push(if login { + "LOGIN".to_string() + } else { + "NOLOGIN".to_string() + }); + } + if let Some(superuser) = params.superuser { + options.push(if superuser { + "SUPERUSER".to_string() + } else { + "NOSUPERUSER".to_string() + }); + } + if let Some(createdb) = params.createdb { + options.push(if createdb { + "CREATEDB".to_string() + } else { + "NOCREATEDB".to_string() + }); + } + if let Some(createrole) = params.createrole { + options.push(if createrole { + "CREATEROLE".to_string() + } else { + "NOCREATEROLE".to_string() + }); + } + if let Some(inherit) = params.inherit { + options.push(if inherit { + "INHERIT".to_string() + } else { + "NOINHERIT".to_string() + }); + } + if let Some(replication) = params.replication { + options.push(if replication { + "REPLICATION".to_string() + } else { + "NOREPLICATION".to_string() + }); + } + if let Some(ref password) = params.password { + options.push(format!("PASSWORD '{}'", password.replace('\'', "''"))); + } + if let Some(limit) = params.connection_limit { + options.push(format!("CONNECTION LIMIT {}", limit)); + } + if let Some(ref valid_until) = params.valid_until { + options.push(format!( + "VALID UNTIL '{}'", + valid_until.replace('\'', "''") + )); + } + + if !options.is_empty() { + let sql = format!( + "ALTER ROLE {} {}", + escape_ident(¶ms.name), + options.join(" ") + ); + sqlx::query(&sql) + .execute(pool) + .await + .map_err(TuskError::Database)?; + } + + if let Some(ref new_name) = params.rename_to { + let sql = format!( + "ALTER ROLE {} RENAME TO {}", + escape_ident(¶ms.name), + escape_ident(new_name) + ); + sqlx::query(&sql) + .execute(pool) + .await + .map_err(TuskError::Database)?; + } + + Ok(()) +} + +#[tauri::command] +pub async fn drop_role( + state: State<'_, AppState>, + connection_id: String, + name: String, +) -> TuskResult<()> { + if state.is_read_only(&connection_id).await { + return Err(TuskError::ReadOnly); + } + + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let sql = format!("DROP ROLE {}", escape_ident(&name)); + sqlx::query(&sql) + .execute(pool) + .await + .map_err(TuskError::Database)?; + + Ok(()) +} + +#[tauri::command] +pub async fn get_table_privileges( + state: State<'_, AppState>, + connection_id: String, + schema: String, + table: String, +) -> TuskResult> { + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let rows = sqlx::query( + "SELECT grantee, table_schema, table_name, privilege_type, \ + is_grantable = 'YES' AS is_grantable \ + FROM information_schema.role_table_grants \ + WHERE table_schema = $1 AND table_name = $2 \ + ORDER BY grantee, privilege_type", + ) + .bind(&schema) + .bind(&table) + .fetch_all(pool) + .await + .map_err(TuskError::Database)?; + + let privileges = rows + .iter() + .map(|row| TablePrivilege { + grantee: row.get("grantee"), + table_schema: row.get("table_schema"), + table_name: row.get("table_name"), + privilege_type: row.get("privilege_type"), + is_grantable: row.get("is_grantable"), + }) + .collect(); + + Ok(privileges) +} + +#[tauri::command] +pub async fn grant_revoke( + state: State<'_, AppState>, + connection_id: String, + params: GrantRevokeParams, +) -> TuskResult<()> { + if state.is_read_only(&connection_id).await { + return Err(TuskError::ReadOnly); + } + + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let privs = params.privileges.join(", "); + let object_type = params.object_type.to_uppercase(); + let object_ref = escape_ident(¶ms.object_name); + let role_ref = escape_ident(¶ms.role_name); + + let sql = if params.action.to_uppercase() == "GRANT" { + let grant_option = if params.with_grant_option { + " WITH GRANT OPTION" + } else { + "" + }; + format!( + "GRANT {} ON {} {} TO {}{}", + privs, object_type, object_ref, role_ref, grant_option + ) + } else { + format!( + "REVOKE {} ON {} {} FROM {}", + privs, object_type, object_ref, role_ref + ) + }; + + sqlx::query(&sql) + .execute(pool) + .await + .map_err(TuskError::Database)?; + + Ok(()) +} + +#[tauri::command] +pub async fn manage_role_membership( + state: State<'_, AppState>, + connection_id: String, + params: RoleMembershipParams, +) -> TuskResult<()> { + if state.is_read_only(&connection_id).await { + return Err(TuskError::ReadOnly); + } + + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let role_ref = escape_ident(¶ms.role_name); + let member_ref = escape_ident(¶ms.member_name); + + let sql = if params.action.to_uppercase() == "GRANT" { + format!("GRANT {} TO {}", role_ref, member_ref) + } else { + format!("REVOKE {} FROM {}", role_ref, member_ref) + }; + + sqlx::query(&sql) + .execute(pool) + .await + .map_err(TuskError::Database)?; + + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6e5a69a..3314687 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,5 +2,6 @@ pub mod connections; pub mod data; pub mod export; pub mod history; +pub mod management; pub mod queries; pub mod schema; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e96bb0..6081a74 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod commands; mod error; mod models; mod state; +mod utils; use state::AppState; @@ -43,6 +44,17 @@ pub fn run() { // export commands::export::export_csv, commands::export::export_json, + // management + commands::management::get_database_info, + commands::management::create_database, + commands::management::drop_database, + commands::management::list_roles, + commands::management::create_role, + commands::management::alter_role, + commands::management::drop_role, + commands::management::get_table_privileges, + commands::management::grant_revoke, + commands::management::manage_role_membership, // history commands::history::add_history_entry, commands::history::get_history, diff --git a/src-tauri/src/models/management.rs b/src-tauri/src/models/management.rs new file mode 100644 index 0000000..192c3c1 --- /dev/null +++ b/src-tauri/src/models/management.rs @@ -0,0 +1,97 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct DatabaseInfo { + pub name: String, + pub owner: String, + pub encoding: String, + pub collation: String, + pub ctype: String, + pub tablespace: String, + pub connection_limit: i32, + pub size: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateDatabaseParams { + pub name: String, + pub owner: Option, + pub template: Option, + pub encoding: Option, + pub tablespace: Option, + pub connection_limit: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoleInfo { + pub name: String, + pub is_superuser: bool, + pub can_login: bool, + pub can_create_db: bool, + pub can_create_role: bool, + pub inherit: bool, + pub is_replication: bool, + pub connection_limit: i32, + pub password_set: bool, + pub valid_until: Option, + pub member_of: Vec, + pub members: Vec, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateRoleParams { + pub name: String, + pub password: Option, + pub login: bool, + pub superuser: bool, + pub createdb: bool, + pub createrole: bool, + pub inherit: bool, + pub replication: bool, + pub connection_limit: Option, + pub valid_until: Option, + pub in_roles: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AlterRoleParams { + pub name: String, + pub password: Option, + pub login: Option, + pub superuser: Option, + pub createdb: Option, + pub createrole: Option, + pub inherit: Option, + pub replication: Option, + pub connection_limit: Option, + pub valid_until: Option, + pub rename_to: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TablePrivilege { + pub grantee: String, + pub table_schema: String, + pub table_name: String, + pub privilege_type: String, + pub is_grantable: bool, +} + +#[derive(Debug, Deserialize)] +pub struct GrantRevokeParams { + pub action: String, + pub privileges: Vec, + pub object_type: String, + pub object_name: String, + pub role_name: String, + pub with_grant_option: bool, +} + +#[derive(Debug, Deserialize)] +pub struct RoleMembershipParams { + pub action: String, + pub role_name: String, + pub member_name: String, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index adae318..c32fd4f 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod connection; pub mod history; +pub mod management; pub mod query_result; pub mod schema; diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs new file mode 100644 index 0000000..4bb82d1 --- /dev/null +++ b/src-tauri/src/utils.rs @@ -0,0 +1,3 @@ +pub fn escape_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4e7b5f7..b127758 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -2,9 +2,10 @@ import { useState } from "react"; import { Input } from "@/components/ui/input"; import { SchemaTree } from "@/components/schema/SchemaTree"; import { HistoryPanel } from "@/components/history/HistoryPanel"; +import { AdminPanel } from "@/components/management/AdminPanel"; import { Search } from "lucide-react"; -type SidebarView = "schema" | "history"; +type SidebarView = "schema" | "history" | "admin"; export function Sidebar() { const [view, setView] = useState("schema"); @@ -33,6 +34,16 @@ export function Sidebar() { > History + {view === "schema" ? ( @@ -52,8 +63,10 @@ export function Sidebar() { - ) : ( + ) : view === "history" ? ( + ) : ( + )} ); diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx index eb4b337..d2bfbe4 100644 --- a/src/components/layout/TabBar.tsx +++ b/src/components/layout/TabBar.tsx @@ -1,7 +1,7 @@ import { useAppStore } from "@/stores/app-store"; import { useConnections } from "@/hooks/use-connections"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { X, Table2, Code, Columns } from "lucide-react"; +import { X, Table2, Code, Columns, Users } from "lucide-react"; export function TabBar() { const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore(); @@ -9,10 +9,11 @@ export function TabBar() { if (tabs.length === 0) return null; - const iconMap = { + const iconMap: Record = { query: , table: , structure: , + roles: , }; return ( diff --git a/src/components/management/AdminPanel.tsx b/src/components/management/AdminPanel.tsx new file mode 100644 index 0000000..767de2c --- /dev/null +++ b/src/components/management/AdminPanel.tsx @@ -0,0 +1,300 @@ +import { useState } from "react"; +import { + useDatabaseInfo, + useRoles, + useDropDatabase, + useDropRole, +} from "@/hooks/use-management"; +import { useAppStore } from "@/stores/app-store"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { CreateDatabaseDialog } from "./CreateDatabaseDialog"; +import { CreateRoleDialog } from "./CreateRoleDialog"; +import { AlterRoleDialog } from "./AlterRoleDialog"; +import { toast } from "sonner"; +import { + Plus, + Pencil, + Trash2, + ChevronDown, + ChevronRight, + HardDrive, + Users, + Loader2, +} from "lucide-react"; +import type { Tab, RoleInfo } from "@/types"; + +export function AdminPanel() { + const { activeConnectionId, currentDatabase, readOnlyMap, addTab } = useAppStore(); + + if (!activeConnectionId) { + return ( +
+ Connect to a database to manage it. +
+ ); + } + + const isReadOnly = readOnlyMap[activeConnectionId] ?? true; + + return ( +
+ + { + const tab: Tab = { + id: crypto.randomUUID(), + type: "roles", + title: "Roles & Users", + connectionId: activeConnectionId, + }; + addTab(tab); + }} + /> +
+ ); +} + +function DatabasesSection({ + connectionId, + currentDatabase, + isReadOnly, +}: { + connectionId: string; + currentDatabase: string | null; + isReadOnly: boolean; +}) { + const [expanded, setExpanded] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const { data: databases, isLoading } = useDatabaseInfo(connectionId); + const dropMutation = useDropDatabase(); + + const handleDrop = (name: string) => { + if (name === currentDatabase) { + toast.error("Cannot drop the active database"); + return; + } + if (!confirm(`Drop database "${name}"? This will terminate all connections and cannot be undone.`)) { + return; + } + dropMutation.mutate( + { connectionId, name }, + { + onSuccess: () => toast.success(`Database "${name}" dropped`), + onError: (err) => toast.error("Failed to drop database", { description: String(err) }), + } + ); + }; + + return ( +
+
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + + Databases + {!isReadOnly && ( + + )} +
+ + {expanded && ( +
+ {isLoading && ( +
+ Loading... +
+ )} + {databases?.map((db) => ( +
+ {db.name} + {db.size} + {db.name === currentDatabase && ( + + active + + )} + {!isReadOnly && db.name !== currentDatabase && ( + + )} +
+ ))} +
+ )} + + +
+ ); +} + +function RolesSection({ + connectionId, + isReadOnly, + onOpenRoleManager, +}: { + connectionId: string; + isReadOnly: boolean; + onOpenRoleManager: () => void; +}) { + const [expanded, setExpanded] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [alterRole, setAlterRole] = useState(null); + const { data: roles, isLoading } = useRoles(connectionId); + const dropMutation = useDropRole(); + + const handleDrop = (name: string) => { + if (!confirm(`Drop role "${name}"? This cannot be undone.`)) return; + dropMutation.mutate( + { connectionId, name }, + { + onSuccess: () => toast.success(`Role "${name}" dropped`), + onError: (err) => toast.error("Failed to drop role", { description: String(err) }), + } + ); + }; + + return ( +
+
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + + Roles +
e.stopPropagation()}> + + {!isReadOnly && ( + + )} +
+
+ + {expanded && ( +
+ {isLoading && ( +
+ Loading... +
+ )} + {roles?.map((role) => ( +
+ {role.name} +
+ {role.can_login && ( + + LOGIN + + )} + {role.is_superuser && ( + + SUPER + + )} +
+
+ {!isReadOnly && ( + <> + + + + )} +
+
+ ))} +
+ )} + + + !open && setAlterRole(null)} + connectionId={connectionId} + role={alterRole} + /> +
+ ); +} diff --git a/src/components/management/AlterRoleDialog.tsx b/src/components/management/AlterRoleDialog.tsx new file mode 100644 index 0000000..347e194 --- /dev/null +++ b/src/components/management/AlterRoleDialog.tsx @@ -0,0 +1,183 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useAlterRole } from "@/hooks/use-management"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import type { RoleInfo } from "@/types"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionId: string; + role: RoleInfo | null; +} + +export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Props) { + const [password, setPassword] = useState(""); + const [login, setLogin] = useState(false); + const [superuser, setSuperuser] = useState(false); + const [createdb, setCreatedb] = useState(false); + const [createrole, setCreaterole] = useState(false); + const [inherit, setInherit] = useState(true); + const [replication, setReplication] = useState(false); + const [connectionLimit, setConnectionLimit] = useState(-1); + const [validUntil, setValidUntil] = useState(""); + const [renameTo, setRenameTo] = useState(""); + + const alterMutation = useAlterRole(); + + useEffect(() => { + if (open && role) { + setPassword(""); + setLogin(role.can_login); + setSuperuser(role.is_superuser); + setCreatedb(role.can_create_db); + setCreaterole(role.can_create_role); + setInherit(role.inherit); + setReplication(role.is_replication); + setConnectionLimit(role.connection_limit); + setValidUntil(role.valid_until ?? ""); + setRenameTo(""); + } + }, [open, role]); + + if (!role) return null; + + const handleAlter = () => { + const params: Record = { name: role.name }; + + if (password) params.password = password; + if (login !== role.can_login) params.login = login; + if (superuser !== role.is_superuser) params.superuser = superuser; + if (createdb !== role.can_create_db) params.createdb = createdb; + if (createrole !== role.can_create_role) params.createrole = createrole; + if (inherit !== role.inherit) params.inherit = inherit; + if (replication !== role.is_replication) params.replication = replication; + if (connectionLimit !== role.connection_limit) params.connection_limit = connectionLimit; + if (validUntil !== (role.valid_until ?? "")) params.valid_until = validUntil || undefined; + if (renameTo.trim()) params.rename_to = renameTo.trim(); + + alterMutation.mutate( + { + connectionId, + params: params as { + name: string; + password?: string; + login?: boolean; + superuser?: boolean; + createdb?: boolean; + createrole?: boolean; + inherit?: boolean; + replication?: boolean; + connection_limit?: number; + valid_until?: string; + rename_to?: string; + }, + }, + { + onSuccess: () => { + toast.success(`Role "${role.name}" updated`); + onOpenChange(false); + }, + onError: (err) => { + toast.error("Failed to alter role", { description: String(err) }); + }, + } + ); + }; + + return ( + + + + Alter Role: {role.name} + + +
+
+ + setRenameTo(e.target.value)} + placeholder={role.name} + /> +
+
+ + setPassword(e.target.value)} + placeholder="Leave empty to keep unchanged" + /> +
+ +
+ +
+ {([ + ["LOGIN", login, setLogin], + ["SUPERUSER", superuser, setSuperuser], + ["CREATEDB", createdb, setCreatedb], + ["CREATEROLE", createrole, setCreaterole], + ["INHERIT", inherit, setInherit], + ["REPLICATION", replication, setReplication], + ] as const).map(([label, value, setter]) => ( + + ))} +
+
+ +
+ + setConnectionLimit(parseInt(e.target.value) || -1)} + /> +
+
+ + setValidUntil(e.target.value)} + /> +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/management/CreateDatabaseDialog.tsx b/src/components/management/CreateDatabaseDialog.tsx new file mode 100644 index 0000000..09ab07e --- /dev/null +++ b/src/components/management/CreateDatabaseDialog.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useCreateDatabase, useRoles } from "@/hooks/use-management"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionId: string; +} + +export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props) { + const [name, setName] = useState(""); + const [owner, setOwner] = useState("__default__"); + const [template, setTemplate] = useState("__default__"); + const [encoding, setEncoding] = useState("UTF8"); + const [connectionLimit, setConnectionLimit] = useState(-1); + + const { data: roles } = useRoles(open ? connectionId : null); + const createMutation = useCreateDatabase(); + + useEffect(() => { + if (open) { + setName(""); + setOwner("__default__"); + setTemplate("__default__"); + setEncoding("UTF8"); + setConnectionLimit(-1); + } + }, [open]); + + const handleCreate = () => { + if (!name.trim()) { + toast.error("Database name is required"); + return; + } + createMutation.mutate( + { + connectionId, + params: { + name: name.trim(), + owner: owner === "__default__" ? undefined : owner, + template: template === "__default__" ? undefined : template, + encoding, + connection_limit: connectionLimit, + }, + }, + { + onSuccess: () => { + toast.success(`Database "${name}" created`); + onOpenChange(false); + }, + onError: (err) => { + toast.error("Failed to create database", { description: String(err) }); + }, + } + ); + }; + + return ( + + + + Create Database + + +
+
+ + setName(e.target.value)} + placeholder="my_database" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + setConnectionLimit(parseInt(e.target.value) || -1)} + /> +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/management/CreateRoleDialog.tsx b/src/components/management/CreateRoleDialog.tsx new file mode 100644 index 0000000..91d2506 --- /dev/null +++ b/src/components/management/CreateRoleDialog.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useCreateRole, useRoles } from "@/hooks/use-management"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionId: string; +} + +export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) { + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [login, setLogin] = useState(true); + const [superuser, setSuperuser] = useState(false); + const [createdb, setCreatedb] = useState(false); + const [createrole, setCreaterole] = useState(false); + const [inherit, setInherit] = useState(true); + const [replication, setReplication] = useState(false); + const [connectionLimit, setConnectionLimit] = useState(-1); + const [validUntil, setValidUntil] = useState(""); + const [inRoles, setInRoles] = useState([]); + + const { data: roles } = useRoles(open ? connectionId : null); + const createMutation = useCreateRole(); + + useEffect(() => { + if (open) { + setName(""); + setPassword(""); + setLogin(true); + setSuperuser(false); + setCreatedb(false); + setCreaterole(false); + setInherit(true); + setReplication(false); + setConnectionLimit(-1); + setValidUntil(""); + setInRoles([]); + } + }, [open]); + + const handleCreate = () => { + if (!name.trim()) { + toast.error("Role name is required"); + return; + } + createMutation.mutate( + { + connectionId, + params: { + name: name.trim(), + password: password || undefined, + login, + superuser, + createdb, + createrole, + inherit, + replication, + connection_limit: connectionLimit, + valid_until: validUntil || undefined, + in_roles: inRoles, + }, + }, + { + onSuccess: () => { + toast.success(`Role "${name}" created`); + onOpenChange(false); + }, + onError: (err) => { + toast.error("Failed to create role", { description: String(err) }); + }, + } + ); + }; + + const toggleInRole = (roleName: string) => { + setInRoles((prev) => + prev.includes(roleName) + ? prev.filter((r) => r !== roleName) + : [...prev, roleName] + ); + }; + + return ( + + + + Create Role + + +
+
+ + setName(e.target.value)} + placeholder="my_role" + /> +
+
+ + setPassword(e.target.value)} + placeholder="Optional" + /> +
+ +
+ +
+ {([ + ["LOGIN", login, setLogin], + ["SUPERUSER", superuser, setSuperuser], + ["CREATEDB", createdb, setCreatedb], + ["CREATEROLE", createrole, setCreaterole], + ["INHERIT", inherit, setInherit], + ["REPLICATION", replication, setReplication], + ] as const).map(([label, value, setter]) => ( + + ))} +
+
+ +
+ + setConnectionLimit(parseInt(e.target.value) || -1)} + /> +
+
+ + setValidUntil(e.target.value)} + /> +
+ + {roles && roles.length > 0 && ( +
+ +
+ {roles.map((r) => ( + + ))} +
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/management/GrantRevokeDialog.tsx b/src/components/management/GrantRevokeDialog.tsx new file mode 100644 index 0000000..b63c40c --- /dev/null +++ b/src/components/management/GrantRevokeDialog.tsx @@ -0,0 +1,243 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + useRoles, + useGrantRevoke, + useTablePrivileges, +} from "@/hooks/use-management"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +const PRIVILEGE_OPTIONS: Record = { + TABLE: ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "ALL"], + SCHEMA: ["USAGE", "CREATE"], + DATABASE: ["CONNECT", "CREATE", "TEMPORARY"], +}; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionId: string; + objectType: string; + objectName: string; + schema?: string; + table?: string; +} + +export function GrantRevokeDialog({ + open, + onOpenChange, + connectionId, + objectType, + objectName, + schema, + table, +}: Props) { + const [action, setAction] = useState("GRANT"); + const [roleName, setRoleName] = useState(""); + const [privileges, setPrivileges] = useState([]); + const [withGrantOption, setWithGrantOption] = useState(false); + + const { data: roles } = useRoles(open ? connectionId : null); + const { data: existingPrivileges } = useTablePrivileges( + open && objectType === "TABLE" ? connectionId : null, + schema ?? null, + table ?? null + ); + const grantRevokeMutation = useGrantRevoke(); + + useEffect(() => { + if (open) { + setAction("GRANT"); + setRoleName(""); + setPrivileges([]); + setWithGrantOption(false); + } + }, [open]); + + const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE; + + const togglePrivilege = (priv: string) => { + setPrivileges((prev) => + prev.includes(priv) + ? prev.filter((p) => p !== priv) + : [...prev, priv] + ); + }; + + const handleSubmit = () => { + if (!roleName) { + toast.error("Please select a role"); + return; + } + if (privileges.length === 0) { + toast.error("Please select at least one privilege"); + return; + } + grantRevokeMutation.mutate( + { + connectionId, + params: { + action, + privileges, + object_type: objectType, + object_name: objectName, + role_name: roleName, + with_grant_option: withGrantOption, + }, + }, + { + onSuccess: () => { + toast.success( + `${action === "GRANT" ? "Granted" : "Revoked"} privileges on ${objectName}` + ); + onOpenChange(false); + }, + onError: (err) => { + toast.error("Operation failed", { description: String(err) }); + }, + } + ); + }; + + return ( + + + + Manage Privileges + + +
+
+ +
+ {objectType}{" "} + {objectName} +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+ {availablePrivileges.map((priv) => ( + + ))} +
+
+ + {action === "GRANT" && ( +
+ + +
+ )} + + {existingPrivileges && existingPrivileges.length > 0 && ( +
+ +
+
+ {existingPrivileges.map((p, i) => ( +
+ {p.grantee} + + {p.privilege_type} + + {p.is_grantable && ( + + GRANTABLE + + )} +
+ ))} +
+
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/management/RoleManagerView.tsx b/src/components/management/RoleManagerView.tsx new file mode 100644 index 0000000..c6f0438 --- /dev/null +++ b/src/components/management/RoleManagerView.tsx @@ -0,0 +1,306 @@ +import { useState } from "react"; +import { useRoles, useDropRole, useManageRoleMembership } from "@/hooks/use-management"; +import { useAppStore } from "@/stores/app-store"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { CreateRoleDialog } from "./CreateRoleDialog"; +import { AlterRoleDialog } from "./AlterRoleDialog"; +import { toast } from "sonner"; +import { Plus, Pencil, Trash2, UserPlus, UserMinus, Loader2 } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { RoleInfo } from "@/types"; + +interface Props { + connectionId: string; +} + +export function RoleManagerView({ connectionId }: Props) { + const { data: roles, isLoading } = useRoles(connectionId); + const readOnlyMap = useAppStore((s) => s.readOnlyMap); + const isReadOnly = readOnlyMap[connectionId] ?? true; + const dropMutation = useDropRole(); + const membershipMutation = useManageRoleMembership(); + + const [createOpen, setCreateOpen] = useState(false); + const [alterRole, setAlterRole] = useState(null); + const [selectedRole, setSelectedRole] = useState(null); + const [memberToAdd, setMemberToAdd] = useState(""); + + const handleDrop = (name: string) => { + if (!confirm(`Drop role "${name}"? This cannot be undone.`)) return; + dropMutation.mutate( + { connectionId, name }, + { + onSuccess: () => toast.success(`Role "${name}" dropped`), + onError: (err) => toast.error("Failed to drop role", { description: String(err) }), + } + ); + }; + + const handleAddMember = (roleName: string, memberName: string) => { + membershipMutation.mutate( + { + connectionId, + params: { action: "GRANT", role_name: roleName, member_name: memberName }, + }, + { + onSuccess: () => { + toast.success(`Added "${memberName}" to "${roleName}"`); + setMemberToAdd(""); + }, + onError: (err) => toast.error("Failed", { description: String(err) }), + } + ); + }; + + const handleRemoveMember = (roleName: string, memberName: string) => { + membershipMutation.mutate( + { + connectionId, + params: { action: "REVOKE", role_name: roleName, member_name: memberName }, + }, + { + onSuccess: () => toast.success(`Removed "${memberName}" from "${roleName}"`), + onError: (err) => toast.error("Failed", { description: String(err) }), + } + ); + }; + + const selected = roles?.find((r) => r.name === selectedRole); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Roles & Users

+ +
+ +
+
+ + + + + + + + + + + + + + + {roles?.map((role) => ( + setSelectedRole(role.name)} + > + + + + + + + + + + ))} + +
NameLoginSuperuserCreateDBCreateRoleConn LimitMember OfActions
{role.name} + + + + + + + + + {role.connection_limit === -1 ? "unlimited" : role.connection_limit} + +
+ {role.member_of.map((r) => ( + + {r} + + ))} +
+
+
+ + +
+
+
+ + {selected && ( +
+
+

{selected.name}

+ +
+
+

Member Of

+
+ {selected.member_of.length === 0 && ( + None + )} + {selected.member_of.map((r) => ( +
+ + {r} + + {!isReadOnly && ( + + )} +
+ ))} +
+
+ +
+

Members

+
+ {selected.members.length === 0 && ( + None + )} + {selected.members.map((m) => ( +
+ + {m} + + {!isReadOnly && ( + + )} +
+ ))} +
+
+ + {!isReadOnly && ( +
+

Add Member

+
+ + +
+
+ )} + + {selected.description && ( +
+

Description

+

{selected.description}

+
+ )} +
+
+
+ )} +
+ + + !open && setAlterRole(null)} + connectionId={connectionId} + role={alterRole} + /> +
+ ); +} + +function BoolBadge({ value }: { value: boolean }) { + return ( + + {value ? "Yes" : "No"} + + ); +} diff --git a/src/components/schema/SchemaTree.tsx b/src/components/schema/SchemaTree.tsx index ac91d69..fdabced 100644 --- a/src/components/schema/SchemaTree.tsx +++ b/src/components/schema/SchemaTree.tsx @@ -9,6 +9,7 @@ import { useSequences, } from "@/hooks/use-schema"; import { useConnections } from "@/hooks/use-connections"; +import { useDropDatabase } from "@/hooks/use-management"; import { useAppStore } from "@/stores/app-store"; import { toast } from "sonner"; import { @@ -26,8 +27,10 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import { GrantRevokeDialog } from "@/components/management/GrantRevokeDialog"; import type { Tab } from "@/types"; export function SchemaTree() { @@ -129,6 +132,9 @@ function DatabaseNode({ onViewStructure: (schema: string, table: string) => void; }) { const [expanded, setExpanded] = useState(false); + const readOnlyMap = useAppStore((s) => s.readOnlyMap); + const isReadOnly = readOnlyMap[connectionId] ?? true; + const dropDbMutation = useDropDatabase(); const handleClick = () => { if (!isActive) { @@ -137,27 +143,63 @@ function DatabaseNode({ setExpanded(!expanded); }; + const handleDropDb = () => { + if (isActive) { + toast.error("Cannot drop the active database"); + return; + } + if (!confirm(`Drop database "${name}"? This will terminate all connections and cannot be undone.`)) { + return; + } + dropDbMutation.mutate( + { connectionId, name }, + { + onSuccess: () => toast.success(`Database "${name}" dropped`), + onError: (err) => toast.error("Failed to drop database", { description: String(err) }), + } + ); + }; + return (
-
- {expanded ? ( - - ) : ( - - )} - - {name} - {isActive && ( - active - )} -
+ + +
+ {expanded ? ( + + ) : ( + + )} + + {name} + {isActive && ( + active + )} +
+
+ + toast.info(`Database: ${name}`, { description: isActive ? "Currently active" : "Not active" })} + > + Properties + + + + Drop Database + + +
{expanded && isActive && (
void; }) { const [expanded, setExpanded] = useState(false); + const [privilegesTarget, setPrivilegesTarget] = useState(null); const tablesQuery = useTables( expanded && category === "tables" ? connectionId : null, @@ -375,6 +418,12 @@ function CategoryNode({ > View Structure + + setPrivilegesTarget(item.name)} + > + View Privileges + ); @@ -393,6 +442,17 @@ function CategoryNode({ })}
)} + {privilegesTarget && ( + !open && setPrivilegesTarget(null)} + connectionId={connectionId} + objectType="TABLE" + objectName={`${schema}.${privilegesTarget}`} + schema={schema} + table={privilegesTarget} + /> + )}
); } diff --git a/src/components/workspace/TabContent.tsx b/src/components/workspace/TabContent.tsx index f9d4315..1f52cc6 100644 --- a/src/components/workspace/TabContent.tsx +++ b/src/components/workspace/TabContent.tsx @@ -2,6 +2,7 @@ import { useAppStore } from "@/stores/app-store"; import { WorkspacePanel } from "./WorkspacePanel"; import { TableDataView } from "@/components/table-viewer/TableDataView"; import { TableStructure } from "@/components/table-viewer/TableStructure"; +import { RoleManagerView } from "@/components/management/RoleManagerView"; export function TabContent() { const { tabs, activeTabId, updateTab } = useAppStore(); @@ -43,6 +44,13 @@ export function TabContent() { table={activeTab.table!} /> ); + case "roles": + return ( + + ); default: return null; } diff --git a/src/hooks/use-management.ts b/src/hooks/use-management.ts new file mode 100644 index 0000000..337ad00 --- /dev/null +++ b/src/hooks/use-management.ts @@ -0,0 +1,166 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getDatabaseInfo, + createDatabase, + dropDatabase, + listRoles, + createRole, + alterRole, + dropRole, + getTablePrivileges, + grantRevoke, + manageRoleMembership, +} from "@/lib/tauri"; +import type { + CreateDatabaseParams, + CreateRoleParams, + AlterRoleParams, + GrantRevokeParams, + RoleMembershipParams, +} from "@/types"; + +// Queries + +export function useDatabaseInfo(connectionId: string | null) { + return useQuery({ + queryKey: ["databaseInfo", connectionId], + queryFn: () => getDatabaseInfo(connectionId!), + enabled: !!connectionId, + }); +} + +export function useRoles(connectionId: string | null) { + return useQuery({ + queryKey: ["roles", connectionId], + queryFn: () => listRoles(connectionId!), + enabled: !!connectionId, + }); +} + +export function useTablePrivileges( + connectionId: string | null, + schema: string | null, + table: string | null +) { + return useQuery({ + queryKey: ["tablePrivileges", connectionId, schema, table], + queryFn: () => getTablePrivileges(connectionId!, schema!, table!), + enabled: !!connectionId && !!schema && !!table, + }); +} + +// Mutations + +export function useCreateDatabase() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + connectionId, + params, + }: { + connectionId: string; + params: CreateDatabaseParams; + }) => createDatabase(connectionId, params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["databaseInfo"] }); + queryClient.invalidateQueries({ queryKey: ["databases"] }); + }, + }); +} + +export function useDropDatabase() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + connectionId, + name, + }: { + connectionId: string; + name: string; + }) => dropDatabase(connectionId, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["databaseInfo"] }); + queryClient.invalidateQueries({ queryKey: ["databases"] }); + }, + }); +} + +export function useCreateRole() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + connectionId, + params, + }: { + connectionId: string; + params: CreateRoleParams; + }) => createRole(connectionId, params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["roles"] }); + }, + }); +} + +export function useAlterRole() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + connectionId, + params, + }: { + connectionId: string; + params: AlterRoleParams; + }) => alterRole(connectionId, params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["roles"] }); + }, + }); +} + +export function useDropRole() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + connectionId, + name, + }: { + connectionId: string; + name: string; + }) => dropRole(connectionId, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["roles"] }); + }, + }); +} + +export function useGrantRevoke() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + connectionId, + params, + }: { + connectionId: string; + params: GrantRevokeParams; + }) => grantRevoke(connectionId, params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tablePrivileges"] }); + }, + }); +} + +export function useManageRoleMembership() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + connectionId, + params, + }: { + connectionId: string; + params: RoleMembershipParams; + }) => manageRoleMembership(connectionId, params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["roles"] }); + }, + }); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index e2ce911..0e651fd 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -8,6 +8,14 @@ import type { ConstraintInfo, IndexInfo, HistoryEntry, + DatabaseInfo, + CreateDatabaseParams, + RoleInfo, + CreateRoleParams, + AlterRoleParams, + TablePrivilege, + GrantRevokeParams, + RoleMembershipParams, } from "@/types"; // Connections @@ -162,3 +170,34 @@ export const exportJson = ( columns: string[], rows: unknown[][] ) => invoke("export_json", { path, columns, rows }); + +// Management +export const getDatabaseInfo = (connectionId: string) => + invoke("get_database_info", { connectionId }); + +export const createDatabase = (connectionId: string, params: CreateDatabaseParams) => + invoke("create_database", { connectionId, params }); + +export const dropDatabase = (connectionId: string, name: string) => + invoke("drop_database", { connectionId, name }); + +export const listRoles = (connectionId: string) => + invoke("list_roles", { connectionId }); + +export const createRole = (connectionId: string, params: CreateRoleParams) => + invoke("create_role", { connectionId, params }); + +export const alterRole = (connectionId: string, params: AlterRoleParams) => + invoke("alter_role", { connectionId, params }); + +export const dropRole = (connectionId: string, name: string) => + invoke("drop_role", { connectionId, name }); + +export const getTablePrivileges = (connectionId: string, schema: string, table: string) => + invoke("get_table_privileges", { connectionId, schema, table }); + +export const grantRevoke = (connectionId: string, params: GrantRevokeParams) => + invoke("grant_revoke", { connectionId, params }); + +export const manageRoleMembership = (connectionId: string, params: RoleMembershipParams) => + invoke("manage_role_membership", { connectionId, params }); diff --git a/src/types/index.ts b/src/types/index.ts index 22f109c..340e3d4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -98,7 +98,95 @@ export interface ExplainResult { "Execution Time": number; } -export type TabType = "query" | "table" | "structure"; +export interface DatabaseInfo { + name: string; + owner: string; + encoding: string; + collation: string; + ctype: string; + tablespace: string; + connection_limit: number; + size: string; + description: string | null; +} + +export interface CreateDatabaseParams { + name: string; + owner?: string; + template?: string; + encoding?: string; + tablespace?: string; + connection_limit?: number; +} + +export interface RoleInfo { + name: string; + is_superuser: boolean; + can_login: boolean; + can_create_db: boolean; + can_create_role: boolean; + inherit: boolean; + is_replication: boolean; + connection_limit: number; + password_set: boolean; + valid_until: string | null; + member_of: string[]; + members: string[]; + description: string | null; +} + +export interface CreateRoleParams { + name: string; + password?: string; + login: boolean; + superuser: boolean; + createdb: boolean; + createrole: boolean; + inherit: boolean; + replication: boolean; + connection_limit?: number; + valid_until?: string; + in_roles: string[]; +} + +export interface AlterRoleParams { + name: string; + password?: string; + login?: boolean; + superuser?: boolean; + createdb?: boolean; + createrole?: boolean; + inherit?: boolean; + replication?: boolean; + connection_limit?: number; + valid_until?: string; + rename_to?: string; +} + +export interface TablePrivilege { + grantee: string; + table_schema: string; + table_name: string; + privilege_type: string; + is_grantable: boolean; +} + +export interface GrantRevokeParams { + action: string; + privileges: string[]; + object_type: string; + object_name: string; + role_name: string; + with_grant_option: boolean; +} + +export interface RoleMembershipParams { + action: string; + role_name: string; + member_name: string; +} + +export type TabType = "query" | "table" | "structure" | "roles"; export interface Tab { id: string; @@ -108,4 +196,5 @@ export interface Tab { schema?: string; table?: string; sql?: string; + roleName?: string; }