diff --git a/packages/ui/src/components/admin/AclEditor.tsx b/packages/ui/src/components/admin/AclEditor.tsx index 202f1a3c..6f838c73 100644 --- a/packages/ui/src/components/admin/AclEditor.tsx +++ b/packages/ui/src/components/admin/AclEditor.tsx @@ -1,19 +1,7 @@ -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, Globe, Users } from "lucide-react"; -import { toast } from "sonner"; -import { T, translate } from "@/i18n"; -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'; +import { useCallback } from "react"; +import { ACLBaseEditor, type AclGrantInput, type AclRevokeInput } from "@/modules/acl/ACLBaseEditor"; +import { fetchAclSettings, grantAclPermission, revokeAclPermission } from "@/modules/acl/client-acl"; +import { fetchProfilesByUserIds } from "@/modules/user/client-user"; interface AclEditorProps { /** Resource type — e.g. 'vfs', 'layout', 'page' */ @@ -24,388 +12,50 @@ interface AclEditorProps { compact?: boolean; } -export function AclEditor({ resourceType = 'vfs', mount, path, compact = false }: AclEditorProps) { - const { session, user } = useAuth(); - 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); +/** VFS-oriented ACL editor: `resourceId` is the mount name. For other backends, use {@link ACLBaseEditor}. */ +export function AclEditor({ resourceType = "vfs", mount, path, compact = false }: AclEditorProps) { + const loadEntries = useCallback(async () => { + const settings = await fetchAclSettings(resourceType, mount); + return settings.acl || []; + }, [resourceType, mount]); - useEffect(() => { - 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; - - const params = new URLSearchParams(); - params.set('ids', userIds.join(',')); - - fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/profiles?${params.toString()}`, { - headers: { 'Authorization': `Bearer ${session?.access_token}` } - }) - .then(res => { - if (!res.ok) throw new Error('Failed to fetch profiles'); - return res.json(); - }) - .then((data: any[]) => { - if (Array.isArray(data)) { - const profileMap = data.reduce((acc, p) => ({ ...acc, [p.user_id]: p }), {}); - setProfiles(prev => ({ ...prev, ...profileMap })); - } - }) - .catch(err => console.error("Profile fetch error:", err)); - }, [entries, session]); - - useEffect(() => { - if (mount && path) { - fetchAcl(); - } - }, [mount, path]); - - const fetchAcl = async () => { - try { - setLoading(true); - const settings = await fetchAclSettings(resourceType, mount); - setEntries(settings.acl || []); - } catch (e) { - console.error(e); - setEntries([]); - } finally { - setLoading(false); - } - }; - - // 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 grant = useCallback( + async (input: AclGrantInput) => { + await grantAclPermission(resourceType, mount, input); + }, + [resourceType, mount], ); - 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 revoke = useCallback( + async (input: AclRevokeInput) => { + await revokeAclPermission(resourceType, mount, input); + }, + [resourceType, mount], ); - 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(translate("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(translate("Anonymous access revoked")); - setAnonPerms(['read', 'list']); // reset defaults - } else { - if (anonPerms.length === 0) { toast.error(translate('Select at least one permission')); setTogglingAnon(false); return; } - await grantAclPermission(resourceType, mount, { - path, - userId: ANONYMOUS_USER_ID, - permissions: anonPerms, - }); - toast.success(translate("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(translate("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(translate("Authenticated access revoked")); - setAuthPerms(['read', 'list']); - } else { - if (authPerms.length === 0) { toast.error(translate('Select at least one permission')); setTogglingAuth(false); return; } - await grantAclPermission(resourceType, mount, { - path, - userId: AUTHENTICATED_USER_ID, - permissions: authPerms, - }); - toast.success(translate("Authenticated access enabled")); - } - fetchAcl(); - } catch (e: any) { - toast.error(e.message); - } finally { - setTogglingAuth(false); - } - }; - - - const handleGrant = async () => { - if (!selectedUser) return; - setGranting(true); - try { - await grantAclPermission(resourceType, mount, { - path, - userId: selectedUser, - permissions: Array.from(userPerms), - }); - toast.success(translate("Access granted")); - fetchAcl(); - setSelectedUser(""); - } catch (e: any) { - toast.error(e.message); - } finally { - setGranting(false); - } - }; - - const handleRevoke = async (entry: AclEntry) => { - if (!confirm(translate("Are you sure you want to revoke this permission?"))) return; - try { - await revokeAclPermission(resourceType, mount, { - path: entry.path, - userId: entry.userId, - group: entry.group, - }); - toast.success(translate("Access revoked")); - fetchAcl(); - } catch (e: any) { - toast.error(e.message); - } - }; - - // Normalize paths for comparison (remove leading slashes) - const targetPath = normPath(path); - - const sortedEntries = entries - .filter(e => normPath(e.path || '') === targetPath) - .sort((a, b) => (a.userId || '').localeCompare(b.userId || '')); return ( - - {!compact && ( - - - - Access Control - - - Manage permissions for {mount}:{path} - - - )} - - - {/* Anonymous Access Toggle */} -
-
-
- -
-

