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:
2026-02-11 19:36:19 +03:00
parent 9b9d2cee94
commit 72c362dfae
14 changed files with 224 additions and 9 deletions

View 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>
);
}

View File

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

View File

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

View File

@@ -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)"

View File

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