feat: add database, role & privilege management

Add Admin sidebar tab with database/role management panels, role manager
workspace tab, and privilege dialogs. Backend provides 10 new Tauri
commands for CRUD on databases, roles, and privileges with read-only
mode enforcement. Context menus on schema tree nodes allow dropping
databases and viewing/granting table privileges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 22:11:02 +03:00
parent d3b98f9261
commit cebe2a307a
20 changed files with 2419 additions and 28 deletions

View File

@@ -0,0 +1,300 @@
import { useState } from "react";
import {
useDatabaseInfo,
useRoles,
useDropDatabase,
useDropRole,
} from "@/hooks/use-management";
import { useAppStore } from "@/stores/app-store";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CreateDatabaseDialog } from "./CreateDatabaseDialog";
import { CreateRoleDialog } from "./CreateRoleDialog";
import { AlterRoleDialog } from "./AlterRoleDialog";
import { toast } from "sonner";
import {
Plus,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
HardDrive,
Users,
Loader2,
} from "lucide-react";
import type { Tab, RoleInfo } from "@/types";
export function AdminPanel() {
const { activeConnectionId, currentDatabase, readOnlyMap, addTab } = useAppStore();
if (!activeConnectionId) {
return (
<div className="p-4 text-sm text-muted-foreground">
Connect to a database to manage it.
</div>
);
}
const isReadOnly = readOnlyMap[activeConnectionId] ?? true;
return (
<div className="flex h-full flex-col overflow-y-auto">
<DatabasesSection
connectionId={activeConnectionId}
currentDatabase={currentDatabase}
isReadOnly={isReadOnly}
/>
<RolesSection
connectionId={activeConnectionId}
isReadOnly={isReadOnly}
onOpenRoleManager={() => {
const tab: Tab = {
id: crypto.randomUUID(),
type: "roles",
title: "Roles & Users",
connectionId: activeConnectionId,
};
addTab(tab);
}}
/>
</div>
);
}
function DatabasesSection({
connectionId,
currentDatabase,
isReadOnly,
}: {
connectionId: string;
currentDatabase: string | null;
isReadOnly: boolean;
}) {
const [expanded, setExpanded] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const { data: databases, isLoading } = useDatabaseInfo(connectionId);
const dropMutation = useDropDatabase();
const handleDrop = (name: string) => {
if (name === currentDatabase) {
toast.error("Cannot drop the active database");
return;
}
if (!confirm(`Drop database "${name}"? This will terminate all connections and cannot be undone.`)) {
return;
}
dropMutation.mutate(
{ connectionId, name },
{
onSuccess: () => toast.success(`Database "${name}" dropped`),
onError: (err) => toast.error("Failed to drop database", { description: String(err) }),
}
);
};
return (
<div className="border-b">
<div
className="flex items-center gap-1 px-3 py-2 cursor-pointer select-none hover:bg-accent/50"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Databases</span>
{!isReadOnly && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
setCreateOpen(true);
}}
title="Create Database"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{expanded && (
<div className="pb-1">
{isLoading && (
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
</div>
)}
{databases?.map((db) => (
<div
key={db.name}
className={`group flex items-center gap-2 px-6 py-1 text-xs hover:bg-accent/50 ${
db.name === currentDatabase ? "text-primary" : ""
}`}
>
<span className="truncate flex-1 font-medium">{db.name}</span>
<span className="text-[10px] text-muted-foreground shrink-0">{db.size}</span>
{db.name === currentDatabase && (
<Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">
active
</Badge>
)}
{!isReadOnly && db.name !== currentDatabase && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive shrink-0"
onClick={() => handleDrop(db.name)}
title="Drop"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
<CreateDatabaseDialog
open={createOpen}
onOpenChange={setCreateOpen}
connectionId={connectionId}
/>
</div>
);
}
function RolesSection({
connectionId,
isReadOnly,
onOpenRoleManager,
}: {
connectionId: string;
isReadOnly: boolean;
onOpenRoleManager: () => void;
}) {
const [expanded, setExpanded] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [alterRole, setAlterRole] = useState<RoleInfo | null>(null);
const { data: roles, isLoading } = useRoles(connectionId);
const dropMutation = useDropRole();
const handleDrop = (name: string) => {
if (!confirm(`Drop role "${name}"? This cannot be undone.`)) return;
dropMutation.mutate(
{ connectionId, name },
{
onSuccess: () => toast.success(`Role "${name}" dropped`),
onError: (err) => toast.error("Failed to drop role", { description: String(err) }),
}
);
};
return (
<div className="border-b">
<div
className="flex items-center gap-1 px-3 py-2 cursor-pointer select-none hover:bg-accent/50"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Roles</span>
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-[10px]"
onClick={onOpenRoleManager}
title="Open Role Manager"
>
Manager
</Button>
{!isReadOnly && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => setCreateOpen(true)}
title="Create Role"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
</div>
{expanded && (
<div className="pb-1">
{isLoading && (
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
</div>
)}
{roles?.map((role) => (
<div
key={role.name}
className="group flex items-center gap-1.5 px-6 py-1 text-xs hover:bg-accent/50"
>
<span className="truncate flex-1 font-medium">{role.name}</span>
<div className="flex gap-0.5 shrink-0">
{role.can_login && (
<Badge variant="secondary" className="text-[9px] px-1 py-0">
LOGIN
</Badge>
)}
{role.is_superuser && (
<Badge variant="default" className="text-[9px] px-1 py-0 bg-amber-600 hover:bg-amber-600">
SUPER
</Badge>
)}
</div>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
{!isReadOnly && (
<>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => setAlterRole(role)}
title="Edit"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
onClick={() => handleDrop(role.name)}
title="Drop"
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
))}
</div>
)}
<CreateRoleDialog
open={createOpen}
onOpenChange={setCreateOpen}
connectionId={connectionId}
/>
<AlterRoleDialog
open={!!alterRole}
onOpenChange={(open) => !open && setAlterRole(null)}
connectionId={connectionId}
role={alterRole}
/>
</div>
);
}