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,243 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useRoles,
useGrantRevoke,
useTablePrivileges,
} from "@/hooks/use-management";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
const PRIVILEGE_OPTIONS: Record<string, string[]> = {
TABLE: ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "ALL"],
SCHEMA: ["USAGE", "CREATE"],
DATABASE: ["CONNECT", "CREATE", "TEMPORARY"],
};
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
objectType: string;
objectName: string;
schema?: string;
table?: string;
}
export function GrantRevokeDialog({
open,
onOpenChange,
connectionId,
objectType,
objectName,
schema,
table,
}: Props) {
const [action, setAction] = useState("GRANT");
const [roleName, setRoleName] = useState("");
const [privileges, setPrivileges] = useState<string[]>([]);
const [withGrantOption, setWithGrantOption] = useState(false);
const { data: roles } = useRoles(open ? connectionId : null);
const { data: existingPrivileges } = useTablePrivileges(
open && objectType === "TABLE" ? connectionId : null,
schema ?? null,
table ?? null
);
const grantRevokeMutation = useGrantRevoke();
useEffect(() => {
if (open) {
setAction("GRANT");
setRoleName("");
setPrivileges([]);
setWithGrantOption(false);
}
}, [open]);
const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE;
const togglePrivilege = (priv: string) => {
setPrivileges((prev) =>
prev.includes(priv)
? prev.filter((p) => p !== priv)
: [...prev, priv]
);
};
const handleSubmit = () => {
if (!roleName) {
toast.error("Please select a role");
return;
}
if (privileges.length === 0) {
toast.error("Please select at least one privilege");
return;
}
grantRevokeMutation.mutate(
{
connectionId,
params: {
action,
privileges,
object_type: objectType,
object_name: objectName,
role_name: roleName,
with_grant_option: withGrantOption,
},
},
{
onSuccess: () => {
toast.success(
`${action === "GRANT" ? "Granted" : "Revoked"} privileges on ${objectName}`
);
onOpenChange(false);
},
onError: (err) => {
toast.error("Operation failed", { description: String(err) });
},
}
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Manage Privileges</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Object</label>
<div className="col-span-3 text-sm">
<Badge variant="outline">{objectType}</Badge>{" "}
<span className="font-medium">{objectName}</span>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Action</label>
<div className="col-span-3 flex gap-2">
<Button
size="sm"
variant={action === "GRANT" ? "default" : "outline"}
onClick={() => setAction("GRANT")}
>
Grant
</Button>
<Button
size="sm"
variant={action === "REVOKE" ? "default" : "outline"}
onClick={() => setAction("REVOKE")}
>
Revoke
</Button>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Role</label>
<Select value={roleName} onValueChange={setRoleName}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select role..." />
</SelectTrigger>
<SelectContent>
{roles?.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Privileges</label>
<div className="col-span-3 flex flex-wrap gap-1.5">
{availablePrivileges.map((priv) => (
<button
key={priv}
type="button"
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
privileges.includes(priv)
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:text-foreground"
}`}
onClick={() => togglePrivilege(priv)}
>
{priv}
</button>
))}
</div>
</div>
{action === "GRANT" && (
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Options</label>
<label className="col-span-3 flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={withGrantOption}
onChange={(e) => setWithGrantOption(e.target.checked)}
className="rounded border-input"
/>
WITH GRANT OPTION
</label>
</div>
)}
{existingPrivileges && existingPrivileges.length > 0 && (
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Current</label>
<div className="col-span-3 max-h-32 overflow-y-auto rounded border p-2">
<div className="space-y-1">
{existingPrivileges.map((p, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="font-medium">{p.grantee}</span>
<Badge variant="secondary" className="text-[10px]">
{p.privilege_type}
</Badge>
{p.is_grantable && (
<Badge variant="outline" className="text-[10px]">
GRANTABLE
</Badge>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={grantRevokeMutation.isPending}>
{grantRevokeMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{action === "GRANT" ? "Grant" : "Revoke"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}