acl ui - 1/2
This commit is contained in:
parent
c54549d238
commit
e47430369c
@ -1,211 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { fetchKnownAclResources } from "@/modules/acl/client-acl";
|
||||
import { T } from "@/i18n";
|
||||
import { Shield, Loader2, Settings } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { UserPicker } from "./UserPicker";
|
||||
import { putAclGroup, evaluateAcl } from "@/modules/user/client-acl";
|
||||
import { T, translate } from "@/i18n";
|
||||
import { toast } from "sonner";
|
||||
import { Shield, ShieldCheck, Users, Trash2, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AclEditor as StorageAclEditor } from "@/modules/storage/AclEditor";
|
||||
|
||||
export function AclPlayground() {
|
||||
const [activeTab, setActiveTab] = useState("groups");
|
||||
const { data: resources, isLoading, error } = useQuery({
|
||||
queryKey: ["acl-resources"],
|
||||
queryFn: fetchKnownAclResources
|
||||
});
|
||||
|
||||
// Group Management State
|
||||
const [groupType, setGroupType] = useState("vfs");
|
||||
const [groupMount, setGroupMount] = useState("docs");
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [groupMembers, setGroupMembers] = useState<string[]>([]);
|
||||
const [memberInput, setMemberInput] = useState("");
|
||||
const [savingGroup, setSavingGroup] = useState(false);
|
||||
|
||||
// Evaluation State
|
||||
const [evalType, setEvalType] = useState("vfs");
|
||||
const [evalMount, setEvalMount] = useState("docs");
|
||||
const [evalPath, setEvalPath] = useState("/");
|
||||
const [evalUserId, setEvalUserId] = useState("");
|
||||
const [evaluating, setEvaluating] = useState(false);
|
||||
const [evalResult, setEvalResult] = useState<string[] | null>(null);
|
||||
|
||||
// Group Handlers
|
||||
const handleAddMember = (id: string) => {
|
||||
if (!id) return;
|
||||
if (!groupMembers.includes(id)) {
|
||||
setGroupMembers([...groupMembers, id]);
|
||||
}
|
||||
setMemberInput("");
|
||||
};
|
||||
|
||||
const handleRemoveMember = (id: string) => {
|
||||
setGroupMembers(groupMembers.filter((m) => m !== id));
|
||||
};
|
||||
|
||||
const handleSaveGroup = async () => {
|
||||
if (!groupName.trim()) {
|
||||
toast.error(translate("Group name is required"));
|
||||
return;
|
||||
}
|
||||
setSavingGroup(true);
|
||||
try {
|
||||
await putAclGroup(groupType, groupMount, {
|
||||
name: groupName.trim(),
|
||||
members: groupMembers,
|
||||
});
|
||||
toast.success(translate("Group saved successfully"));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || translate("Failed to save group"));
|
||||
} finally {
|
||||
setSavingGroup(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Evaluation Handlers
|
||||
const handleEvaluate = async () => {
|
||||
if (!evalUserId) {
|
||||
toast.error(translate("User is required for evaluation"));
|
||||
return;
|
||||
}
|
||||
setEvaluating(true);
|
||||
setEvalResult(null);
|
||||
try {
|
||||
const res = await evaluateAcl(evalType, evalMount, evalUserId, evalPath || "/");
|
||||
setEvalResult(res.permissions || []);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || translate("Failed to evaluate permissions"));
|
||||
} finally {
|
||||
setEvaluating(false);
|
||||
}
|
||||
};
|
||||
const [editingRes, setEditingRes] = useState<{ type: string, id: string } | null>(null);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-4xl shadow-sm">
|
||||
<CardHeader className="bg-muted/30 border-b">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<T>ACL Playground</T>
|
||||
<T>Global ACL Registry</T>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<T>Test and configure advanced Access Control settings and groups.</T>
|
||||
<T>List of all resources that have explicit access control configured.</T>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="mb-6 grid w-[400px] grid-cols-2">
|
||||
<TabsTrigger value="groups" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
<T>Groups</T>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="evaluate" className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<T>Evaluation</T>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* GROUPS TAB */}
|
||||
<TabsContent value="groups" className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label><T>Resource Type</T></Label>
|
||||
<Input value={groupType} onChange={(e) => setGroupType(e.target.value)} placeholder="vfs, category, etc." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label><T>Resource ID / Mount</T></Label>
|
||||
<Input value={groupMount} onChange={(e) => setGroupMount(e.target.value)} placeholder="docs, media, etc." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label><T>Group Name</T></Label>
|
||||
<Input value={groupName} onChange={(e) => setGroupName(e.target.value)} placeholder="e.g. editors" />
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-md space-y-4 bg-muted/10">
|
||||
<h3 className="text-sm font-medium"><T>Group Members</T></h3>
|
||||
<div className="flex flex-wrap gap-2 min-h-10">
|
||||
{groupMembers.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground"><T>No members selected.</T></span>
|
||||
) : (
|
||||
groupMembers.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="px-2 py-1 text-sm flex items-center gap-2">
|
||||
{id.slice(0, 8)}...
|
||||
<Trash2
|
||||
className="h-3 w-3 cursor-pointer hover:text-destructive"
|
||||
onClick={() => handleRemoveMember(id)}
|
||||
/>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 isolate pt-2">
|
||||
<div className="flex-1">
|
||||
<UserPicker
|
||||
value={memberInput}
|
||||
onSelect={(id) => handleAddMember(id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSaveGroup} disabled={savingGroup} className="w-full sm:w-auto">
|
||||
{savingGroup ? <Loader2 className="animate-spin h-4 w-4 mr-2" /> : <Shield className="h-4 w-4 mr-2" />}
|
||||
<T>Save Group</T>
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
{/* EVALUATE TAB */}
|
||||
<TabsContent value="evaluate" className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label><T>Resource Type</T></Label>
|
||||
<Input value={evalType} onChange={(e) => setEvalType(e.target.value)} placeholder="vfs, category, etc." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label><T>Resource ID / Mount</T></Label>
|
||||
<Input value={evalMount} onChange={(e) => setEvalMount(e.target.value)} placeholder="docs, media, etc." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label><T>Target Path</T></Label>
|
||||
<Input value={evalPath} onChange={(e) => setEvalPath(e.target.value)} placeholder="/some/path" />
|
||||
<p className="text-xs text-muted-foreground"><T>Optional path string to test hierarchical evaluation.</T></p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 isolate">
|
||||
<Label><T>User</T></Label>
|
||||
<UserPicker
|
||||
value={evalUserId}
|
||||
onSelect={(id) => setEvalUserId(id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleEvaluate} disabled={evaluating} className="w-full sm:w-auto">
|
||||
{evaluating ? <Loader2 className="animate-spin h-4 w-4 mr-2" /> : <ShieldCheck className="h-4 w-4 mr-2" />}
|
||||
<T>Evaluate Permissions</T>
|
||||
</Button>
|
||||
|
||||
{evalResult !== null && (
|
||||
<div className="p-4 border rounded-md mt-6 bg-accent/20">
|
||||
<h3 className="font-semibold text-sm mb-3"><T>Effective Permissions</T></h3>
|
||||
{evalResult.length === 0 ? (
|
||||
<p className="text-sm text-destructive"><T>User has NO permissions for this resource/path.</T></p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{evalResult.map((p) => (
|
||||
<Badge key={p} variant="default" className="bg-green-600 hover:bg-green-500">
|
||||
{p}
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-12 flex justify-center text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-destructive">
|
||||
<T>Failed to load ACL resources</T>: {(error as any).message}
|
||||
</div>
|
||||
) : resources?.length === 0 ? (
|
||||
<div className="p-12 text-center text-muted-foreground">
|
||||
<T>No ACL configurations found in the database.</T>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead><T>Resource Type</T></TableHead>
|
||||
<TableHead><T>Resource ID</T></TableHead>
|
||||
<TableHead><T>Owner ID</T></TableHead>
|
||||
<TableHead className="w-[100px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{resources?.map((res) => (
|
||||
<TableRow key={`${res.resourceType}-${res.resourceId}`}>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{res.resourceType}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{res.resourceId}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm font-mono">
|
||||
{res.ownerId || <span className="opacity-50">none</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingRes({ type: res.resourceType, id: res.resourceId })}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
<T>Configure</T>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={!!editingRes} onOpenChange={(open) => !open && setEditingRes(null)}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<T>Configure ACL:</T> <Badge variant="outline" className="text-base">{editingRes?.type}</Badge> <span>{editingRes?.id}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
{editingRes?.type === "vfs" ? (
|
||||
<StorageAclEditor resourceType="vfs" mount={editingRes.id} path="/" compact={false} />
|
||||
) : editingRes ? (
|
||||
<div className="p-4 text-center text-muted-foreground border rounded-md">
|
||||
<T>Direct global editing for</T> <Badge variant="outline">{editingRes.type}</Badge> <T>is currently only supported for storage mounts. Please edit this resource via its respective manager.</T>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { fetchCategories, createCategory, updateCategory, deleteCategory, Category } from "@/modules/categories/client-categories";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2, Hash, Languages, Shield } from "lucide-react";
|
||||
import { AclEditor } from "@/modules/storage/AclEditor";
|
||||
import { ACLEditorContent } from "@/modules/acl/ACLEditorContent";
|
||||
import CollapsibleSection from "@/components/CollapsibleSection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@ -396,7 +396,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
||||
</div>
|
||||
|
||||
{/* Right: Editor or Actions */}
|
||||
<div className="border rounded-md p-4 flex flex-col gap-4 overflow-y-auto bg-muted/10 w-full md:w-[300px] border-t md:border-l-0 md:border-l min-h-[50%] md:min-h-0">
|
||||
<div className="border rounded-md p-4 flex flex-col gap-4 overflow-y-auto bg-muted/10 w-full md:w-[450px] border-t md:border-l-0 md:border-l min-h-[50%] md:min-h-0">
|
||||
{editingCategory ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -560,11 +560,11 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
||||
storageKey="cat-acl-open"
|
||||
minimal
|
||||
>
|
||||
<AclEditor
|
||||
<ACLEditorContent
|
||||
resourceType="category"
|
||||
mount={selectedCategoryId}
|
||||
path="/"
|
||||
compact
|
||||
resourceId={selectedCategoryId}
|
||||
resourceName={categories.find(c => c.id === selectedCategoryId)?.name || ''}
|
||||
availablePermissions={['read', 'list', 'write', 'delete']}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
@ -625,3 +625,4 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
127
packages/ui/src/modules/acl/ACLEditorContent.tsx
Normal file
127
packages/ui/src/modules/acl/ACLEditorContent.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Shield, User, Check, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { T, translate } from "@/i18n";
|
||||
|
||||
import { ACLBaseEditor, type AclGrantInput, type AclRevokeInput, type GrantSectionContext } from "@/modules/acl/ACLBaseEditor";
|
||||
import { fetchAclSettings, grantAclPermission, revokeAclPermission } from "@/modules/acl/client-acl";
|
||||
import { fetchProfilesByUserIds } from "@/modules/user/client-user";
|
||||
|
||||
import { UserPicker } from "@/components/admin/UserPicker";
|
||||
import { GroupPicker } from "@/components/admin/GroupPicker";
|
||||
import { PermissionPicker } from "@/components/admin/PermissionPicker";
|
||||
|
||||
const DEFAULT_PERMISSIONS = ['read', 'list', 'write', 'delete'] as const;
|
||||
|
||||
export interface ACLEditorContentProps {
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
resourceName?: React.ReactNode;
|
||||
availablePermissions?: readonly string[];
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ACLEditorContent({
|
||||
resourceType,
|
||||
resourceId,
|
||||
resourceName,
|
||||
availablePermissions = DEFAULT_PERMISSIONS,
|
||||
compact
|
||||
}: ACLEditorContentProps) {
|
||||
const loadEntries = useCallback(async () => {
|
||||
const settings = await fetchAclSettings(resourceType, resourceId);
|
||||
return settings.acl || [];
|
||||
}, [resourceType, resourceId]);
|
||||
|
||||
const grant = useCallback(async (input: AclGrantInput) => {
|
||||
await grantAclPermission(resourceType, resourceId, { ...input, permissions: input.permissions });
|
||||
}, [resourceType, resourceId]);
|
||||
|
||||
const revoke = useCallback(async (input: AclRevokeInput) => {
|
||||
await revokeAclPermission(resourceType, resourceId, { ...input });
|
||||
}, [resourceType, resourceId]);
|
||||
|
||||
const renderGrantSection = useCallback((ctx: GrantSectionContext) => (
|
||||
<EntityGrantSection ctx={ctx} availablePermissions={availablePermissions} />
|
||||
), [availablePermissions]);
|
||||
|
||||
return (
|
||||
<ACLBaseEditor
|
||||
compact={compact}
|
||||
resourceDescription={resourceName ? <code>{resourceName}</code> : <></>}
|
||||
resourceContextNote={<></>}
|
||||
loadEntries={loadEntries}
|
||||
grant={grant}
|
||||
revoke={revoke}
|
||||
fetchProfiles={fetchProfilesByUserIds}
|
||||
renderGrantSection={renderGrantSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityGrantSection({ ctx, availablePermissions }: { ctx: GrantSectionContext, availablePermissions: readonly string[] }) {
|
||||
const { grant, refresh } = ctx;
|
||||
|
||||
const [tab, setTab] = useState<"user" | "group">("user");
|
||||
const [selectedUser, setSelectedUser] = useState("");
|
||||
const [userPerms, setUserPerms] = useState<string[]>(["read", "list"]);
|
||||
const [selectedGroup, setSelectedGroup] = useState("");
|
||||
const [groupPerms, setGroupPerms] = useState<string[]>(["read", "list"]);
|
||||
const [granting, setGranting] = useState(false);
|
||||
|
||||
const handleGrant = async () => {
|
||||
const isUser = tab === "user";
|
||||
const subject = isUser ? selectedUser : selectedGroup;
|
||||
const perms = isUser ? userPerms : groupPerms;
|
||||
if (!subject || perms.length === 0) return;
|
||||
setGranting(true);
|
||||
try {
|
||||
await grant({ ...(isUser ? { userId: subject } : { group: subject }), permissions: perms });
|
||||
toast.success(isUser ? translate("User access granted") : translate("Group access granted"));
|
||||
refresh();
|
||||
if (isUser) setSelectedUser(""); else setSelectedGroup("");
|
||||
} catch (e: any) { toast.error(e.message); }
|
||||
finally { setGranting(false); }
|
||||
};
|
||||
|
||||
const canSubmit = tab === "user" ? !!selectedUser && userPerms.length > 0 : !!selectedGroup && groupPerms.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium"><T>Grant Access</T></h3>
|
||||
<div className="flex rounded-md border overflow-hidden text-xs">
|
||||
<button
|
||||
className={`flex items-center gap-1.5 px-3 py-1 transition-colors ${tab === "user" ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
|
||||
onClick={() => setTab("user")}
|
||||
>
|
||||
<User className="h-3 w-3" /><T>User</T>
|
||||
</button>
|
||||
<button
|
||||
className={`flex items-center gap-1.5 px-3 py-1 transition-colors ${tab === "group" ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
|
||||
onClick={() => setTab("group")}
|
||||
>
|
||||
<Shield className="h-3 w-3" /><T>Group</T>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
{tab === "user"
|
||||
? <UserPicker value={selectedUser} onSelect={setSelectedUser} />
|
||||
: <GroupPicker value={selectedGroup} onSelect={(name) => setSelectedGroup(name)} />}
|
||||
</div>
|
||||
<Button onClick={handleGrant} disabled={!canSubmit || granting}>
|
||||
{granting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4 mr-2" />}
|
||||
<T>Grant</T>
|
||||
</Button>
|
||||
</div>
|
||||
{tab === "user"
|
||||
? <PermissionPicker value={userPerms} onChange={setUserPerms} availablePermissions={availablePermissions} />
|
||||
: <PermissionPicker value={groupPerms} onChange={setGroupPerms} availablePermissions={availablePermissions} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -89,6 +89,10 @@ export const fetchAclEntries = async (
|
||||
return settings.acl;
|
||||
};
|
||||
|
||||
export const fetchKnownAclResources = async (): Promise<{ resourceType: string, resourceId: string, ownerId: string | null }[]> => {
|
||||
return apiClient<{ resourceType: string, resourceId: string, ownerId: string | null }[]>('/api/admin/acl/resources');
|
||||
};
|
||||
|
||||
// =============================================
|
||||
// Write
|
||||
// =============================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user