412 lines
20 KiB
TypeScript
412 lines
20 KiB
TypeScript
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';
|
|
|
|
interface AclEditorProps {
|
|
/** Resource type — e.g. 'vfs', 'layout', 'page' */
|
|
resourceType?: string;
|
|
mount: string;
|
|
path: string;
|
|
/** Compact mode for narrow containers (e.g. category manager sidebar) */
|
|
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);
|
|
|
|
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 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(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>
|
|
);
|
|
}
|