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:
@@ -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>,
|
||||
|
||||
509
src-tauri/src/commands/management.rs
Normal file
509
src-tauri/src/commands/management.rs
Normal 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(¶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<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(¶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<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(¶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<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(¶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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
97
src-tauri/src/models/management.rs
Normal file
97
src-tauri/src/models/management.rs
Normal 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,
|
||||
}
|
||||
@@ -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
3
src-tauri/src/utils.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub fn escape_ident(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
300
src/components/management/AdminPanel.tsx
Normal file
300
src/components/management/AdminPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
src/components/management/AlterRoleDialog.tsx
Normal file
183
src/components/management/AlterRoleDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/management/CreateDatabaseDialog.tsx
Normal file
160
src/components/management/CreateDatabaseDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
src/components/management/CreateRoleDialog.tsx
Normal file
203
src/components/management/CreateRoleDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
src/components/management/GrantRevokeDialog.tsx
Normal file
243
src/components/management/GrantRevokeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
src/components/management/RoleManagerView.tsx
Normal file
306
src/components/management/RoleManagerView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
166
src/hooks/use-management.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user