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