acl ui - 1/2

This commit is contained in:
lovebird 2026-04-05 11:16:25 +02:00
parent c54549d238
commit e47430369c
4 changed files with 226 additions and 196 deletions

View File

@ -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>
);
}

View File

@ -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>
);
};

View 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>
);
}

View File

@ -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
// =============================================