From 72c362dfaeceb04ee45152531014d18c80b31396 Mon Sep 17 00:00:00 2001 From: "A.Shakhmatov" Date: Wed, 11 Feb 2026 19:36:19 +0300 Subject: [PATCH] feat: add per-connection read-only mode Connections default to read-only. SQL editor wraps queries in a read-only transaction so PostgreSQL rejects mutations. Data mutation commands (update_row, insert_row, delete_rows) are blocked at the Rust layer. Toolbar toggle with confirmation dialog lets users switch to read-write. Badges shown in workspace, table viewer, and status bar. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/connections.rs | 29 ++++++++ src-tauri/src/commands/data.rs | 12 ++++ src-tauri/src/commands/queries.rs | 24 +++++-- src-tauri/src/error.rs | 3 + src-tauri/src/lib.rs | 2 + src-tauri/src/state.rs | 7 ++ src/components/layout/ReadOnlyToggle.tsx | 68 +++++++++++++++++++ src/components/layout/StatusBar.tsx | 13 +++- src/components/layout/Toolbar.tsx | 5 ++ src/components/table-viewer/TableDataView.tsx | 20 +++++- src/components/workspace/WorkspacePanel.tsx | 12 +++- src/hooks/use-read-only.ts | 20 ++++++ src/lib/tauri.ts | 7 ++ src/stores/app-store.ts | 11 ++- 14 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 src/components/layout/ReadOnlyToggle.tsx create mode 100644 src/hooks/use-read-only.ts diff --git a/src-tauri/src/commands/connections.rs b/src-tauri/src/commands/connections.rs index 2a9292e..9556708 100644 --- a/src-tauri/src/commands/connections.rs +++ b/src-tauri/src/commands/connections.rs @@ -68,6 +68,9 @@ pub async fn delete_connection( pool.close().await; } + let mut ro = state.read_only.write().await; + ro.remove(&id); + Ok(()) } @@ -102,6 +105,9 @@ pub async fn connect(state: State<'_, AppState>, config: ConnectionConfig) -> Tu let mut pools = state.pools.write().await; pools.insert(config.id.clone(), pool); + let mut ro = state.read_only.write().await; + ro.insert(config.id.clone(), true); + Ok(()) } @@ -138,5 +144,28 @@ pub async fn disconnect(state: State<'_, AppState>, id: String) -> TuskResult<() if let Some(pool) = pools.remove(&id) { pool.close().await; } + + let mut ro = state.read_only.write().await; + ro.remove(&id); + Ok(()) } + +#[tauri::command] +pub async fn set_read_only( + state: State<'_, AppState>, + connection_id: String, + read_only: bool, +) -> TuskResult<()> { + let mut map = state.read_only.write().await; + map.insert(connection_id, read_only); + Ok(()) +} + +#[tauri::command] +pub async fn get_read_only( + state: State<'_, AppState>, + connection_id: String, +) -> TuskResult { + Ok(state.is_read_only(&connection_id).await) +} diff --git a/src-tauri/src/commands/data.rs b/src-tauri/src/commands/data.rs index b57ed1d..83057c0 100644 --- a/src-tauri/src/commands/data.rs +++ b/src-tauri/src/commands/data.rs @@ -107,6 +107,10 @@ pub async fn update_row( column: String, value: Value, ) -> 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) @@ -148,6 +152,10 @@ pub async fn insert_row( columns: Vec, values: Vec, ) -> 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) @@ -184,6 +192,10 @@ pub async fn delete_rows( pk_columns: Vec, pk_values_list: Vec>, ) -> 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) diff --git a/src-tauri/src/commands/queries.rs b/src-tauri/src/commands/queries.rs index 77d750e..e481d4b 100644 --- a/src-tauri/src/commands/queries.rs +++ b/src-tauri/src/commands/queries.rs @@ -74,16 +74,32 @@ pub async fn execute_query( connection_id: String, sql: String, ) -> TuskResult { + let read_only = state.is_read_only(&connection_id).await; + let pools = state.pools.read().await; let pool = pools .get(&connection_id) .ok_or(TuskError::NotConnected(connection_id))?; let start = Instant::now(); - let rows = sqlx::query(&sql) - .fetch_all(pool) - .await - .map_err(TuskError::Database)?; + let rows = if read_only { + let mut tx = pool.begin().await.map_err(TuskError::Database)?; + sqlx::query("SET TRANSACTION READ ONLY") + .execute(&mut *tx) + .await + .map_err(TuskError::Database)?; + let result = sqlx::query(&sql) + .fetch_all(&mut *tx) + .await + .map_err(TuskError::Database); + tx.rollback().await.map_err(TuskError::Database)?; + result? + } else { + sqlx::query(&sql) + .fetch_all(pool) + .await + .map_err(TuskError::Database)? + }; let execution_time_ms = start.elapsed().as_millis(); let mut columns = Vec::new(); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 7adc71f..6f718f4 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -17,6 +17,9 @@ pub enum TuskError { #[error("Not connected: {0}")] NotConnected(String), + #[error("Connection is in read-only mode")] + ReadOnly, + #[error("{0}")] Custom(String), } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d79a37a..dd2122e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,8 @@ pub fn run() { commands::connections::connect, commands::connections::switch_database, commands::connections::disconnect, + commands::connections::set_read_only, + commands::connections::get_read_only, // queries commands::queries::execute_query, // schema diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index bb4ef26..fefae63 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -6,6 +6,7 @@ use tokio::sync::RwLock; pub struct AppState { pub pools: RwLock>, pub config_path: RwLock>, + pub read_only: RwLock>, } impl AppState { @@ -13,6 +14,12 @@ impl AppState { Self { pools: RwLock::new(HashMap::new()), config_path: RwLock::new(None), + read_only: RwLock::new(HashMap::new()), } } + + pub async fn is_read_only(&self, id: &str) -> bool { + let map = self.read_only.read().await; + map.get(id).copied().unwrap_or(true) + } } diff --git a/src/components/layout/ReadOnlyToggle.tsx b/src/components/layout/ReadOnlyToggle.tsx new file mode 100644 index 0000000..590cf82 --- /dev/null +++ b/src/components/layout/ReadOnlyToggle.tsx @@ -0,0 +1,68 @@ +import { useAppStore } from "@/stores/app-store"; +import { useToggleReadOnly } from "@/hooks/use-read-only"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Lock, LockOpen } from "lucide-react"; + +export function ReadOnlyToggle() { + const { activeConnectionId, readOnlyMap, connectedIds } = useAppStore(); + const toggleMutation = useToggleReadOnly(); + + if (!activeConnectionId || !connectedIds.has(activeConnectionId)) { + return null; + } + + const isReadOnly = readOnlyMap[activeConnectionId] ?? true; + + const handleToggle = () => { + if (isReadOnly) { + const confirmed = window.confirm( + "Switch to Read-Write mode?\n\nThis will allow executing INSERT, UPDATE, DELETE, DROP, and other mutating queries." + ); + if (!confirmed) return; + } + toggleMutation.mutate({ + connectionId: activeConnectionId, + readOnly: !isReadOnly, + }); + }; + + return ( + + + + + + {isReadOnly + ? "Read-Only mode: mutations are blocked. Click to enable writes." + : "Read-Write mode: all queries are allowed. Click to restrict to read-only."} + + + ); +} diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 343750c..108d6b8 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -8,7 +8,7 @@ interface Props { } export function StatusBar({ rowCount, executionTime }: Props) { - const { activeConnectionId, connectedIds, pgVersion } = useAppStore(); + const { activeConnectionId, connectedIds, readOnlyMap, pgVersion } = useAppStore(); const { data: connections } = useConnections(); const activeConn = connections?.find((c) => c.id === activeConnectionId); @@ -25,6 +25,17 @@ export function StatusBar({ rowCount, executionTime }: Props) { /> {activeConn ? activeConn.name : "No connection"} + {isConnected && activeConnectionId && ( + + {(readOnlyMap[activeConnectionId] ?? true) ? "RO" : "RW"} + + )} {pgVersion && ( {pgVersion.split(",")[0]?.replace("PostgreSQL ", "PG ")} )} diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index a4b1ea3..8d69c0a 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -4,6 +4,7 @@ import { Separator } from "@/components/ui/separator"; import { ConnectionSelector } from "@/components/connections/ConnectionSelector"; import { ConnectionList } from "@/components/connections/ConnectionList"; import { ConnectionDialog } from "@/components/connections/ConnectionDialog"; +import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle"; import { useAppStore } from "@/stores/app-store"; import { Database, Plus } from "lucide-react"; import type { ConnectionConfig, Tab } from "@/types"; @@ -45,6 +46,10 @@ export function Toolbar() { + + + +