acl ole :)
This commit is contained in:
parent
e1c1d182e4
commit
65e2a06c6b
@ -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'}
|
||||
|
||||
43
packages/ui/src/components/admin/PermissionPicker.tsx
Normal file
43
packages/ui/src/components/admin/PermissionPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user