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:
300
src/components/management/AdminPanel.tsx
Normal file
300
src/components/management/AdminPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user