acl ole :)

This commit is contained in:
lovebird 2026-02-21 00:11:38 +01:00
parent e1c1d182e4
commit 65e2a06c6b
2 changed files with 246 additions and 25 deletions

View File

@ -1,14 +1,19 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { UserPicker } from "./UserPicker";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Trash2, AlertCircle, Check, Loader2, Shield } from "lucide-react";
import { Trash2, AlertCircle, Check, Loader2, Shield, Globe, Users } from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { PermissionPicker } from "./PermissionPicker";
import { fetchAclSettings, grantAclPermission, revokeAclPermission, type AclEntry } from "@/modules/user/client-acl";
const ANONYMOUS_USER_ID = 'anonymous';
const AUTHENTICATED_USER_ID = 'authenticated';
interface AclEditorProps {
/** Resource type — e.g. 'vfs', 'layout', 'page' */
resourceType?: string;
@ -21,17 +26,20 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
const [entries, setEntries] = useState<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(Boolean))) as string[];
const userIds = Array.from(new Set(
entries.map(e => e.userId).filter(id => id && id !== ANONYMOUS_USER_ID)
)) as string[];
if (userIds.length === 0) return;
// Ensure session token is available before fetching if needed,
// though /api/profiles likely uses service key on backend,
// passing auth is good practice if RLS is involved.
const params = new URLSearchParams();
params.set('ids', userIds.join(','));
@ -70,6 +78,136 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
}
};
// Check if anonymous has a grant for root "/"
const normPath = (p: string) => p.replace(/^\/+/, '') || '/';
const anonEntry = useMemo(() =>
entries.find(e => e.userId === ANONYMOUS_USER_ID && normPath(e.path || '/') === normPath(path)),
[entries, path]
);
const anonEnabled = !!anonEntry;
// Sync anonPerms state from the current anonymous entry whenever entries change
useEffect(() => {
if (anonEntry) {
setAnonPerms([...anonEntry.permissions]);
}
}, [anonEntry]);
// Check if authenticated has a grant for current path
const authEntry = useMemo(() =>
entries.find(e => e.userId === AUTHENTICATED_USER_ID && normPath(e.path || '/') === normPath(path)),
[entries, path]
);
const authEnabled = !!authEntry;
useEffect(() => {
if (authEntry) {
setAuthPerms([...authEntry.permissions]);
}
}, [authEntry]);
const handleAnonPermsChange = async (next: string[]) => {
setAnonPerms(next);
// If anon is already enabled, immediately re-grant with updated perms
if (anonEnabled) {
if (next.length === 0) {
try {
await revokeAclPermission(resourceType, mount, { path, userId: ANONYMOUS_USER_ID });
toast.success("Anonymous access revoked");
fetchAcl();
} catch (e: any) { toast.error(e.message); }
return;
}
try {
await grantAclPermission(resourceType, mount, {
path,
userId: ANONYMOUS_USER_ID,
permissions: next,
});
fetchAcl();
} catch (e: any) { toast.error(e.message); }
}
};
const handleToggleAnonymous = async () => {
setTogglingAnon(true);
try {
if (anonEnabled) {
await revokeAclPermission(resourceType, mount, {
path,
userId: ANONYMOUS_USER_ID,
});
toast.success("Anonymous access revoked");
setAnonPerms(['read', 'list']); // reset defaults
} else {
if (anonPerms.length === 0) { toast.error('Select at least one permission'); setTogglingAnon(false); return; }
await grantAclPermission(resourceType, mount, {
path,
userId: ANONYMOUS_USER_ID,
permissions: anonPerms,
});
toast.success("Anonymous access enabled");
}
fetchAcl();
} catch (e: any) {
toast.error(e.message);
} finally {
setTogglingAnon(false);
}
};
const handleAuthPermsChange = async (next: string[]) => {
setAuthPerms(next);
if (authEnabled) {
if (next.length === 0) {
try {
await revokeAclPermission(resourceType, mount, { path, userId: AUTHENTICATED_USER_ID });
toast.success("Authenticated access revoked");
fetchAcl();
} catch (e: any) { toast.error(e.message); }
return;
}
try {
await grantAclPermission(resourceType, mount, {
path,
userId: AUTHENTICATED_USER_ID,
permissions: next,
});
fetchAcl();
} catch (e: any) { toast.error(e.message); }
}
};
const handleToggleAuthenticated = async () => {
setTogglingAuth(true);
try {
if (authEnabled) {
await revokeAclPermission(resourceType, mount, {
path,
userId: AUTHENTICATED_USER_ID,
});
toast.success("Authenticated access revoked");
setAuthPerms(['read', 'list']);
} else {
if (authPerms.length === 0) { toast.error('Select at least one permission'); setTogglingAuth(false); return; }
await grantAclPermission(resourceType, mount, {
path,
userId: AUTHENTICATED_USER_ID,
permissions: authPerms,
});
toast.success("Authenticated access enabled");
}
fetchAcl();
} catch (e: any) {
toast.error(e.message);
} finally {
setTogglingAuth(false);
}
};
const handleGrant = async () => {
if (!selectedUser) return;
setGranting(true);
@ -77,7 +215,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
await grantAclPermission(resourceType, mount, {
path,
userId: selectedUser,
permissions: ['read', 'list'],
permissions: Array.from(userPerms),
});
toast.success("Access granted");
fetchAcl();
@ -104,20 +242,11 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
}
};
// Filter entries? Or show all?
// User requested "set acl permissions on any folder".
// If I select "Folder A", I probably want to see who has access to "Folder A".
// But ACLs can be inherited or exact. Our current backend implementation is exact matching in the `vfs-settings`.
// So let's show entries where entry.path matches current path OR is parent/child?
// For now, let's just list ALL ACLs for the mount, because that gives the best overview for admin.
// Maybe sort them by path.
// Normalize paths for comparison (remove leading slashes)
const normalizePath = (p: string) => p.replace(/^\/+/, '') || '/';
const targetPath = normalizePath(path);
const targetPath = normPath(path);
const sortedEntries = entries
.filter(e => normalizePath(e.path || '') === targetPath)
.filter(e => normPath(e.path || '') === targetPath)
.sort((a, b) => (a.userId || '').localeCompare(b.userId || ''));
return (
@ -133,21 +262,65 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
</CardHeader>
<CardContent className="px-0 space-y-6">
{/* Anonymous Access Toggle */}
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-blue-500" />
<div>
<h3 className="text-sm font-medium">Anonymous Access</h3>
<p className="text-xs text-muted-foreground">
Allow unauthenticated access on <code>{path}</code>
</p>
</div>
</div>
<Switch
checked={anonEnabled}
onCheckedChange={handleToggleAnonymous}
disabled={togglingAnon}
/>
</div>
<div className="pl-8">
<PermissionPicker value={anonPerms} onChange={handleAnonPermsChange} disabled={!anonEnabled} />
</div>
</div>
{/* Authenticated Users Toggle */}
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-green-500" />
<div>
<h3 className="text-sm font-medium">Authenticated Users</h3>
<p className="text-xs text-muted-foreground">
Allow any logged-in user access on <code>{path}</code>
</p>
</div>
</div>
<Switch
checked={authEnabled}
onCheckedChange={handleToggleAuthenticated}
disabled={togglingAuth}
/>
</div>
<div className="pl-8">
<PermissionPicker value={authPerms} onChange={handleAuthPermsChange} disabled={!authEnabled} />
</div>
</div>
{/* Grant Form */}
<div className="space-y-4 border rounded-lg p-4 bg-muted/30">
<div className="space-y-3 border rounded-lg p-4 bg-muted/30">
<h3 className="text-sm font-medium">Grant Access</h3>
<div className="flex gap-2">
<div className="flex-1">
<UserPicker value={selectedUser} onSelect={(id) => setSelectedUser(id)} />
</div>
<Button onClick={handleGrant} disabled={!selectedUser || granting}>
<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" />}
Grant
</Button>
</div>
<p className="text-xs text-muted-foreground">
Grants <strong>read/list</strong> access to {path}
</p>
<PermissionPicker value={userPerms} onChange={setUserPerms} />
</div>
{/* ACL List */}
@ -184,7 +357,12 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
{entry.path === path && <Badge variant="outline" className="ml-2 text-[10px] h-4">Current</Badge>}
</TableCell>
<TableCell>
{entry.userId ? (
{entry.userId === ANONYMOUS_USER_ID ? (
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-blue-500" />
<Badge variant="secondary">Anonymous</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'}

View File

@ -0,0 +1,43 @@
/**
* PermissionPicker reusable permission checkbox row using Radix Checkbox.
*
* Usage:
* <PermissionPicker value={['read','list']} onChange={setPerms} />
*/
import React from "react";
import { Checkbox } from "@/components/ui/checkbox";
const ALL_PERMISSIONS = ['read', 'list', 'write', 'delete'] as const;
interface PermissionPickerProps {
/** Currently selected permissions */
value: string[];
/** Called with new permissions array on every change */
onChange: (perms: string[]) => void;
/** Disable interaction */
disabled?: boolean;
}
export const PermissionPicker: React.FC<PermissionPickerProps> = ({ value, onChange, disabled }) => {
const toggle = (perm: string, checked: boolean) => {
const next = checked
? [...value.filter(p => p !== perm), perm]
: value.filter(p => p !== perm);
onChange(next);
};
return (
<div className="flex gap-4">
{ALL_PERMISSIONS.map(p => (
<label key={p} className={`flex items-center gap-1.5 text-xs select-none ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}>
<Checkbox
checked={value.includes(p)}
onCheckedChange={(checked) => toggle(p, !!checked)}
disabled={disabled}
/>
{p}
</label>
))}
</div>
);
};