diff --git a/packages/ui/src/components/admin/AclEditor.tsx b/packages/ui/src/components/admin/AclEditor.tsx index 12ac0687..97b28aca 100644 --- a/packages/ui/src/components/admin/AclEditor.tsx +++ b/packages/ui/src/components/admin/AclEditor.tsx @@ -1,14 +1,19 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useAuth } from "@/hooks/useAuth"; import { Button } from "@/components/ui/button"; import { UserPicker } from "./UserPicker"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Trash2, AlertCircle, Check, Loader2, Shield } from "lucide-react"; +import { Trash2, AlertCircle, Check, Loader2, Shield, Globe, Users } from "lucide-react"; import { toast } from "sonner"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { PermissionPicker } from "./PermissionPicker"; import { fetchAclSettings, grantAclPermission, revokeAclPermission, type AclEntry } from "@/modules/user/client-acl"; +const ANONYMOUS_USER_ID = 'anonymous'; +const AUTHENTICATED_USER_ID = 'authenticated'; + interface AclEditorProps { /** Resource type — e.g. 'vfs', 'layout', 'page' */ resourceType?: string; @@ -21,17 +26,20 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [granting, setGranting] = useState(false); + const [togglingAnon, setTogglingAnon] = useState(false); const [selectedUser, setSelectedUser] = useState(""); const [profiles, setProfiles] = useState>({}); + const [anonPerms, setAnonPerms] = useState(['read', 'list']); + const [userPerms, setUserPerms] = useState(['read', 'list']); + const [authPerms, setAuthPerms] = useState(['read', 'list']); + const [togglingAuth, setTogglingAuth] = useState(false); useEffect(() => { - const userIds = Array.from(new Set(entries.map(e => e.userId).filter(Boolean))) as string[]; + const userIds = Array.from(new Set( + entries.map(e => e.userId).filter(id => id && id !== ANONYMOUS_USER_ID) + )) as string[]; if (userIds.length === 0) return; - // Ensure session token is available before fetching if needed, - // though /api/profiles likely uses service key on backend, - // passing auth is good practice if RLS is involved. - const params = new URLSearchParams(); params.set('ids', userIds.join(',')); @@ -70,6 +78,136 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) } }; + // Check if anonymous has a grant for root "/" + const normPath = (p: string) => p.replace(/^\/+/, '') || '/'; + const anonEntry = useMemo(() => + entries.find(e => e.userId === ANONYMOUS_USER_ID && normPath(e.path || '/') === normPath(path)), + [entries, path] + ); + const anonEnabled = !!anonEntry; + + // Sync anonPerms state from the current anonymous entry whenever entries change + useEffect(() => { + if (anonEntry) { + setAnonPerms([...anonEntry.permissions]); + } + }, [anonEntry]); + + // Check if authenticated has a grant for current path + const authEntry = useMemo(() => + entries.find(e => e.userId === AUTHENTICATED_USER_ID && normPath(e.path || '/') === normPath(path)), + [entries, path] + ); + const authEnabled = !!authEntry; + + useEffect(() => { + if (authEntry) { + setAuthPerms([...authEntry.permissions]); + } + }, [authEntry]); + + const handleAnonPermsChange = async (next: string[]) => { + setAnonPerms(next); + + // If anon is already enabled, immediately re-grant with updated perms + if (anonEnabled) { + if (next.length === 0) { + try { + await revokeAclPermission(resourceType, mount, { path, userId: ANONYMOUS_USER_ID }); + toast.success("Anonymous access revoked"); + fetchAcl(); + } catch (e: any) { toast.error(e.message); } + return; + } + try { + await grantAclPermission(resourceType, mount, { + path, + userId: ANONYMOUS_USER_ID, + permissions: next, + }); + fetchAcl(); + } catch (e: any) { toast.error(e.message); } + } + }; + + const handleToggleAnonymous = async () => { + setTogglingAnon(true); + try { + if (anonEnabled) { + await revokeAclPermission(resourceType, mount, { + path, + userId: ANONYMOUS_USER_ID, + }); + toast.success("Anonymous access revoked"); + setAnonPerms(['read', 'list']); // reset defaults + } else { + if (anonPerms.length === 0) { toast.error('Select at least one permission'); setTogglingAnon(false); return; } + await grantAclPermission(resourceType, mount, { + path, + userId: ANONYMOUS_USER_ID, + permissions: anonPerms, + }); + toast.success("Anonymous access enabled"); + } + fetchAcl(); + } catch (e: any) { + toast.error(e.message); + } finally { + setTogglingAnon(false); + } + }; + + const handleAuthPermsChange = async (next: string[]) => { + setAuthPerms(next); + + if (authEnabled) { + if (next.length === 0) { + try { + await revokeAclPermission(resourceType, mount, { path, userId: AUTHENTICATED_USER_ID }); + toast.success("Authenticated access revoked"); + fetchAcl(); + } catch (e: any) { toast.error(e.message); } + return; + } + try { + await grantAclPermission(resourceType, mount, { + path, + userId: AUTHENTICATED_USER_ID, + permissions: next, + }); + fetchAcl(); + } catch (e: any) { toast.error(e.message); } + } + }; + + const handleToggleAuthenticated = async () => { + setTogglingAuth(true); + try { + if (authEnabled) { + await revokeAclPermission(resourceType, mount, { + path, + userId: AUTHENTICATED_USER_ID, + }); + toast.success("Authenticated access revoked"); + setAuthPerms(['read', 'list']); + } else { + if (authPerms.length === 0) { toast.error('Select at least one permission'); setTogglingAuth(false); return; } + await grantAclPermission(resourceType, mount, { + path, + userId: AUTHENTICATED_USER_ID, + permissions: authPerms, + }); + toast.success("Authenticated access enabled"); + } + fetchAcl(); + } catch (e: any) { + toast.error(e.message); + } finally { + setTogglingAuth(false); + } + }; + + const handleGrant = async () => { if (!selectedUser) return; setGranting(true); @@ -77,7 +215,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) await grantAclPermission(resourceType, mount, { path, userId: selectedUser, - permissions: ['read', 'list'], + permissions: Array.from(userPerms), }); toast.success("Access granted"); fetchAcl(); @@ -104,20 +242,11 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) } }; - // Filter entries? Or show all? - // User requested "set acl permissions on any folder". - // If I select "Folder A", I probably want to see who has access to "Folder A". - // But ACLs can be inherited or exact. Our current backend implementation is exact matching in the `vfs-settings`. - // So let's show entries where entry.path matches current path OR is parent/child? - // For now, let's just list ALL ACLs for the mount, because that gives the best overview for admin. - // Maybe sort them by path. - // Normalize paths for comparison (remove leading slashes) - const normalizePath = (p: string) => p.replace(/^\/+/, '') || '/'; - const targetPath = normalizePath(path); + const targetPath = normPath(path); const sortedEntries = entries - .filter(e => normalizePath(e.path || '') === targetPath) + .filter(e => normPath(e.path || '') === targetPath) .sort((a, b) => (a.userId || '').localeCompare(b.userId || '')); return ( @@ -133,21 +262,65 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) + {/* Anonymous Access Toggle */} +
+
+
+ +
+

Anonymous Access

+

+ Allow unauthenticated access on {path} +

+
+
+ +
+
+ +
+
+ + {/* Authenticated Users Toggle */} +
+
+
+ +
+

Authenticated Users

+

+ Allow any logged-in user access on {path} +

+
+
+ +
+
+ +
+
+ {/* Grant Form */} -
+

Grant Access

setSelectedUser(id)} />
-
-

- Grants read/list access to {path} -

+
{/* ACL List */} @@ -184,7 +357,12 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) {entry.path === path && Current} - {entry.userId ? ( + {entry.userId === ANONYMOUS_USER_ID ? ( +
+ + Anonymous +
+ ) : entry.userId ? (
{profiles[entry.userId]?.display_name || profiles[entry.userId]?.username || 'User'} diff --git a/packages/ui/src/components/admin/PermissionPicker.tsx b/packages/ui/src/components/admin/PermissionPicker.tsx new file mode 100644 index 00000000..70376f0a --- /dev/null +++ b/packages/ui/src/components/admin/PermissionPicker.tsx @@ -0,0 +1,43 @@ +/** + * PermissionPicker — reusable permission checkbox row using Radix Checkbox. + * + * Usage: + * + */ +import React from "react"; +import { Checkbox } from "@/components/ui/checkbox"; + +const ALL_PERMISSIONS = ['read', 'list', 'write', 'delete'] as const; + +interface PermissionPickerProps { + /** Currently selected permissions */ + value: string[]; + /** Called with new permissions array on every change */ + onChange: (perms: string[]) => void; + /** Disable interaction */ + disabled?: boolean; +} + +export const PermissionPicker: React.FC = ({ value, onChange, disabled }) => { + const toggle = (perm: string, checked: boolean) => { + const next = checked + ? [...value.filter(p => p !== perm), perm] + : value.filter(p => p !== perm); + onChange(next); + }; + + return ( +
+ {ALL_PERMISSIONS.map(p => ( + + ))} +
+ ); +};