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