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"