acl ui - 1/2

This commit is contained in:
lovebird 2026-04-04 21:00:56 +02:00
parent cf4e0fa08b
commit f0937d3519
7 changed files with 828 additions and 716 deletions

View File

@ -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<AclEntry[]>([]);
const [loading, setLoading] = useState(false);
const [granting, setGranting] = useState(false);
const [togglingAnon, setTogglingAnon] = useState(false);
const [selectedUser, setSelectedUser] = useState<string>("");
const [profiles, setProfiles] = useState<Record<string, any>>({});
const [anonPerms, setAnonPerms] = useState<string[]>(['read', 'list']);
const [userPerms, setUserPerms] = useState<string[]>(['read', 'list']);
const [authPerms, setAuthPerms] = useState<string[]>(['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 (
<Card className={`h-full border-0 shadow-none bg-transparent ${compact ? 'compact-acl' : ''}`}>
{!compact && (
<CardHeader className="px-0 pt-0">
<CardTitle className="text-lg flex items-center gap-2">
<Shield className="h-5 w-5" />
<T>Access Control</T>
</CardTitle>
<CardDescription>
<T>Manage permissions for</T> <code>{mount}:{path}</code>
</CardDescription>
</CardHeader>
)}
<CardContent className={compact ? 'px-0 space-y-3' : 'px-0 space-y-6'}>
{/* Anonymous Access Toggle */}
<div className={compact ? 'border rounded-md p-2.5 bg-muted/30 space-y-2' : 'border rounded-lg p-4 bg-muted/30 space-y-3'}>
<div className={compact ? 'flex items-center justify-between gap-2' : 'flex items-center justify-between'}>
<div className="flex items-center gap-2">
<Globe className={compact ? 'h-4 w-4 text-blue-500 shrink-0' : 'h-5 w-5 text-blue-500'} />
<div className="min-w-0">
<h3 className={compact ? 'text-xs font-medium' : 'text-sm font-medium'}><T>Anonymous Access</T></h3>
{!compact && <p className="text-xs text-muted-foreground"><T>Allow unauthenticated access on</T> <code>{path}</code></p>}
</div>
</div>
<Switch
checked={anonEnabled}
onCheckedChange={handleToggleAnonymous}
disabled={togglingAnon}
/>
</div>
<div className={compact ? 'pl-6' : 'pl-8'}>
<PermissionPicker value={anonPerms} onChange={handleAnonPermsChange} disabled={!anonEnabled} />
</div>
</div>
{/* Authenticated Users Toggle */}
<div className={compact ? 'border rounded-md p-2.5 bg-muted/30 space-y-2' : 'border rounded-lg p-4 bg-muted/30 space-y-3'}>
<div className={compact ? 'flex items-center justify-between gap-2' : 'flex items-center justify-between'}>
<div className="flex items-center gap-2">
<Users className={compact ? 'h-4 w-4 text-green-500 shrink-0' : 'h-5 w-5 text-green-500'} />
<div className="min-w-0">
<h3 className={compact ? 'text-xs font-medium' : 'text-sm font-medium'}><T>Authenticated Users</T></h3>
{!compact && <p className="text-xs text-muted-foreground"><T>Allow any logged-in user access on</T> <code>{path}</code></p>}
</div>
</div>
<Switch
checked={authEnabled}
onCheckedChange={handleToggleAuthenticated}
disabled={togglingAuth}
/>
</div>
<div className={compact ? 'pl-6' : 'pl-8'}>
<PermissionPicker value={authPerms} onChange={handleAuthPermsChange} disabled={!authEnabled} />
</div>
</div>
{/* Grant Form — hidden in compact mode (too wide for sidebar) */}
{!compact && (
<div className="space-y-3 border rounded-lg p-4 bg-muted/30">
<h3 className="text-sm font-medium"><T>Grant Access</T></h3>
<div className="flex gap-2">
<div className="flex-1">
<UserPicker value={selectedUser} onSelect={(id) => setSelectedUser(id)} />
</div>
<Button onClick={handleGrant} disabled={!selectedUser || granting || userPerms.length === 0}>
{granting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4 mr-2" />}
<T>Grant</T>
</Button>
</div>
<PermissionPicker value={userPerms} onChange={setUserPerms} />
</div>
)}
{/* ACL List — hidden in compact mode (uses table, too wide) */}
{!compact && (
<div className="space-y-2">
<h3 className="text-sm font-medium"><T>Active Permissions</T> (Mount: {mount})</h3>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead><T>Path</T></TableHead>
<TableHead><T>Subject</T></TableHead>
<TableHead><T>Permissions</T></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : sortedEntries.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
<T>No active permissions found.</T>
</TableCell>
</TableRow>
) : (
sortedEntries.map((entry, i) => (
<TableRow key={i} className={entry.path === path ? "bg-muted/50" : ""}>
<TableCell className="font-mono text-xs">
{entry.path || '/'}
{entry.path === path && <Badge variant="outline" className="ml-2 text-[10px] h-4"><T>Current</T></Badge>}
</TableCell>
<TableCell>
{entry.userId === ANONYMOUS_USER_ID ? (
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-blue-500" />
<Badge variant="secondary"><T>Anonymous</T></Badge>
</div>
) : entry.userId ? (
<div className="flex flex-col">
<span className="text-sm font-medium">
{profiles[entry.userId]?.display_name || profiles[entry.userId]?.username || 'User'}
</span>
<span className="text-xs text-muted-foreground font-mono" title={entry.userId}>
{entry.userId.slice(0, 8)}...
</span>
</div>
) : entry.group ? (
<Badge variant="secondary">Group: {entry.group}</Badge>
) : 'Unknown'}
</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{entry.permissions.map(p => (
<Badge key={p} variant="outline" className="text-[10px] px-1 h-5">{p}</Badge>
))}
</div>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRevoke(entry)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
)}
</CardContent>
</Card>
<ACLBaseEditor
path={path}
compact={compact}
resourceDescription={
<>
<code>
{mount}:{path}
</code>
</>
}
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";

View File

@ -180,6 +180,7 @@ export default function StorageManager() {
allowFolderMove={true}
allowFolderRename={true}
showToolbar={true}
index={false}
glob="*.*"
sortBy="name"
/>

View File

@ -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<HeadersInit> => {
return headers;
};
/** Generic API Client handler */
async function apiResponseJson<T>(response: Response, endpoint: string): Promise<T> {
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers = await getAuthHeaders();
const response = await fetch(`${serverUrl}${endpoint}`, {
@ -89,16 +111,21 @@ export async function apiClient<T>(endpoint: string, options: RequestInit = {}):
...options.headers,
},
});
return apiResponseJson<T>(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<T>(endpoint: string, options: RequestInit = {}): Promise<T | null> {
const headers = await getAuthHeaders();
const response = await fetch(`${serverUrl}${endpoint}`, {
...options,
headers: {
...headers,
...options.headers,
},
});
if (response.status === 404) return null;
return apiResponseJson<T>(response, endpoint);
}

View File

@ -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. <code>mount:path</code> or a layout id). */
resourceDescription: React.ReactNode;
/** Optional line under “Active Permissions” (e.g. mount name). */
resourceContextNote?: React.ReactNode;
loadEntries: () => Promise<AclEntry[]>;
grant: (input: AclGrantInput) => Promise<void>;
revoke: (input: AclRevokeInput) => Promise<void>;
/** Resolve display names for user rows; omit to show only a short id. */
fetchProfiles?: (
userIds: string[],
) => Promise<Record<string, { display_name?: string; username?: string }>>;
}
const normPath = (p: string) => p.replace(/^\/+/, "") || "/";
export function ACLBaseEditor({
path,
compact = false,
resourceDescription,
resourceContextNote,
loadEntries,
grant,
revoke,
fetchProfiles,
}: ACLBaseEditorProps) {
const [entries, setEntries] = useState<AclEntry[]>([]);
const [loading, setLoading] = useState(false);
const [granting, setGranting] = useState(false);
const [togglingAnon, setTogglingAnon] = useState(false);
const [selectedUser, setSelectedUser] = useState<string>("");
const [profiles, setProfiles] = useState<Record<string, { display_name?: string; username?: string }>>(
{},
);
const [anonPerms, setAnonPerms] = useState<string[]>(["read", "list"]);
const [userPerms, setUserPerms] = useState<string[]>(["read", "list"]);
const [authPerms, setAuthPerms] = useState<string[]>(["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 (
<Card className={`h-full border-0 shadow-none bg-transparent ${compact ? "compact-acl" : ""}`}>
{!compact && (
<CardHeader className="px-0 pt-0">
<CardTitle className="text-lg flex items-center gap-2">
<Shield className="h-5 w-5" />
<T>Access Control</T>
</CardTitle>
<CardDescription>
<T>Manage permissions for</T> {resourceDescription}
</CardDescription>
</CardHeader>
)}
<CardContent className={compact ? "px-0 space-y-3" : "px-0 space-y-6"}>
<div
className={
compact
? "border rounded-md p-2.5 bg-muted/30 space-y-2"
: "border rounded-lg p-4 bg-muted/30 space-y-3"
}
>
<div
className={
compact ? "flex items-center justify-between gap-2" : "flex items-center justify-between"
}
>
<div className="flex items-center gap-2">
<Globe
className={compact ? "h-4 w-4 text-blue-500 shrink-0" : "h-5 w-5 text-blue-500"}
/>
<div className="min-w-0">
<h3 className={compact ? "text-xs font-medium" : "text-sm font-medium"}>
<T>Anonymous Access</T>
</h3>
{!compact && (
<p className="text-xs text-muted-foreground">
<T>Allow unauthenticated access on</T> <code>{path}</code>
</p>
)}
</div>
</div>
<Switch
checked={anonEnabled}
onCheckedChange={handleToggleAnonymous}
disabled={togglingAnon}
/>
</div>
<div className={compact ? "pl-6" : "pl-8"}>
<PermissionPicker
value={anonPerms}
onChange={handleAnonPermsChange}
disabled={!anonEnabled}
/>
</div>
</div>
<div
className={
compact
? "border rounded-md p-2.5 bg-muted/30 space-y-2"
: "border rounded-lg p-4 bg-muted/30 space-y-3"
}
>
<div
className={
compact ? "flex items-center justify-between gap-2" : "flex items-center justify-between"
}
>
<div className="flex items-center gap-2">
<Users
className={compact ? "h-4 w-4 text-green-500 shrink-0" : "h-5 w-5 text-green-500"}
/>
<div className="min-w-0">
<h3 className={compact ? "text-xs font-medium" : "text-sm font-medium"}>
<T>Authenticated Users</T>
</h3>
{!compact && (
<p className="text-xs text-muted-foreground">
<T>Allow any logged-in user access on</T> <code>{path}</code>
</p>
)}
</div>
</div>
<Switch
checked={authEnabled}
onCheckedChange={handleToggleAuthenticated}
disabled={togglingAuth}
/>
</div>
<div className={compact ? "pl-6" : "pl-8"}>
<PermissionPicker
value={authPerms}
onChange={handleAuthPermsChange}
disabled={!authEnabled}
/>
</div>
</div>
{!compact && (
<div className="space-y-3 border rounded-lg p-4 bg-muted/30">
<h3 className="text-sm font-medium">
<T>Grant Access</T>
</h3>
<div className="flex gap-2">
<div className="flex-1">
<UserPicker value={selectedUser} onSelect={(id) => setSelectedUser(id)} />
</div>
<Button
onClick={handleGrant}
disabled={!selectedUser || granting || userPerms.length === 0}
>
{granting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4 mr-2" />
)}
<T>Grant</T>
</Button>
</div>
<PermissionPicker value={userPerms} onChange={setUserPerms} />
</div>
)}
{!compact && (
<div className="space-y-2">
<h3 className="text-sm font-medium">
<T>Active Permissions</T>
{resourceContextNote != null ? <> {resourceContextNote}</> : null}
</h3>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>
<T>Path</T>
</TableHead>
<TableHead>
<T>Subject</T>
</TableHead>
<TableHead>
<T>Permissions</T>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : sortedEntries.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
<T>No active permissions found.</T>
</TableCell>
</TableRow>
) : (
sortedEntries.map((entry, i) => (
<TableRow key={i} className={entry.path === path ? "bg-muted/50" : ""}>
<TableCell className="font-mono text-xs">
{entry.path || "/"}
{entry.path === path && (
<Badge variant="outline" className="ml-2 text-[10px] h-4">
<T>Current</T>
</Badge>
)}
</TableCell>
<TableCell>
{entry.userId === ANONYMOUS_USER_ID ? (
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-blue-500" />
<Badge variant="secondary">
<T>Anonymous</T>
</Badge>
</div>
) : entry.userId === AUTHENTICATED_USER_ID ? (
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-green-500" />
<Badge variant="secondary">
<T>Authenticated Users</T>
</Badge>
</div>
) : entry.userId ? (
<div className="flex flex-col">
<span className="text-sm font-medium">
{profiles[entry.userId]?.display_name ||
profiles[entry.userId]?.username ||
"User"}
</span>
<span
className="text-xs text-muted-foreground font-mono"
title={entry.userId}
>
{entry.userId.slice(0, 8)}...
</span>
</div>
) : entry.group ? (
<Badge variant="secondary">Group: {entry.group}</Badge>
) : (
"Unknown"
)}
</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{entry.permissions.map((p) => (
<Badge
key={p}
variant="outline"
className="text-[10px] px-1 h-5"
>
{p}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRevoke(entry)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -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<AclSettings> => {
return fetchWithDeduplication(
aclCacheKey(resourceType, resourceId),
async () => {
const data = await apiClientOr404<AclSettings>(aclPath(resourceType, resourceId));
return data ?? { owner: "", groups: [], acl: [] };
},
30000,
);
};
export const fetchAclEntries = async (
resourceType: string,
resourceId: string,
): Promise<AclEntry[]> => {
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<GlobalAclGroup[]> => {
const params = new URLSearchParams();
if (nativeType) params.set("nativeType", nativeType);
const qs = params.toString();
return apiClient<GlobalAclGroup[]>(`/api/admin/acl/groups${qs ? `?${qs}` : ""}`);
};
export const putGlobalGroup = async (group: Partial<GlobalAclGroup>): Promise<GlobalAclGroup> => {
const out = await apiClient<GlobalAclGroup>("/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<GroupMember[]> => {
return apiClient<GroupMember[]>(`/api/admin/acl/groups/${encodeURIComponent(groupId)}/members`);
};
export const addGroupMember = async (groupId: string, userId: string): Promise<GroupMember> => {
const out = await apiClient<GroupMember>(
`/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<GlobalAclGroup[]> => {
return apiClient<GlobalAclGroup[]>(`/api/admin/users/${encodeURIComponent(userId)}/groups`);
};
export const fetchUserEffectiveGroups = async (userId: string): Promise<GlobalAclGroup[]> => {
return apiClient<GlobalAclGroup[]>(
`/api/admin/users/${encodeURIComponent(userId)}/groups/effective`,
);
};

View File

@ -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<string> => {
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<AclSettings> => {
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<AclEntry[]> => {
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<GlobalAclGroup[]> => {
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<GlobalAclGroup>): Promise<GlobalAclGroup> => {
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<GroupMember[]> => {
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<GroupMember> => {
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<GlobalAclGroup[]> => {
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<GlobalAclGroup[]> => {
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";

View File

@ -352,6 +352,27 @@ const getAuthToken = async (): Promise<string> => {
return token;
};
/** Batch-fetch profiles for admin UIs (e.g. ACL subject column). GET /api/profiles?ids= */
export const fetchProfilesByUserIds = async (
userIds: string[],
): Promise<Record<string, { display_name?: string; username?: string }>> => {
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<string, { display_name?: string; username?: string }> = {};
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<any[]> => {
const token = await getAuthToken();