feat: add database, role & privilege management

Add Admin sidebar tab with database/role management panels, role manager
workspace tab, and privilege dialogs. Backend provides 10 new Tauri
commands for CRUD on databases, roles, and privileges with read-only
mode enforcement. Context menus on schema tree nodes allow dropping
databases and viewing/granting table privileges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 22:11:02 +03:00
parent d3b98f9261
commit cebe2a307a
20 changed files with 2419 additions and 28 deletions

View File

@@ -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>,

View File

@@ -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<Vec<DatabaseInfo>> {
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(&params.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<Vec<RoleInfo>> {
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(&params.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<String> = 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(&params.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(&params.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<Vec<TablePrivilege>> {
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(&params.object_name);
let role_ref = escape_ident(&params.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(&params.role_name);
let member_ref = escape_ident(&params.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(())
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateDatabaseParams {
pub name: String,
pub owner: Option<String>,
pub template: Option<String>,
pub encoding: Option<String>,
pub tablespace: Option<String>,
pub connection_limit: Option<i32>,
}
#[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<String>,
pub member_of: Vec<String>,
pub members: Vec<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateRoleParams {
pub name: String,
pub password: Option<String>,
pub login: bool,
pub superuser: bool,
pub createdb: bool,
pub createrole: bool,
pub inherit: bool,
pub replication: bool,
pub connection_limit: Option<i32>,
pub valid_until: Option<String>,
pub in_roles: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct AlterRoleParams {
pub name: String,
pub password: Option<String>,
pub login: Option<bool>,
pub superuser: Option<bool>,
pub createdb: Option<bool>,
pub createrole: Option<bool>,
pub inherit: Option<bool>,
pub replication: Option<bool>,
pub connection_limit: Option<i32>,
pub valid_until: Option<String>,
pub rename_to: Option<String>,
}
#[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<String>,
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,
}

View File

@@ -1,4 +1,5 @@
pub mod connection;
pub mod history;
pub mod management;
pub mod query_result;
pub mod schema;

3
src-tauri/src/utils.rs Normal file
View File

@@ -0,0 +1,3 @@
pub fn escape_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}

View File

@@ -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<SidebarView>("schema");
@@ -33,6 +34,16 @@ export function Sidebar() {
>
History
</button>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "admin"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setView("admin")}
>
Admin
</button>
</div>
{view === "schema" ? (
@@ -52,8 +63,10 @@ export function Sidebar() {
<SchemaTree />
</div>
</>
) : (
) : view === "history" ? (
<HistoryPanel />
) : (
<AdminPanel />
)}
</div>
);

View File

@@ -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<string, React.ReactNode> = {
query: <Code className="h-3 w-3" />,
table: <Table2 className="h-3 w-3" />,
structure: <Columns className="h-3 w-3" />,
roles: <Users className="h-3 w-3" />,
};
return (

View File

@@ -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 (
<div className="p-4 text-sm text-muted-foreground">
Connect to a database to manage it.
</div>
);
}
const isReadOnly = readOnlyMap[activeConnectionId] ?? true;
return (
<div className="flex h-full flex-col overflow-y-auto">
<DatabasesSection
connectionId={activeConnectionId}
currentDatabase={currentDatabase}
isReadOnly={isReadOnly}
/>
<RolesSection
connectionId={activeConnectionId}
isReadOnly={isReadOnly}
onOpenRoleManager={() => {
const tab: Tab = {
id: crypto.randomUUID(),
type: "roles",
title: "Roles & Users",
connectionId: activeConnectionId,
};
addTab(tab);
}}
/>
</div>
);
}
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 (
<div className="border-b">
<div
className="flex items-center gap-1 px-3 py-2 cursor-pointer select-none hover:bg-accent/50"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Databases</span>
{!isReadOnly && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
setCreateOpen(true);
}}
title="Create Database"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{expanded && (
<div className="pb-1">
{isLoading && (
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
</div>
)}
{databases?.map((db) => (
<div
key={db.name}
className={`group flex items-center gap-2 px-6 py-1 text-xs hover:bg-accent/50 ${
db.name === currentDatabase ? "text-primary" : ""
}`}
>
<span className="truncate flex-1 font-medium">{db.name}</span>
<span className="text-[10px] text-muted-foreground shrink-0">{db.size}</span>
{db.name === currentDatabase && (
<Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">
active
</Badge>
)}
{!isReadOnly && db.name !== currentDatabase && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive shrink-0"
onClick={() => handleDrop(db.name)}
title="Drop"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
<CreateDatabaseDialog
open={createOpen}
onOpenChange={setCreateOpen}
connectionId={connectionId}
/>
</div>
);
}
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<RoleInfo | null>(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 (
<div className="border-b">
<div
className="flex items-center gap-1 px-3 py-2 cursor-pointer select-none hover:bg-accent/50"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Roles</span>
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-[10px]"
onClick={onOpenRoleManager}
title="Open Role Manager"
>
Manager
</Button>
{!isReadOnly && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => setCreateOpen(true)}
title="Create Role"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
</div>
{expanded && (
<div className="pb-1">
{isLoading && (
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
</div>
)}
{roles?.map((role) => (
<div
key={role.name}
className="group flex items-center gap-1.5 px-6 py-1 text-xs hover:bg-accent/50"
>
<span className="truncate flex-1 font-medium">{role.name}</span>
<div className="flex gap-0.5 shrink-0">
{role.can_login && (
<Badge variant="secondary" className="text-[9px] px-1 py-0">
LOGIN
</Badge>
)}
{role.is_superuser && (
<Badge variant="default" className="text-[9px] px-1 py-0 bg-amber-600 hover:bg-amber-600">
SUPER
</Badge>
)}
</div>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
{!isReadOnly && (
<>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => setAlterRole(role)}
title="Edit"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
onClick={() => handleDrop(role.name)}
title="Drop"
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
))}
</div>
)}
<CreateRoleDialog
open={createOpen}
onOpenChange={setCreateOpen}
connectionId={connectionId}
/>
<AlterRoleDialog
open={!!alterRole}
onOpenChange={(open) => !open && setAlterRole(null)}
connectionId={connectionId}
role={alterRole}
/>
</div>
);
}

View File

@@ -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<string, unknown> = { 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Alter Role: {role.name}</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Rename To</label>
<Input
className="col-span-3"
value={renameTo}
onChange={(e) => setRenameTo(e.target.value)}
placeholder={role.name}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Password</label>
<Input
className="col-span-3"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Leave empty to keep unchanged"
/>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Privileges</label>
<div className="col-span-3 grid grid-cols-2 gap-2">
{([
["LOGIN", login, setLogin],
["SUPERUSER", superuser, setSuperuser],
["CREATEDB", createdb, setCreatedb],
["CREATEROLE", createrole, setCreaterole],
["INHERIT", inherit, setInherit],
["REPLICATION", replication, setReplication],
] as const).map(([label, value, setter]) => (
<label key={label} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={value}
onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)}
className="rounded border-input"
/>
{label}
</label>
))}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Conn Limit</label>
<Input
className="col-span-3"
type="number"
value={connectionLimit}
onChange={(e) => setConnectionLimit(parseInt(e.target.value) || -1)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Valid Until</label>
<Input
className="col-span-3"
type="datetime-local"
value={validUntil}
onChange={(e) => setValidUntil(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleAlter} disabled={alterMutation.isPending}>
{alterMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create Database</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Name</label>
<Input
className="col-span-3"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my_database"
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Owner</label>
<Select value={owner} onValueChange={setOwner}>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default</SelectItem>
{roles?.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Template</label>
<Select value={template} onValueChange={setTemplate}>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default</SelectItem>
<SelectItem value="template0">template0</SelectItem>
<SelectItem value="template1">template1</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Encoding</label>
<Select value={encoding} onValueChange={setEncoding}>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="UTF8">UTF8</SelectItem>
<SelectItem value="LATIN1">LATIN1</SelectItem>
<SelectItem value="SQL_ASCII">SQL_ASCII</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Conn Limit</label>
<Input
className="col-span-3"
type="number"
value={connectionLimit}
onChange={(e) => setConnectionLimit(parseInt(e.target.value) || -1)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<string[]>([]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create Role</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Name</label>
<Input
className="col-span-3"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my_role"
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Password</label>
<Input
className="col-span-3"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Privileges</label>
<div className="col-span-3 grid grid-cols-2 gap-2">
{([
["LOGIN", login, setLogin],
["SUPERUSER", superuser, setSuperuser],
["CREATEDB", createdb, setCreatedb],
["CREATEROLE", createrole, setCreaterole],
["INHERIT", inherit, setInherit],
["REPLICATION", replication, setReplication],
] as const).map(([label, value, setter]) => (
<label key={label} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={value}
onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)}
className="rounded border-input"
/>
{label}
</label>
))}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Conn Limit</label>
<Input
className="col-span-3"
type="number"
value={connectionLimit}
onChange={(e) => setConnectionLimit(parseInt(e.target.value) || -1)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Valid Until</label>
<Input
className="col-span-3"
type="datetime-local"
value={validUntil}
onChange={(e) => setValidUntil(e.target.value)}
/>
</div>
{roles && roles.length > 0 && (
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Member Of</label>
<div className="col-span-3 flex flex-wrap gap-1.5">
{roles.map((r) => (
<button
key={r.name}
type="button"
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
inRoles.includes(r.name)
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:text-foreground"
}`}
onClick={() => toggleInRole(r.name)}
>
{r.name}
</button>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<string, string[]> = {
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<string[]>([]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Manage Privileges</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Object</label>
<div className="col-span-3 text-sm">
<Badge variant="outline">{objectType}</Badge>{" "}
<span className="font-medium">{objectName}</span>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Action</label>
<div className="col-span-3 flex gap-2">
<Button
size="sm"
variant={action === "GRANT" ? "default" : "outline"}
onClick={() => setAction("GRANT")}
>
Grant
</Button>
<Button
size="sm"
variant={action === "REVOKE" ? "default" : "outline"}
onClick={() => setAction("REVOKE")}
>
Revoke
</Button>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Role</label>
<Select value={roleName} onValueChange={setRoleName}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select role..." />
</SelectTrigger>
<SelectContent>
{roles?.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Privileges</label>
<div className="col-span-3 flex flex-wrap gap-1.5">
{availablePrivileges.map((priv) => (
<button
key={priv}
type="button"
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
privileges.includes(priv)
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:text-foreground"
}`}
onClick={() => togglePrivilege(priv)}
>
{priv}
</button>
))}
</div>
</div>
{action === "GRANT" && (
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Options</label>
<label className="col-span-3 flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={withGrantOption}
onChange={(e) => setWithGrantOption(e.target.checked)}
className="rounded border-input"
/>
WITH GRANT OPTION
</label>
</div>
)}
{existingPrivileges && existingPrivileges.length > 0 && (
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Current</label>
<div className="col-span-3 max-h-32 overflow-y-auto rounded border p-2">
<div className="space-y-1">
{existingPrivileges.map((p, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="font-medium">{p.grantee}</span>
<Badge variant="secondary" className="text-[10px]">
{p.privilege_type}
</Badge>
{p.is_grantable && (
<Badge variant="outline" className="text-[10px]">
GRANTABLE
</Badge>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={grantRevokeMutation.isPending}>
{grantRevokeMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{action === "GRANT" ? "Grant" : "Revoke"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<RoleInfo | null>(null);
const [selectedRole, setSelectedRole] = useState<string | null>(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 (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2">
<h2 className="text-sm font-semibold">Roles & Users</h2>
<Button size="sm" onClick={() => setCreateOpen(true)} disabled={isReadOnly}>
<Plus className="mr-1 h-3.5 w-3.5" />
Create Role
</Button>
</div>
<div className="flex flex-1 min-h-0">
<div className="flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-card">
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Login</th>
<th className="px-4 py-2 font-medium">Superuser</th>
<th className="px-4 py-2 font-medium">CreateDB</th>
<th className="px-4 py-2 font-medium">CreateRole</th>
<th className="px-4 py-2 font-medium">Conn Limit</th>
<th className="px-4 py-2 font-medium">Member Of</th>
<th className="px-4 py-2 font-medium w-24">Actions</th>
</tr>
</thead>
<tbody>
{roles?.map((role) => (
<tr
key={role.name}
className={`border-b hover:bg-accent/50 cursor-pointer ${
selectedRole === role.name ? "bg-accent/30" : ""
}`}
onClick={() => setSelectedRole(role.name)}
>
<td className="px-4 py-2 font-medium">{role.name}</td>
<td className="px-4 py-2">
<BoolBadge value={role.can_login} />
</td>
<td className="px-4 py-2">
<BoolBadge value={role.is_superuser} />
</td>
<td className="px-4 py-2">
<BoolBadge value={role.can_create_db} />
</td>
<td className="px-4 py-2">
<BoolBadge value={role.can_create_role} />
</td>
<td className="px-4 py-2 text-muted-foreground">
{role.connection_limit === -1 ? "unlimited" : role.connection_limit}
</td>
<td className="px-4 py-2">
<div className="flex flex-wrap gap-1">
{role.member_of.map((r) => (
<Badge key={r} variant="secondary" className="text-[10px]">
{r}
</Badge>
))}
</div>
</td>
<td className="px-4 py-2">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
setAlterRole(role);
}}
disabled={isReadOnly}
title="Edit"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDrop(role.name);
}}
disabled={isReadOnly}
title="Drop"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{selected && (
<div className="w-64 shrink-0 border-l overflow-auto">
<div className="p-3">
<h3 className="text-sm font-semibold mb-3">{selected.name}</h3>
<div className="space-y-3">
<div>
<p className="text-xs text-muted-foreground mb-1">Member Of</p>
<div className="flex flex-wrap gap-1">
{selected.member_of.length === 0 && (
<span className="text-xs text-muted-foreground">None</span>
)}
{selected.member_of.map((r) => (
<div key={r} className="flex items-center gap-0.5">
<Badge variant="secondary" className="text-[10px]">
{r}
</Badge>
{!isReadOnly && (
<button
className="text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveMember(r, selected.name)}
title={`Remove from ${r}`}
>
<UserMinus className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Members</p>
<div className="flex flex-wrap gap-1">
{selected.members.length === 0 && (
<span className="text-xs text-muted-foreground">None</span>
)}
{selected.members.map((m) => (
<div key={m} className="flex items-center gap-0.5">
<Badge variant="secondary" className="text-[10px]">
{m}
</Badge>
{!isReadOnly && (
<button
className="text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveMember(selected.name, m)}
title={`Remove ${m}`}
>
<UserMinus className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
{!isReadOnly && (
<div>
<p className="text-xs text-muted-foreground mb-1">Add Member</p>
<div className="flex gap-1">
<Select value={memberToAdd} onValueChange={setMemberToAdd}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{roles
?.filter(
(r) =>
r.name !== selected.name &&
!selected.members.includes(r.name)
)
.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="h-7"
disabled={!memberToAdd || membershipMutation.isPending}
onClick={() => handleAddMember(selected.name, memberToAdd)}
>
<UserPlus className="h-3 w-3" />
</Button>
</div>
</div>
)}
{selected.description && (
<div>
<p className="text-xs text-muted-foreground mb-1">Description</p>
<p className="text-xs">{selected.description}</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
<CreateRoleDialog
open={createOpen}
onOpenChange={setCreateOpen}
connectionId={connectionId}
/>
<AlterRoleDialog
open={!!alterRole}
onOpenChange={(open) => !open && setAlterRole(null)}
connectionId={connectionId}
role={alterRole}
/>
</div>
);
}
function BoolBadge({ value }: { value: boolean }) {
return (
<Badge
variant={value ? "default" : "secondary"}
className={`text-[10px] ${value ? "bg-green-600 hover:bg-green-600" : "text-muted-foreground"}`}
>
{value ? "Yes" : "No"}
</Badge>
);
}

View File

@@ -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 (
<div>
<div
className={`flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium ${
isActive ? "text-primary" : "text-muted-foreground"
}`}
onClick={handleClick}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<HardDrive
className={`h-3.5 w-3.5 shrink-0 ${isActive ? "text-primary" : "text-muted-foreground"}`}
/>
<span className="truncate">{name}</span>
{isActive && (
<span className="ml-auto text-[10px] text-primary">active</span>
)}
</div>
<ContextMenu>
<ContextMenuTrigger>
<div
className={`flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium ${
isActive ? "text-primary" : "text-muted-foreground"
}`}
onClick={handleClick}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<HardDrive
className={`h-3.5 w-3.5 shrink-0 ${isActive ? "text-primary" : "text-muted-foreground"}`}
/>
<span className="truncate">{name}</span>
{isActive && (
<span className="ml-auto text-[10px] text-primary">active</span>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => toast.info(`Database: ${name}`, { description: isActive ? "Currently active" : "Not active" })}
>
Properties
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={isActive || isReadOnly}
onClick={handleDropDb}
className="text-destructive focus:text-destructive"
>
Drop Database
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{expanded && isActive && (
<div className="ml-4">
<SchemasForCurrentDb
@@ -302,6 +344,7 @@ function CategoryNode({
onViewStructure: (table: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [privilegesTarget, setPrivilegesTarget] = useState<string | null>(null);
const tablesQuery = useTables(
expanded && category === "tables" ? connectionId : null,
@@ -375,6 +418,12 @@ function CategoryNode({
>
View Structure
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => setPrivilegesTarget(item.name)}
>
View Privileges
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
@@ -393,6 +442,17 @@ function CategoryNode({
})}
</div>
)}
{privilegesTarget && (
<GrantRevokeDialog
open={!!privilegesTarget}
onOpenChange={(open) => !open && setPrivilegesTarget(null)}
connectionId={connectionId}
objectType="TABLE"
objectName={`${schema}.${privilegesTarget}`}
schema={schema}
table={privilegesTarget}
/>
)}
</div>
);
}

View File

@@ -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 (
<RoleManagerView
key={activeTab.id}
connectionId={activeTab.connectionId}
/>
);
default:
return null;
}

166
src/hooks/use-management.ts Normal file
View File

@@ -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"] });
},
});
}

View File

@@ -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<void>("export_json", { path, columns, rows });
// Management
export const getDatabaseInfo = (connectionId: string) =>
invoke<DatabaseInfo[]>("get_database_info", { connectionId });
export const createDatabase = (connectionId: string, params: CreateDatabaseParams) =>
invoke<void>("create_database", { connectionId, params });
export const dropDatabase = (connectionId: string, name: string) =>
invoke<void>("drop_database", { connectionId, name });
export const listRoles = (connectionId: string) =>
invoke<RoleInfo[]>("list_roles", { connectionId });
export const createRole = (connectionId: string, params: CreateRoleParams) =>
invoke<void>("create_role", { connectionId, params });
export const alterRole = (connectionId: string, params: AlterRoleParams) =>
invoke<void>("alter_role", { connectionId, params });
export const dropRole = (connectionId: string, name: string) =>
invoke<void>("drop_role", { connectionId, name });
export const getTablePrivileges = (connectionId: string, schema: string, table: string) =>
invoke<TablePrivilege[]>("get_table_privileges", { connectionId, schema, table });
export const grantRevoke = (connectionId: string, params: GrantRevokeParams) =>
invoke<void>("grant_revoke", { connectionId, params });
export const manageRoleMembership = (connectionId: string, params: RoleMembershipParams) =>
invoke<void>("manage_role_membership", { connectionId, params });

View File

@@ -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;
}