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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<bool> {
|
||||
Ok(state.is_read_only(&connection_id).await)
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
values: Vec<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)
|
||||
@@ -184,6 +192,10 @@ pub async fn delete_rows(
|
||||
pk_columns: Vec<String>,
|
||||
pk_values_list: Vec<Vec<Value>>,
|
||||
) -> TuskResult<u64> {
|
||||
if state.is_read_only(&connection_id).await {
|
||||
return Err(TuskError::ReadOnly);
|
||||
}
|
||||
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
|
||||
@@ -74,16 +74,32 @@ pub async fn execute_query(
|
||||
connection_id: String,
|
||||
sql: String,
|
||||
) -> TuskResult<QueryResult> {
|
||||
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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ use tokio::sync::RwLock;
|
||||
pub struct AppState {
|
||||
pub pools: RwLock<HashMap<String, PgPool>>,
|
||||
pub config_path: RwLock<Option<PathBuf>>,
|
||||
pub read_only: RwLock<HashMap<String, bool>>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
68
src/components/layout/ReadOnlyToggle.tsx
Normal file
68
src/components/layout/ReadOnlyToggle.tsx
Normal file
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 gap-1.5 text-xs font-medium ${
|
||||
isReadOnly
|
||||
? "text-yellow-600 dark:text-yellow-500"
|
||||
: "text-green-600 dark:text-green-500"
|
||||
}`}
|
||||
onClick={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
{isReadOnly ? (
|
||||
<>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
Read-Only
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LockOpen className="h-3.5 w-3.5" />
|
||||
Read-Write
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isReadOnly
|
||||
? "Read-Only mode: mutations are blocked. Click to enable writes."
|
||||
: "Read-Write mode: all queries are allowed. Click to restrict to read-only."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
</span>
|
||||
{isConnected && activeConnectionId && (
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
(readOnlyMap[activeConnectionId] ?? true)
|
||||
? "text-yellow-600 dark:text-yellow-500"
|
||||
: "text-green-600 dark:text-green-500"
|
||||
}`}
|
||||
>
|
||||
{(readOnlyMap[activeConnectionId] ?? true) ? "RO" : "RW"}
|
||||
</span>
|
||||
)}
|
||||
{pgVersion && (
|
||||
<span className="hidden sm:inline">{pgVersion.split(",")[0]?.replace("PostgreSQL ", "PG ")}</span>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
|
||||
<ReadOnlyToggle />
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { updateRow as updateRowApi } from "@/lib/tauri";
|
||||
import { getTableColumns } from "@/lib/tauri";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { toast } from "sonner";
|
||||
import { Save, RotateCcw, Filter, Loader2 } from "lucide-react";
|
||||
import { Save, RotateCcw, Filter, Loader2, Lock } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -17,6 +18,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export function TableDataView({ connectionId, schema, table }: Props) {
|
||||
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
|
||||
const isReadOnly = readOnlyMap[connectionId] ?? true;
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [sortColumn, _setSortColumn] = useState<string | undefined>();
|
||||
@@ -57,6 +61,12 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
||||
|
||||
const handleCellDoubleClick = useCallback(
|
||||
(rowIndex: number, colIndex: number, value: unknown) => {
|
||||
if (isReadOnly) {
|
||||
toast.warning("Read-only mode is active", {
|
||||
description: "Switch to Read-Write mode to edit data.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const key = `${rowIndex}:${colIndex}`;
|
||||
const currentValue = pendingChanges.get(key)?.value ?? value;
|
||||
const newVal = prompt("Edit value (leave empty and click NULL for null):",
|
||||
@@ -69,7 +79,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
||||
});
|
||||
}
|
||||
},
|
||||
[pendingChanges]
|
||||
[pendingChanges, isReadOnly]
|
||||
);
|
||||
|
||||
const handleCommit = async () => {
|
||||
@@ -121,6 +131,12 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-2 py-1">
|
||||
{isReadOnly && (
|
||||
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
|
||||
<Lock className="h-3 w-3" />
|
||||
Read-Only
|
||||
</span>
|
||||
)}
|
||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="WHERE clause (e.g. id > 10)"
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
import { SqlEditor } from "@/components/editor/SqlEditor";
|
||||
import { ResultsPanel } from "@/components/results/ResultsPanel";
|
||||
import { useQueryExecution } from "@/hooks/use-query-execution";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Loader2 } from "lucide-react";
|
||||
import { Play, Loader2, Lock } from "lucide-react";
|
||||
import type { QueryResult } from "@/types";
|
||||
|
||||
interface Props {
|
||||
@@ -24,6 +25,9 @@ export function WorkspacePanel({
|
||||
onSqlChange,
|
||||
onResult,
|
||||
}: Props) {
|
||||
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
|
||||
const isReadOnly = readOnlyMap[connectionId] ?? true;
|
||||
|
||||
const [sqlValue, setSqlValue] = useState(initialSql);
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -79,6 +83,12 @@ export function WorkspacePanel({
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Ctrl+Enter to execute
|
||||
</span>
|
||||
{isReadOnly && (
|
||||
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
|
||||
<Lock className="h-3 w-3" />
|
||||
Read-Only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SqlEditor
|
||||
|
||||
20
src/hooks/use-read-only.ts
Normal file
20
src/hooks/use-read-only.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { setReadOnly as setReadOnlyApi } from "@/lib/tauri";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
|
||||
export function useToggleReadOnly() {
|
||||
const setReadOnly = useAppStore((s) => s.setReadOnly);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
connectionId,
|
||||
readOnly,
|
||||
}: {
|
||||
connectionId: string;
|
||||
readOnly: boolean;
|
||||
}) => setReadOnlyApi(connectionId, readOnly),
|
||||
onSuccess: (_data, { connectionId, readOnly }) => {
|
||||
setReadOnly(connectionId, readOnly);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,13 @@ export const disconnectDb = (id: string) =>
|
||||
export const switchDatabase = (config: ConnectionConfig, database: string) =>
|
||||
invoke<void>("switch_database", { config, database });
|
||||
|
||||
// Read-Only
|
||||
export const setReadOnly = (connectionId: string, readOnly: boolean) =>
|
||||
invoke<void>("set_read_only", { connectionId, readOnly });
|
||||
|
||||
export const getReadOnly = (connectionId: string) =>
|
||||
invoke<boolean>("get_read_only", { connectionId });
|
||||
|
||||
// Queries
|
||||
export const executeQuery = (connectionId: string, sql: string) =>
|
||||
invoke<QueryResult>("execute_query", { connectionId, sql });
|
||||
|
||||
@@ -6,6 +6,7 @@ interface AppState {
|
||||
activeConnectionId: string | null;
|
||||
currentDatabase: string | null;
|
||||
connectedIds: Set<string>;
|
||||
readOnlyMap: Record<string, boolean>;
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
sidebarWidth: number;
|
||||
@@ -16,6 +17,7 @@ interface AppState {
|
||||
setCurrentDatabase: (db: string | null) => void;
|
||||
addConnectedId: (id: string) => void;
|
||||
removeConnectedId: (id: string) => void;
|
||||
setReadOnly: (connectionId: string, readOnly: boolean) => void;
|
||||
setPgVersion: (version: string | null) => void;
|
||||
|
||||
addTab: (tab: Tab) => void;
|
||||
@@ -30,6 +32,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
activeConnectionId: null,
|
||||
currentDatabase: null,
|
||||
connectedIds: new Set(),
|
||||
readOnlyMap: {},
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
sidebarWidth: 260,
|
||||
@@ -41,13 +44,19 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
addConnectedId: (id) =>
|
||||
set((state) => ({
|
||||
connectedIds: new Set([...state.connectedIds, id]),
|
||||
readOnlyMap: { ...state.readOnlyMap, [id]: true },
|
||||
})),
|
||||
removeConnectedId: (id) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.connectedIds);
|
||||
next.delete(id);
|
||||
return { connectedIds: next };
|
||||
const { [id]: _, ...restRo } = state.readOnlyMap;
|
||||
return { connectedIds: next, readOnlyMap: restRo };
|
||||
}),
|
||||
setReadOnly: (connectionId, readOnly) =>
|
||||
set((state) => ({
|
||||
readOnlyMap: { ...state.readOnlyMap, [connectionId]: readOnly },
|
||||
})),
|
||||
setPgVersion: (version) => set({ pgVersion: version }),
|
||||
|
||||
addTab: (tab) =>
|
||||
|
||||
Reference in New Issue
Block a user