Anonymous Access

- {!compact &&

Allow unauthenticated access on {path}

} -
-
- -
-
- -
-
- - {/* Authenticated Users Toggle */} -
-
-
- -
-

Authenticated Users

- {!compact &&

Allow any logged-in user access on {path}

} -
-
- -
-
- -
-
- - {/* Grant Form — hidden in compact mode (too wide for sidebar) */} - {!compact && ( -
-

Grant Access

-
-
- setSelectedUser(id)} /> -
- -
- -
- )} - - {/* ACL List — hidden in compact mode (uses table, too wide) */} - {!compact && ( -
-

Active Permissions (Mount: {mount})

-
- - - - Path - Subject - Permissions - - - - - {loading ? ( - - - - - - ) : sortedEntries.length === 0 ? ( - - - No active permissions found. - - - ) : ( - sortedEntries.map((entry, i) => ( - - - {entry.path || '/'} - {entry.path === path && Current} - - - {entry.userId === ANONYMOUS_USER_ID ? ( -
- - Anonymous -
- ) : entry.userId ? ( -
- - {profiles[entry.userId]?.display_name || profiles[entry.userId]?.username || 'User'} - - - {entry.userId.slice(0, 8)}... - -
- ) : entry.group ? ( - Group: {entry.group} - ) : 'Unknown'} -
- -
- {entry.permissions.map(p => ( - {p} - ))} -
-
- - - -
- )) - )} -
-
-
-
- )} - -
-
+ + + {mount}:{path} + + + } + resourceContextNote={<> (Mount: {mount})} + loadEntries={loadEntries} + grant={grant} + revoke={revoke} + fetchProfiles={fetchProfilesByUserIds} + /> ); } + +export { + ACLBaseEditor, + ANONYMOUS_USER_ID, + AUTHENTICATED_USER_ID, +} from "@/modules/acl/ACLBaseEditor"; +export type { ACLBaseEditorProps, AclGrantInput, AclRevokeInput } from "@/modules/acl/ACLBaseEditor"; diff --git a/packages/ui/src/components/admin/StorageManager.tsx b/packages/ui/src/components/admin/StorageManager.tsx index a3fc908b..e19e17d4 100644 --- a/packages/ui/src/components/admin/StorageManager.tsx +++ b/packages/ui/src/components/admin/StorageManager.tsx @@ -180,6 +180,7 @@ export default function StorageManager() { allowFolderMove={true} allowFolderRename={true} showToolbar={true} + index={false} glob="*.*" sortBy="name" /> diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index f574da9a..3cf6df29 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -37,7 +37,8 @@ const KEY_PREFIXES: [RegExp, (...groups: string[]) => string[]][] = [ [/^type-(.+)$/, (_, id) => ['types', id]], [/^types-(.+)$/, (_, rest) => ['types', rest]], [/^i18n-(.+)$/, (_, rest) => ['i18n', rest]], - [/^acl-(.+?)-(.+)$/, (_, type, id) => ['acl', type, id]], + // resourceType is first segment (no hyphens); resourceId may contain hyphens (e.g. vfs mount) + [/^acl-([^-]+)-(.+)$/, (_, type, id) => ['acl', type, id]], [/^layout-(.+)$/, (_, id) => ['layouts', id]], [/^layouts-(.+)$/, (_, rest) => ['layouts', rest]], [/^contacts-(.+)$/, (_, rest) => ['contacts', rest]], @@ -79,7 +80,28 @@ export const getAuthHeaders = async (): Promise => { return headers; }; -/** Generic API Client handler */ +async function apiResponseJson(response: Response, endpoint: string): Promise { + if (!response.ok) { + let msg = response.statusText; + const ct = response.headers.get('content-type'); + if (ct?.includes('application/json')) { + try { + const body = await response.json(); + if (body && typeof body.error === 'string') msg = body.error; + } catch { + /* ignore */ + } + } + throw new Error(`API Error on ${endpoint}: ${msg}`); + } + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json(); + } + return response.text() as unknown as T; +} + +/** Generic API client: `${serverUrl}${endpoint}`, auth headers, JSON error bodies when present */ export async function apiClient(endpoint: string, options: RequestInit = {}): Promise { const headers = await getAuthHeaders(); const response = await fetch(`${serverUrl}${endpoint}`, { @@ -89,16 +111,21 @@ export async function apiClient(endpoint: string, options: RequestInit = {}): ...options.headers, }, }); + return apiResponseJson(response, endpoint); +} - if (!response.ok) { - throw new Error(`API Error on ${endpoint}: ${response.statusText}`); - } - - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - return response.json(); - } - return response.text() as unknown as T; +/** Same as apiClient but returns null on HTTP 404 (no throw). */ +export async function apiClientOr404(endpoint: string, options: RequestInit = {}): Promise { + const headers = await getAuthHeaders(); + const response = await fetch(`${serverUrl}${endpoint}`, { + ...options, + headers: { + ...headers, + ...options.headers, + }, + }); + if (response.status === 404) return null; + return apiResponseJson(response, endpoint); } diff --git a/packages/ui/src/modules/acl/ACLBaseEditor.tsx b/packages/ui/src/modules/acl/ACLBaseEditor.tsx new file mode 100644 index 00000000..2ce6f183 --- /dev/null +++ b/packages/ui/src/modules/acl/ACLBaseEditor.tsx @@ -0,0 +1,517 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { UserPicker } from "@/components/admin/UserPicker"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Trash2, Check, Loader2, Shield, Globe, Users } from "lucide-react"; +import { toast } from "sonner"; +import { T, translate } from "@/i18n"; +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 "@/components/admin/PermissionPicker"; +import type { AclEntry } from "./client-acl"; + +export const ANONYMOUS_USER_ID = "anonymous"; +export const AUTHENTICATED_USER_ID = "authenticated"; + +export type AclGrantInput = { + path: string; + userId?: string; + group?: string; + permissions: string[]; +}; + +export type AclRevokeInput = { + path?: string; + userId?: string; + group?: string; +}; + +export interface ACLBaseEditorProps { + path: string; + compact?: boolean; + /** Shown in the card description (e.g. mount:path or a layout id). */ + resourceDescription: React.ReactNode; + /** Optional line under “Active Permissions” (e.g. mount name). */ + resourceContextNote?: React.ReactNode; + loadEntries: () => Promise; + grant: (input: AclGrantInput) => Promise; + revoke: (input: AclRevokeInput) => Promise; + /** Resolve display names for user rows; omit to show only a short id. */ + fetchProfiles?: ( + userIds: string[], + ) => Promise>; +} + +const normPath = (p: string) => p.replace(/^\/+/, "") || "/"; + +export function ACLBaseEditor({ + path, + compact = false, + resourceDescription, + resourceContextNote, + loadEntries, + grant, + revoke, + fetchProfiles, +}: ACLBaseEditorProps) { + 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); + + const refresh = useCallback(async () => { + try { + setLoading(true); + const list = await loadEntries(); + setEntries(list || []); + } catch (e) { + console.error(e); + setEntries([]); + } finally { + setLoading(false); + } + }, [loadEntries]); + + useEffect(() => { + if (path) refresh(); + }, [path, refresh]); + + useEffect(() => { + const userIds = Array.from( + new Set(entries.map((e) => e.userId).filter((id) => id && id !== ANONYMOUS_USER_ID)), + ) as string[]; + if (userIds.length === 0 || !fetchProfiles) return; + + let cancelled = false; + fetchProfiles(userIds) + .then((map) => { + if (!cancelled && map) setProfiles((prev) => ({ ...prev, ...map })); + }) + .catch((err) => console.error("Profile fetch error:", err)); + return () => { + cancelled = true; + }; + }, [entries, fetchProfiles]); + + const anonEntry = useMemo( + () => + entries.find( + (e) => e.userId === ANONYMOUS_USER_ID && normPath(e.path || "/") === normPath(path), + ), + [entries, path], + ); + const anonEnabled = !!anonEntry; + + useEffect(() => { + if (anonEntry) setAnonPerms([...anonEntry.permissions]); + }, [anonEntry]); + + 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 (anonEnabled) { + if (next.length === 0) { + try { + await revoke({ path, userId: ANONYMOUS_USER_ID }); + toast.success(translate("Anonymous access revoked")); + refresh(); + } catch (e: any) { + toast.error(e.message); + } + return; + } + try { + await grant({ + path, + userId: ANONYMOUS_USER_ID, + permissions: next, + }); + refresh(); + } catch (e: any) { + toast.error(e.message); + } + } + }; + + const handleToggleAnonymous = async () => { + setTogglingAnon(true); + try { + if (anonEnabled) { + await revoke({ path, userId: ANONYMOUS_USER_ID }); + toast.success(translate("Anonymous access revoked")); + setAnonPerms(["read", "list"]); + } else { + if (anonPerms.length === 0) { + toast.error(translate("Select at least one permission")); + setTogglingAnon(false); + return; + } + await grant({ + path, + userId: ANONYMOUS_USER_ID, + permissions: anonPerms, + }); + toast.success(translate("Anonymous access enabled")); + } + refresh(); + } 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 revoke({ path, userId: AUTHENTICATED_USER_ID }); + toast.success(translate("Authenticated access revoked")); + refresh(); + } catch (e: any) { + toast.error(e.message); + } + return; + } + try { + await grant({ + path, + userId: AUTHENTICATED_USER_ID, + permissions: next, + }); + refresh(); + } catch (e: any) { + toast.error(e.message); + } + } + }; + + const handleToggleAuthenticated = async () => { + setTogglingAuth(true); + try { + if (authEnabled) { + await revoke({ path, userId: AUTHENTICATED_USER_ID }); + toast.success(translate("Authenticated access revoked")); + setAuthPerms(["read", "list"]); + } else { + if (authPerms.length === 0) { + toast.error(translate("Select at least one permission")); + setTogglingAuth(false); + return; + } + await grant({ + path, + userId: AUTHENTICATED_USER_ID, + permissions: authPerms, + }); + toast.success(translate("Authenticated access enabled")); + } + refresh(); + } catch (e: any) { + toast.error(e.message); + } finally { + setTogglingAuth(false); + } + }; + + const handleGrant = async () => { + if (!selectedUser) return; + setGranting(true); + try { + await grant({ + path, + userId: selectedUser, + permissions: Array.from(userPerms), + }); + toast.success(translate("Access granted")); + refresh(); + setSelectedUser(""); + } catch (e: any) { + toast.error(e.message); + } finally { + setGranting(false); + } + }; + + const handleRevoke = async (entry: AclEntry) => { + if (!confirm(translate("Are you sure you want to revoke this permission?"))) return; + try { + await revoke({ + path: entry.path, + userId: entry.userId, + group: entry.group, + }); + toast.success(translate("Access revoked")); + refresh(); + } catch (e: any) { + toast.error(e.message); + } + }; + + const targetPath = normPath(path); + const sortedEntries = entries + .filter((e) => normPath(e.path || "") === targetPath) + .sort((a, b) => (a.userId || "").localeCompare(b.userId || "")); + + return ( + + {!compact && ( + + + + Access Control + + + Manage permissions for {resourceDescription} + + + )} + +
+
+
+ +
+

+ Anonymous Access +

+ {!compact && ( +

+ Allow unauthenticated access on {path} +

+ )} +
+
+ +
+
+ +
+
+ +
+
+
+ +
+

+ Authenticated Users +

+ {!compact && ( +

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

+ )} +
+
+ +
+
+ +
+
+ + {!compact && ( +
+

+ Grant Access +

+
+
+ setSelectedUser(id)} /> +
+ +
+ +
+ )} + + {!compact && ( +
+

+ Active Permissions + {resourceContextNote != null ? <> {resourceContextNote} : null} +

+
+ + + + + Path + + + Subject + + + Permissions + + + + + + {loading ? ( + + + + + + ) : sortedEntries.length === 0 ? ( + + + No active permissions found. + + + ) : ( + sortedEntries.map((entry, i) => ( + + + {entry.path || "/"} + {entry.path === path && ( + + Current + + )} + + + {entry.userId === ANONYMOUS_USER_ID ? ( +
+ + + Anonymous + +
+ ) : entry.userId === AUTHENTICATED_USER_ID ? ( +
+ + + Authenticated Users + +
+ ) : entry.userId ? ( +
+ + {profiles[entry.userId]?.display_name || + profiles[entry.userId]?.username || + "User"} + + + {entry.userId.slice(0, 8)}... + +
+ ) : entry.group ? ( + Group: {entry.group} + ) : ( + "Unknown" + )} +
+ +
+ {entry.permissions.map((p) => ( + + {p} + + ))} +
+
+ + + +
+ )) + )} +
+
+
+
+ )} +
+
+ ); +} diff --git a/packages/ui/src/modules/acl/client-acl.ts b/packages/ui/src/modules/acl/client-acl.ts new file mode 100644 index 00000000..a0a87e69 --- /dev/null +++ b/packages/ui/src/modules/acl/client-acl.ts @@ -0,0 +1,206 @@ +/** + * Client-side ACL API wrappers (mirrors server acl endpoints). + * Uses apiClient from @/lib/db (serverUrl + auth + JSON errors). + */ +import { apiClient, apiClientOr404, fetchWithDeduplication, invalidateCache } from "@/lib/db"; +import { queryClient } from "@/lib/queryClient"; + +// ============================================= +// Types (server AclEntry / AclSettings) +// ============================================= + +export interface AclEntry { + userId?: string; + group?: string; + path?: string; + permissions: string[]; +} + +export interface AclSettings { + owner: string; + groups?: { name: string; members: string[] }[]; + acl: AclEntry[]; +} + +export interface GlobalAclGroup { + id: string; + name: string; + description?: string; + parent_id?: string; + native_type?: string; + settings?: any; + created_at: string; + modified_at: string; + created_by: string; +} + +export interface GroupMember { + user_id: string; + created_at: string; + created_by: string; + profiles?: { + id: string; + display_name?: string; + username?: string; + avatar_url?: string; + }; +} + +// ============================================= +// Helpers +// ============================================= + +const aclCacheKey = (resourceType: string, resourceId: string) => + `acl-${resourceType}-${resourceId}`; + +const aclPath = (resourceType: string, resourceId: string) => + `/api/acl/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`; + +// ============================================= +// Read +// ============================================= + +/** GET /api/acl/:resourceType/:resourceId — cached via React Query */ +export const fetchAclSettings = async ( + resourceType: string, + resourceId: string, +): Promise => { + return fetchWithDeduplication( + aclCacheKey(resourceType, resourceId), + async () => { + const data = await apiClientOr404(aclPath(resourceType, resourceId)); + return data ?? { owner: "", groups: [], acl: [] }; + }, + 30000, + ); +}; + +export const fetchAclEntries = async ( + resourceType: string, + resourceId: string, +): Promise => { + const settings = await fetchAclSettings(resourceType, resourceId); + return settings.acl; +}; + +// ============================================= +// Write +// ============================================= + +/** POST /api/acl/:resourceType/:resourceId/grant */ +export const grantAclPermission = async ( + resourceType: string, + resourceId: string, + grant: { userId?: string; group?: string; path?: string; permissions: string[] }, +): Promise<{ success: boolean; settings: AclSettings }> => { + const out = await apiClient<{ success: boolean; settings: AclSettings }>( + `${aclPath(resourceType, resourceId)}/grant`, + { method: "POST", body: JSON.stringify(grant) }, + ); + invalidateCache(aclCacheKey(resourceType, resourceId)); + return out; +}; + +/** POST /api/acl/:resourceType/:resourceId/revoke */ +export const revokeAclPermission = async ( + resourceType: string, + resourceId: string, + target: { userId?: string; group?: string; path?: string }, +): Promise<{ success: boolean; settings: AclSettings }> => { + const out = await apiClient<{ success: boolean; settings: AclSettings }>( + `${aclPath(resourceType, resourceId)}/revoke`, + { method: "POST", body: JSON.stringify(target) }, + ); + invalidateCache(aclCacheKey(resourceType, resourceId)); + return out; +}; + +/** POST /api/acl/:resourceType/:resourceId/groups */ +export const putAclGroup = async ( + resourceType: string, + resourceId: string, + group: { name: string; members: string[] }, +): Promise<{ success: boolean; settings: AclSettings }> => { + const out = await apiClient<{ success: boolean; settings: AclSettings }>( + `${aclPath(resourceType, resourceId)}/groups`, + { method: "POST", body: JSON.stringify(group) }, + ); + invalidateCache(aclCacheKey(resourceType, resourceId)); + return out; +}; + +/** POST /api/acl/:resourceType/:resourceId/evaluate */ +export const evaluateAcl = async ( + resourceType: string, + resourceId: string, + userId: string, + path: string, +): Promise<{ permissions: string[] }> => { + return apiClient<{ permissions: string[] }>(`${aclPath(resourceType, resourceId)}/evaluate`, { + method: "POST", + body: JSON.stringify({ userId, path }), + }); +}; + +// ============================================= +// Global groups (admin) +// ============================================= + +export const fetchGlobalGroups = async (nativeType?: string): Promise => { + const params = new URLSearchParams(); + if (nativeType) params.set("nativeType", nativeType); + const qs = params.toString(); + return apiClient(`/api/admin/acl/groups${qs ? `?${qs}` : ""}`); +}; + +export const putGlobalGroup = async (group: Partial): Promise => { + const out = await apiClient("/api/admin/acl/groups", { + method: "POST", + body: JSON.stringify(group), + }); + queryClient.invalidateQueries({ queryKey: ["global-acl-groups"] }); + return out; +}; + +export const deleteGlobalGroup = async (id: string): Promise<{ success: boolean }> => { + const out = await apiClient<{ success: boolean }>(`/api/admin/acl/groups/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + queryClient.invalidateQueries({ queryKey: ["global-acl-groups"] }); + return out; +}; + +export const fetchGroupMembers = async (groupId: string): Promise => { + return apiClient(`/api/admin/acl/groups/${encodeURIComponent(groupId)}/members`); +}; + +export const addGroupMember = async (groupId: string, userId: string): Promise => { + const out = await apiClient( + `/api/admin/acl/groups/${encodeURIComponent(groupId)}/members`, + { method: "POST", body: JSON.stringify({ userId }) }, + ); + queryClient.invalidateQueries({ queryKey: ["group-members", groupId] }); + return out; +}; + +export const removeGroupMember = async ( + groupId: string, + userId: string, +): Promise<{ success: boolean }> => { + const out = await apiClient<{ success: boolean }>( + `/api/admin/acl/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}`, + { method: "DELETE" }, + ); + queryClient.invalidateQueries({ queryKey: ["group-members", groupId] }); + return out; +}; + +export const fetchUserGroups = async (userId: string): Promise => { + return apiClient(`/api/admin/users/${encodeURIComponent(userId)}/groups`); +}; + +export const fetchUserEffectiveGroups = async (userId: string): Promise => { + return apiClient( + `/api/admin/users/${encodeURIComponent(userId)}/groups/effective`, + ); +}; diff --git a/packages/ui/src/modules/user/client-acl.ts b/packages/ui/src/modules/user/client-acl.ts index deb4b6e6..843ceb7a 100644 --- a/packages/ui/src/modules/user/client-acl.ts +++ b/packages/ui/src/modules/user/client-acl.ts @@ -1,312 +1,2 @@ -/** - * client-acl.ts — Client-side ACL API wrappers - * - * Resource-agnostic: works with any resourceType (vfs, layout, page, etc.) - * Follows the pattern in client-user.ts (fetchWithDeduplication, getAuthToken). - */ -import { supabase as defaultSupabase } from "@/integrations/supabase/client"; -import { fetchWithDeduplication, parseQueryKey } from "@/lib/db"; -import { queryClient } from "@/lib/queryClient"; - -// ============================================= -// Types (mirrors server-side AclEntry / AclSettings) -// ============================================= - -export interface AclEntry { - userId?: string; - group?: string; - path?: string; - permissions: string[]; -} - -export interface AclSettings { - owner: string; - groups?: { name: string; members: string[] }[]; - acl: AclEntry[]; -} - -export interface GlobalAclGroup { - id: string; - name: string; - description?: string; - parent_id?: string; - native_type?: string; - settings?: any; - created_at: string; - modified_at: string; - created_by: string; -} - -export interface GroupMember { - user_id: string; - created_at: string; - created_by: string; - profiles?: { - id: string; - display_name?: string; - username?: string; - avatar_url?: string; - }; -} - -// ============================================= -// Helpers -// ============================================= - -const getAuthToken = async (): Promise => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - if (!token) throw new Error('Not authenticated'); - return token; -}; - -const serverUrl = (): string => - (import.meta as any).env?.VITE_SERVER_IMAGE_API_URL || ''; - -// ============================================= -// Read -// ============================================= - -/** Fetch ACL settings for a resource. Cached via React Query / fetchWithDeduplication. */ -export const fetchAclSettings = async ( - resourceType: string, - resourceId: string, -): Promise => { - return fetchWithDeduplication( - `acl-${resourceType}-${resourceId}`, - async () => { - const token = await getAuthToken(); - const res = await fetch( - `${serverUrl()}/api/acl/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`, - { headers: { Authorization: `Bearer ${token}` } }, - ); - if (!res.ok) { - if (res.status === 404) return { owner: '', groups: [], acl: [] }; - throw new Error(`Failed to fetch ACL: ${res.statusText}`); - } - return await res.json(); - }, - 30000, // 30s stale time - ); -}; - -/** Convenience: fetch just the entries array. */ -export const fetchAclEntries = async ( - resourceType: string, - resourceId: string, -): Promise => { - const settings = await fetchAclSettings(resourceType, resourceId); - return settings.acl; -}; - -// ============================================= -// Write -// ============================================= - -/** Grant permissions on a resource. Invalidates local cache. */ -export const grantAclPermission = async ( - resourceType: string, - resourceId: string, - grant: { userId?: string; group?: string; path?: string; permissions: string[] }, -): Promise<{ success: boolean; settings: AclSettings }> => { - const token = await getAuthToken(); - const res = await fetch( - `${serverUrl()}/api/acl/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/grant`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(grant), - }, - ); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error((err as any).error || `Failed to grant: ${res.statusText}`); - } - queryClient.invalidateQueries({ queryKey: parseQueryKey(`acl-${resourceType}-${resourceId}`) }); - return await res.json(); -}; - -/** Revoke permissions on a resource. Invalidates local cache. */ -export const revokeAclPermission = async ( - resourceType: string, - resourceId: string, - target: { userId?: string; group?: string; path?: string }, -): Promise<{ success: boolean; settings: AclSettings }> => { - const token = await getAuthToken(); - const res = await fetch( - `${serverUrl()}/api/acl/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/revoke`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(target), - }, - ); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error((err as any).error || `Failed to revoke: ${res.statusText}`); - } - queryClient.invalidateQueries({ queryKey: parseQueryKey(`acl-${resourceType}-${resourceId}`) }); - return await res.json(); -}; - -export const putAclGroup = async ( - resourceType: string, - resourceId: string, - group: { name: string; members: string[] }, -): Promise<{ success: boolean; settings: AclSettings }> => { - const token = await getAuthToken(); - const res = await fetch( - `${serverUrl()}/api/acl/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/groups`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(group), - }, - ); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error((err as any).error || `Failed to update group: ${res.statusText}`); - } - queryClient.invalidateQueries({ queryKey: parseQueryKey(`acl-${resourceType}-${resourceId}`) }); - return await res.json(); -}; - -export const evaluateAcl = async ( - resourceType: string, - resourceId: string, - userId: string, - path: string -): Promise<{ permissions: string[] }> => { - const token = await getAuthToken(); - const res = await fetch( - `${serverUrl()}/api/acl/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/evaluate`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ userId, path }), - }, - ); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error((err as any).error || `Failed to evaluate ACL: ${res.statusText}`); - } - return await res.json(); -}; - -// ============================================= -// Global Groups (Admin) -// ============================================= - -/** List all global ACL groups. */ -export const fetchGlobalGroups = async (nativeType?: string): Promise => { - const token = await getAuthToken(); - const url = new URL(`${serverUrl()}/api/admin/acl/groups`); - if (nativeType) url.searchParams.set('nativeType', nativeType); - - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) throw new Error(`Failed to fetch global groups: ${res.statusText}`); - return await res.json(); -}; - -/** Create or update a global ACL group. */ -export const putGlobalGroup = async (group: Partial): Promise => { - const token = await getAuthToken(); - const res = await fetch(`${serverUrl()}/api/admin/acl/groups`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(group), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error((err as any).error || `Failed to put global group: ${res.statusText}`); - } - queryClient.invalidateQueries({ queryKey: ['global-acl-groups'] }); - return await res.json(); -}; - -/** Delete a global ACL group. */ -export const deleteGlobalGroup = async (id: string): Promise<{ success: boolean }> => { - const token = await getAuthToken(); - const res = await fetch(`${serverUrl()}/api/admin/acl/groups/${encodeURIComponent(id)}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) throw new Error(`Failed to delete global group: ${res.statusText}`); - queryClient.invalidateQueries({ queryKey: ['global-acl-groups'] }); - return await res.json(); -}; - -/** List members of a global group. */ -export const fetchGroupMembers = async (groupId: string): Promise => { - const token = await getAuthToken(); - const res = await fetch(`${serverUrl()}/api/admin/acl/groups/${encodeURIComponent(groupId)}/members`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) throw new Error(`Failed to fetch group members: ${res.statusText}`); - return await res.json(); -}; - -/** Add a user to a global group. */ -export const addGroupMember = async (groupId: string, userId: string): Promise => { - const token = await getAuthToken(); - const res = await fetch(`${serverUrl()}/api/admin/acl/groups/${encodeURIComponent(groupId)}/members`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ userId }), - }); - if (!res.ok) throw new Error(`Failed to add group member: ${res.statusText}`); - queryClient.invalidateQueries({ queryKey: ['group-members', groupId] }); - return await res.json(); -}; - -/** Remove a user from a global group. */ -export const removeGroupMember = async (groupId: string, userId: string): Promise<{ success: boolean }> => { - const token = await getAuthToken(); - const res = await fetch(`${serverUrl()}/api/admin/acl/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) throw new Error(`Failed to remove group member: ${res.statusText}`); - queryClient.invalidateQueries({ queryKey: ['group-members', groupId] }); - return await res.json(); -}; - -/** List groups for a specific user. */ -export const fetchUserGroups = async (userId: string): Promise => { - const token = await getAuthToken(); - const res = await fetch(`${serverUrl()}/api/admin/users/${encodeURIComponent(userId)}/groups`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) throw new Error(`Failed to fetch user groups: ${res.statusText}`); - return await res.json(); -}; - -/** List all effective groups (including inheritance) for a specific user. */ -export const fetchUserEffectiveGroups = async (userId: string): Promise => { - const token = await getAuthToken(); - const res = await fetch(`${serverUrl()}/api/admin/users/${encodeURIComponent(userId)}/groups/effective`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) throw new Error(`Failed to fetch effective user groups: ${res.statusText}`); - return await res.json(); -}; +/** @deprecated Import from @/modules/acl/client-acl — kept for existing import paths */ +export * from "@/modules/acl/client-acl"; diff --git a/packages/ui/src/modules/user/client-user.ts b/packages/ui/src/modules/user/client-user.ts index 835246df..8e6f1a5d 100644 --- a/packages/ui/src/modules/user/client-user.ts +++ b/packages/ui/src/modules/user/client-user.ts @@ -352,6 +352,27 @@ const getAuthToken = async (): Promise => { return token; }; +/** Batch-fetch profiles for admin UIs (e.g. ACL subject column). GET /api/profiles?ids= */ +export const fetchProfilesByUserIds = async ( + userIds: string[], +): Promise> => { + if (userIds.length === 0) return {}; + const token = await getAuthToken(); + const params = new URLSearchParams(); + params.set('ids', userIds.join(',')); + const res = await fetch(serverUrl(`/api/profiles?${params.toString()}`), { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error('Failed to fetch profiles'); + const data: unknown = await res.json(); + if (!Array.isArray(data)) return {}; + const map: Record = {}; + for (const p of data as { user_id: string }[]) { + if (p?.user_id) map[p.user_id] = p as { display_name?: string; username?: string }; + } + return map; +}; + /** Fetch all users (admin) */ export const fetchAdminUsersAPI = async (): Promise => { const token = await getAuthToken();