diff --git a/packages/ui/src/components/admin/AclPlayground.tsx b/packages/ui/src/components/admin/AclPlayground.tsx index 52a7d30d..bda13d6f 100644 --- a/packages/ui/src/components/admin/AclPlayground.tsx +++ b/packages/ui/src/components/admin/AclPlayground.tsx @@ -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([]); - 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(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 ( - ACL Playground + Global ACL Registry - Test and configure advanced Access Control settings and groups. + List of all resources that have explicit access control configured. - - - - - - Groups - - - - Evaluation - - - - {/* GROUPS TAB */} - -
-
- - setGroupType(e.target.value)} placeholder="vfs, category, etc." /> -
-
- - setGroupMount(e.target.value)} placeholder="docs, media, etc." /> -
-
- -
- - setGroupName(e.target.value)} placeholder="e.g. editors" /> -
- -
-

Group Members

-
- {groupMembers.length === 0 ? ( - No members selected. - ) : ( - groupMembers.map((id) => ( - - {id.slice(0, 8)}... - handleRemoveMember(id)} - /> - - )) - )} -
-
-
- handleAddMember(id)} - /> -
-
-
- - -
- - {/* EVALUATE TAB */} - -
-
- - setEvalType(e.target.value)} placeholder="vfs, category, etc." /> -
-
- - setEvalMount(e.target.value)} placeholder="docs, media, etc." /> -
-
- -
- - setEvalPath(e.target.value)} placeholder="/some/path" /> -

Optional path string to test hierarchical evaluation.

-
- -
- - setEvalUserId(id)} - /> -
- - - - {evalResult !== null && ( -
-

Effective Permissions

- {evalResult.length === 0 ? ( -

User has NO permissions for this resource/path.

- ) : ( -
- {evalResult.map((p) => ( - - {p} + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ Failed to load ACL resources: {(error as any).message} +
+ ) : resources?.length === 0 ? ( +
+ No ACL configurations found in the database. +
+ ) : ( +
+ + + + Resource Type + Resource ID + Owner ID + + + + + {resources?.map((res) => ( + + + + {res.resourceType} - ))} - - )} - - )} - - + + + {res.resourceId} + + + {res.ownerId || none} + + + + + + ))} + +
+
+ )}
+ + !open && setEditingRes(null)}> + + + + + Configure ACL: {editingRes?.type} {editingRes?.id} + + +
+ {editingRes?.type === "vfs" ? ( + + ) : editingRes ? ( +
+ Direct global editing for {editingRes.type} is currently only supported for storage mounts. Please edit this resource via its respective manager. +
+ ) : null} +
+
+
); } diff --git a/packages/ui/src/components/widgets/CategoryManager.tsx b/packages/ui/src/components/widgets/CategoryManager.tsx index 17fe9295..e701b046 100644 --- a/packages/ui/src/components/widgets/CategoryManager.tsx +++ b/packages/ui/src/components/widgets/CategoryManager.tsx @@ -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
{/* Right: Editor or Actions */} -
+
{editingCategory ? (
@@ -560,11 +560,11 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet storageKey="cat-acl-open" minimal > - c.id === selectedCategoryId)?.name || ''} + availablePermissions={['read', 'list', 'write', 'delete']} />
@@ -625,3 +625,4 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet ); }; + diff --git a/packages/ui/src/modules/acl/ACLEditorContent.tsx b/packages/ui/src/modules/acl/ACLEditorContent.tsx new file mode 100644 index 00000000..0242e506 --- /dev/null +++ b/packages/ui/src/modules/acl/ACLEditorContent.tsx @@ -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) => ( + + ), [availablePermissions]); + + return ( + {resourceName} : <>} + 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(["read", "list"]); + const [selectedGroup, setSelectedGroup] = useState(""); + const [groupPerms, setGroupPerms] = useState(["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 ( +
+
+
+

Grant Access

+
+ + +
+
+
+
+ {tab === "user" + ? + : setSelectedGroup(name)} />} +
+ +
+ {tab === "user" + ? + : } +
+
+ ); +} diff --git a/packages/ui/src/modules/acl/client-acl.ts b/packages/ui/src/modules/acl/client-acl.ts index bcda28de..1636f2d0 100644 --- a/packages/ui/src/modules/acl/client-acl.ts +++ b/packages/ui/src/modules/acl/client-acl.ts @@ -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 // =============================================