acl ui - 1/2

This commit is contained in:
lovebird 2026-04-05 10:34:09 +02:00
parent f0937d3519
commit c54549d238
8 changed files with 335 additions and 610 deletions

View File

@ -1,61 +0,0 @@
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' */
resourceType?: string;
mount: string;
path: string;
/** Compact mode for narrow containers (e.g. category manager sidebar) */
compact?: boolean;
}
/** 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]);
const grant = useCallback(
async (input: AclGrantInput) => {
await grantAclPermission(resourceType, mount, input);
},
[resourceType, mount],
);
const revoke = useCallback(
async (input: AclRevokeInput) => {
await revokeAclPermission(resourceType, mount, input);
},
[resourceType, mount],
);
return (
<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

@ -25,8 +25,7 @@ import {
Loader2,
UserMinus
} from "lucide-react";
import { AclEditor } from "@/components/admin/AclEditor";
import CollapsibleSection from "@/components/CollapsibleSection";
import { cn } from "@/lib/utils";
import { T, translate } from "@/i18n";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@ -358,30 +357,7 @@ export const GroupManager = () => {
</div>
</section>
{/* Permissions Section */}
<CollapsibleSection
title={
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<span className="text-sm font-bold uppercase tracking-wider text-muted-foreground"><T>Permissions & ACLs</T></span>
</div>
}
initiallyOpen={false}
minimal
className="bg-muted/5 rounded-xl border p-2"
>
<div className="p-4 space-y-4">
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg text-xs text-yellow-600 dark:text-yellow-400">
<T>These permissions apply to resources where this group is explicitly granted access.</T>
</div>
<AclEditor
resourceType="acl_group"
mount={selectedGroupId}
path="/"
compact
/>
</div>
</CollapsibleSection>
</div>
</div>
) : (

View File

@ -7,8 +7,6 @@
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[];
@ -16,9 +14,11 @@ interface PermissionPickerProps {
onChange: (perms: string[]) => void;
/** Disable interaction */
disabled?: boolean;
/** List of available permissions to pick from */
availablePermissions: readonly string[];
}
export const PermissionPicker: React.FC<PermissionPickerProps> = ({ value, onChange, disabled }) => {
export const PermissionPicker: React.FC<PermissionPickerProps> = ({ value, onChange, disabled, availablePermissions }) => {
const toggle = (perm: string, checked: boolean) => {
const next = checked
? [...value.filter(p => p !== perm), perm]
@ -28,7 +28,7 @@ export const PermissionPicker: React.FC<PermissionPickerProps> = ({ value, onCha
return (
<div className="flex flex-wrap gap-x-4 gap-y-1">
{ALL_PERMISSIONS.map(p => (
{availablePermissions.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)}

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { FileBrowserWidget } from "@/modules/storage";
import { AclEditor } from "@/components/admin/AclEditor";
import { AclEditor } from "@/modules/storage/AclEditor";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { T, translate } from "@/i18n";

View File

@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { fetchCategories, createCategory, updateCategory, deleteCategory, Category } from "@/modules/categories/client-categories";
import { toast } from "sonner";
import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2, Hash, Languages, Shield } from "lucide-react";
import { AclEditor } from "@/components/admin/AclEditor";
import { AclEditor } from "@/modules/storage/AclEditor";
import CollapsibleSection from "@/components/CollapsibleSection";
import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";

View File

@ -1,517 +1,195 @@
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>
);
}
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Trash2, Loader2, Shield } 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 { type AclEntry } from "./client-acl";
export type AclGrantInput = {
userId?: string;
group?: string;
permissions: string[];
};
export type AclRevokeInput = {
userId?: string;
group?: string;
};
/** Context passed to renderGrantSection. */
export type GrantSectionContext = {
entries: AclEntry[];
grant: (input: AclGrantInput) => Promise<void>;
revoke: (input: AclRevokeInput) => Promise<void>;
refresh: () => void;
};
export interface ACLBaseEditorProps {
compact?: boolean;
resourceDescription: React.ReactNode;
resourceContextNote?: React.ReactNode;
loadEntries: () => Promise<AclEntry[]>;
grant: (input: AclGrantInput) => Promise<void>;
revoke: (input: AclRevokeInput) => Promise<void>;
fetchProfiles?: (
userIds: string[],
) => Promise<Record<string, { display_name?: string; username?: string }>>;
renderGrantSection?: (ctx: GrantSectionContext) => React.ReactNode;
}
export function ACLBaseEditor({
compact = false,
resourceDescription,
resourceContextNote,
loadEntries,
grant,
revoke,
fetchProfiles,
renderGrantSection,
}: ACLBaseEditorProps) {
const [entries, setEntries] = useState<AclEntry[]>([]);
const [loading, setLoading] = useState(false);
const [profiles, setProfiles] = useState<Record<string, { display_name?: string; username?: string }>>({});
const refresh = useCallback(async () => {
try {
setLoading(true);
const list = await loadEntries();
setEntries(list || []);
} catch (e) {
console.error(e);
setEntries([]);
} finally {
setLoading(false);
}
}, [loadEntries]);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
const userIds = Array.from(
new Set(
entries
.map((e) => e.userId)
.filter(Boolean),
),
) 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 handleRevoke = async (entry: AclEntry) => {
if (!confirm(translate("Are you sure you want to revoke this permission?"))) return;
try {
await revoke({ userId: entry.userId, group: entry.group });
toast.success(translate("Access revoked"));
refresh();
} catch (e: any) { toast.error(e.message); }
};
const sortedEntries = [...entries]
.sort((a, b) => (a.userId || a.group || "").localeCompare(b.userId || b.group || ""));
const ctx: GrantSectionContext = { entries, grant, revoke, refresh };
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"}>
{renderGrantSection && renderGrantSection(ctx)}
{/* Active Permissions table */}
{!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>Subject</T></TableHead>
<TableHead><T>Permissions</T></TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={3} 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={3} className="h-24 text-center text-muted-foreground">
<T>No active permissions found.</T>
</TableCell>
</TableRow>
) : (
sortedEntries.map((entry, i) => (
<TableRow key={i}>
<TableCell>
{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 ? (
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<Badge variant="secondary">{entry.group}</Badge>
</div>
) : "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

@ -5,6 +5,12 @@
import { apiClient, apiClientOr404, fetchWithDeduplication, invalidateCache } from "@/lib/db";
import { queryClient } from "@/lib/queryClient";
// =============================================
// Sentinel userId values
// Mirrors the virtual identity strings used by the server's DbAclBackend
// (server/src/acl/db-acl-db.ts). These are stored as group_name in the DB.
// =============================================
// =============================================
// Types (server AclEntry / AclSettings)
// =============================================

View File

@ -0,0 +1,126 @@
import { useCallback, useState } from "react";
import { ACLBaseEditor, type AclGrantInput, type AclRevokeInput, type GrantSectionContext } from "@/modules/acl/ACLBaseEditor";
import { fetchAclSettings, grantAclPermission, revokeAclPermission } from "@/modules/acl/client-acl";
import { fetchProfilesByUserIds } from "@/modules/user/client-user";
import { UserPicker } from "@/components/admin/UserPicker";
import { GroupPicker } from "@/components/admin/GroupPicker";
import { PermissionPicker } from "@/components/admin/PermissionPicker";
import { Button } from "@/components/ui/button";
import { Check, Loader2, Shield, User } from "lucide-react";
import { toast } from "sonner";
import { T, translate } from "@/i18n";
interface AclEditorProps {
resourceType?: string;
mount: string;
path: string;
compact?: boolean;
}
type GrantTab = "user" | "group";
const STORAGE_PERMISSIONS = ['read', 'list', 'write', 'delete'] as const;
export function AclEditor({ resourceType = "vfs", mount, path, compact = false }: AclEditorProps) {
const loadEntries = useCallback(async () => {
const settings = await fetchAclSettings(resourceType, mount);
const normPath = (p: string) => p.replace(/^\/+/, "") || "/";
const targetPath = normPath(path);
// AclEditor takes ownership of filtering the entries to the current path
return (settings.acl || []).filter(e => normPath(e.path || "") === targetPath);
}, [resourceType, mount, path]);
const grant = useCallback(async (input: Omit<AclGrantInput, "path">) => {
await grantAclPermission(resourceType, mount, { ...input, path });
}, [resourceType, mount, path]);
const revoke = useCallback(async (input: Omit<AclRevokeInput, "path">) => {
await revokeAclPermission(resourceType, mount, { ...input, path });
}, [resourceType, mount, path]);
const renderGrantSection = useCallback((ctx: GrantSectionContext) => (
<StorageGrantSection ctx={ctx} path={path} />
), [path]);
return (
<ACLBaseEditor
compact={compact}
resourceDescription={<code>{mount}:{path}</code>}
resourceContextNote={<>(Mount: {mount})</>}
loadEntries={loadEntries}
grant={grant}
revoke={revoke}
fetchProfiles={fetchProfilesByUserIds}
renderGrantSection={renderGrantSection}
/>
);
}
/** Full grant section: anon/auth toggles + user/group picker. */
function StorageGrantSection({ ctx, path }: { ctx: GrantSectionContext; path: string }) {
const { grant, refresh } = ctx;
// User / Group grant
const [tab, setTab] = useState<GrantTab>("user");
const [selectedUser, setSelectedUser] = useState("");
const [userPerms, setUserPerms] = useState<string[]>(["read", "list"]);
const [selectedGroup, setSelectedGroup] = useState("");
const [groupPerms, setGroupPerms] = useState<string[]>(["read", "list"]);
const [granting, setGranting] = useState(false);
const handleGrant = async () => {
const isUser = tab === "user";
const subject = isUser ? selectedUser : selectedGroup;
const perms = isUser ? userPerms : groupPerms;
if (!subject || perms.length === 0) return;
setGranting(true);
try {
await grant({ ...(isUser ? { userId: subject } : { group: subject }), permissions: perms });
toast.success(isUser ? translate("User access granted") : translate("Group access granted"));
refresh();
if (isUser) setSelectedUser(""); else setSelectedGroup("");
} catch (e: any) { toast.error(e.message); }
finally { setGranting(false); }
};
const canSubmit = tab === "user" ? !!selectedUser && userPerms.length > 0 : !!selectedGroup && groupPerms.length > 0;
return (
<div className="space-y-3">
{/* User / Group grant */}
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"><T>Grant Access</T></h3>
<div className="flex rounded-md border overflow-hidden text-xs">
<button
className={`flex items-center gap-1.5 px-3 py-1 transition-colors ${tab === "user" ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
onClick={() => setTab("user")}
>
<User className="h-3 w-3" /><T>User</T>
</button>
<button
className={`flex items-center gap-1.5 px-3 py-1 transition-colors ${tab === "group" ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
onClick={() => setTab("group")}
>
<Shield className="h-3 w-3" /><T>Group</T>
</button>
</div>
</div>
<div className="flex gap-2">
<div className="flex-1">
{tab === "user"
? <UserPicker value={selectedUser} onSelect={setSelectedUser} />
: <GroupPicker value={selectedGroup} onSelect={(name) => setSelectedGroup(name)} />}
</div>
<Button onClick={handleGrant} disabled={!canSubmit || granting}>
{granting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4 mr-2" />}
<T>Grant</T>
</Button>
</div>
{tab === "user"
? <PermissionPicker value={userPerms} onChange={setUserPerms} availablePermissions={STORAGE_PERMISSIONS} />
: <PermissionPicker value={groupPerms} onChange={setGroupPerms} availablePermissions={STORAGE_PERMISSIONS} />}
</div>
</div>
);
}