iam / acl - groups 2/2

This commit is contained in:
lovebird 2026-04-01 21:21:02 +02:00
parent ecf2cb1836
commit 6e60dfc9fb
10 changed files with 1603 additions and 106 deletions

View File

@ -0,0 +1,211 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { UserPicker } from "./UserPicker";
import { putAclGroup, evaluateAcl } from "@/modules/user/client-acl";
import { T, translate } from "@/i18n";
import { toast } from "sonner";
import { Shield, ShieldCheck, Users, Trash2, Loader2 } from "lucide-react";
export function AclPlayground() {
const [activeTab, setActiveTab] = useState("groups");
// Group Management State
const [groupType, setGroupType] = useState("vfs");
const [groupMount, setGroupMount] = useState("docs");
const [groupName, setGroupName] = useState("");
const [groupMembers, setGroupMembers] = useState<string[]>([]);
const [memberInput, setMemberInput] = useState("");
const [savingGroup, setSavingGroup] = useState(false);
// Evaluation State
const [evalType, setEvalType] = useState("vfs");
const [evalMount, setEvalMount] = useState("docs");
const [evalPath, setEvalPath] = useState("/");
const [evalUserId, setEvalUserId] = useState("");
const [evaluating, setEvaluating] = useState(false);
const [evalResult, setEvalResult] = useState<string[] | null>(null);
// Group Handlers
const handleAddMember = (id: string) => {
if (!id) return;
if (!groupMembers.includes(id)) {
setGroupMembers([...groupMembers, id]);
}
setMemberInput("");
};
const handleRemoveMember = (id: string) => {
setGroupMembers(groupMembers.filter((m) => m !== id));
};
const handleSaveGroup = async () => {
if (!groupName.trim()) {
toast.error(translate("Group name is required"));
return;
}
setSavingGroup(true);
try {
await putAclGroup(groupType, groupMount, {
name: groupName.trim(),
members: groupMembers,
});
toast.success(translate("Group saved successfully"));
} catch (err: any) {
toast.error(err.message || translate("Failed to save group"));
} finally {
setSavingGroup(false);
}
};
// Evaluation Handlers
const handleEvaluate = async () => {
if (!evalUserId) {
toast.error(translate("User is required for evaluation"));
return;
}
setEvaluating(true);
setEvalResult(null);
try {
const res = await evaluateAcl(evalType, evalMount, evalUserId, evalPath || "/");
setEvalResult(res.permissions || []);
} catch (err: any) {
toast.error(err.message || translate("Failed to evaluate permissions"));
} finally {
setEvaluating(false);
}
};
return (
<Card className="w-full max-w-4xl shadow-sm">
<CardHeader className="bg-muted/30 border-b">
<CardTitle className="text-xl flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<T>ACL Playground</T>
</CardTitle>
<CardDescription>
<T>Test and configure advanced Access Control settings and groups.</T>
</CardDescription>
</CardHeader>
<CardContent className="p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-6 grid w-[400px] grid-cols-2">
<TabsTrigger value="groups" className="flex items-center gap-2">
<Users className="h-4 w-4" />
<T>Groups</T>
</TabsTrigger>
<TabsTrigger value="evaluate" className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
<T>Evaluation</T>
</TabsTrigger>
</TabsList>
{/* GROUPS TAB */}
<TabsContent value="groups" className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label><T>Resource Type</T></Label>
<Input value={groupType} onChange={(e) => setGroupType(e.target.value)} placeholder="vfs, category, etc." />
</div>
<div className="space-y-2">
<Label><T>Resource ID / Mount</T></Label>
<Input value={groupMount} onChange={(e) => setGroupMount(e.target.value)} placeholder="docs, media, etc." />
</div>
</div>
<div className="space-y-2">
<Label><T>Group Name</T></Label>
<Input value={groupName} onChange={(e) => setGroupName(e.target.value)} placeholder="e.g. editors" />
</div>
<div className="p-4 border rounded-md space-y-4 bg-muted/10">
<h3 className="text-sm font-medium"><T>Group Members</T></h3>
<div className="flex flex-wrap gap-2 min-h-10">
{groupMembers.length === 0 ? (
<span className="text-sm text-muted-foreground"><T>No members selected.</T></span>
) : (
groupMembers.map((id) => (
<Badge key={id} variant="secondary" className="px-2 py-1 text-sm flex items-center gap-2">
{id.slice(0, 8)}...
<Trash2
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => handleRemoveMember(id)}
/>
</Badge>
))
)}
</div>
<div className="flex gap-2 isolate pt-2">
<div className="flex-1">
<UserPicker
value={memberInput}
onSelect={(id) => handleAddMember(id)}
/>
</div>
</div>
</div>
<Button onClick={handleSaveGroup} disabled={savingGroup} className="w-full sm:w-auto">
{savingGroup ? <Loader2 className="animate-spin h-4 w-4 mr-2" /> : <Shield className="h-4 w-4 mr-2" />}
<T>Save Group</T>
</Button>
</TabsContent>
{/* EVALUATE TAB */}
<TabsContent value="evaluate" className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label><T>Resource Type</T></Label>
<Input value={evalType} onChange={(e) => setEvalType(e.target.value)} placeholder="vfs, category, etc." />
</div>
<div className="space-y-2">
<Label><T>Resource ID / Mount</T></Label>
<Input value={evalMount} onChange={(e) => setEvalMount(e.target.value)} placeholder="docs, media, etc." />
</div>
</div>
<div className="space-y-2">
<Label><T>Target Path</T></Label>
<Input value={evalPath} onChange={(e) => setEvalPath(e.target.value)} placeholder="/some/path" />
<p className="text-xs text-muted-foreground"><T>Optional path string to test hierarchical evaluation.</T></p>
</div>
<div className="space-y-2 isolate">
<Label><T>User</T></Label>
<UserPicker
value={evalUserId}
onSelect={(id) => setEvalUserId(id)}
/>
</div>
<Button onClick={handleEvaluate} disabled={evaluating} className="w-full sm:w-auto">
{evaluating ? <Loader2 className="animate-spin h-4 w-4 mr-2" /> : <ShieldCheck className="h-4 w-4 mr-2" />}
<T>Evaluate Permissions</T>
</Button>
{evalResult !== null && (
<div className="p-4 border rounded-md mt-6 bg-accent/20">
<h3 className="font-semibold text-sm mb-3"><T>Effective Permissions</T></h3>
{evalResult.length === 0 ? (
<p className="text-sm text-destructive"><T>User has NO permissions for this resource/path.</T></p>
) : (
<div className="flex flex-wrap gap-2">
{evalResult.map((p) => (
<Badge key={p} variant="default" className="bg-green-600 hover:bg-green-500">
{p}
</Badge>
))}
</div>
)}
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View File

@ -10,12 +10,12 @@ import {
useSidebar
} from "@/components/ui/sidebar";
import { T, translate } from "@/i18n";
import { LayoutDashboard, Users, Server, Shield, AlertTriangle, ChartBar, Settings, Database, Video } from "lucide-react";
import { LayoutDashboard, Users, Server, Shield, AlertTriangle, ChartBar, Settings, Database, Video, Package } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { useLocation, useNavigate } from "react-router-dom";
export type AdminActiveSection = 'dashboard' | 'users' | 'server' | 'bans' | 'violations' | 'analytics' | 'storage' | 'videos';
export type AdminActiveSection = 'dashboard' | 'users' | 'server' | 'bans' | 'violations' | 'analytics' | 'storage' | 'videos' | 'products' | 'acl';
const menuItems = [
{ id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard },
@ -26,6 +26,8 @@ const menuItems = [
{ id: 'analytics' as AdminActiveSection, label: translate('Analytics'), icon: ChartBar },
{ id: 'storage' as AdminActiveSection, label: translate('Storage'), icon: Database },
{ id: 'videos' as AdminActiveSection, label: translate('Videos'), icon: Video },
{ id: 'products' as AdminActiveSection, label: translate('Products'), icon: Package },
{ id: 'acl' as AdminActiveSection, label: translate('ACL'), icon: Shield },
];
export const AdminSidebar = () => {

View File

@ -9,7 +9,12 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Save } from 'lucide-react';
import { Save, Shield, Check, Loader2 } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchGlobalGroups, fetchUserGroups, addGroupMember, removeGroupMember } from '@/modules/user/client-acl';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
type Profile = Tables<'profiles'>;
@ -22,8 +27,22 @@ interface EditUserDialogProps {
export const EditUserDialog = ({ user, open, onOpenChange, onUserUpdate }: EditUserDialogProps) => {
const [profile, setProfile] = useState<Profile | null>(null);
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [secrets, setSecrets] = useState<Record<string, { masked: string | null; has_key: boolean }>>({});
const [newSecrets, setNewSecrets] = useState<Record<string, string>>({});
const [updating, setUpdating] = useState(false);
const queryClient = useQueryClient();
const { data: allGroups = [] } = useQuery({
queryKey: ['global-acl-groups'],
queryFn: () => fetchGlobalGroups(),
enabled: open
});
const { data: userGroups = [] } = useQuery({
queryKey: ['user-groups', user?.user_id],
queryFn: () => user ? fetchUserGroups(user.user_id) : Promise.resolve([]),
enabled: open && !!user
});
useEffect(() => {
if (user) {
@ -49,8 +68,8 @@ export const EditUserDialog = ({ user, open, onOpenChange, onUserUpdate }: EditU
});
// 2. Update secrets
if (profile.user_id) {
await updateUserSecrets(profile.user_id, secrets);
if (profile.user_id && Object.keys(newSecrets).length > 0) {
await updateUserSecrets(profile.user_id, newSecrets);
}
toast.success(translate('User profile updated successfully'));
@ -73,9 +92,10 @@ export const EditUserDialog = ({ user, open, onOpenChange, onUserUpdate }: EditU
<DialogTitle>Edit User: {profile.display_name || profile.username}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="api-keys">API Keys</TabsTrigger>
<TabsList className="bg-muted/50 p-1 rounded-lg">
<TabsTrigger value="general" className="rounded-md"><T>General</T></TabsTrigger>
<TabsTrigger value="groups" className="rounded-md"><T>Groups</T></TabsTrigger>
<TabsTrigger value="api-keys" className="rounded-md"><T>API Keys</T></TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 py-4">
<div className="space-y-2">
@ -104,12 +124,58 @@ export const EditUserDialog = ({ user, open, onOpenChange, onUserUpdate }: EditU
</div>
</TabsContent>
<TabsContent value="api-keys" className="space-y-4 py-4">
<ApiKeyInput id="google_api_key" label="Google API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="openai_api_key" label="OpenAI API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="replicate_api_key" label="Replicate API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="bria_api_key" label="Bria API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="huggingface_api_key" label="HuggingFace API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="aimlapi_api_key" label="AIMLAPI API Key" secrets={secrets} setSecrets={setSecrets} />
<ApiKeyInput id="google_api_key" label="Google API Key" secret={secrets.google_api_key} newValue={newSecrets.google_api_key} onChange={(val) => setNewSecrets({ ...newSecrets, google_api_key: val })} />
<ApiKeyInput id="openai_api_key" label="OpenAI API Key" secret={secrets.openai_api_key} newValue={newSecrets.openai_api_key} onChange={(val) => setNewSecrets({ ...newSecrets, openai_api_key: val })} />
<ApiKeyInput id="replicate_api_key" label="Replicate API Key" secret={secrets.replicate_api_key} newValue={newSecrets.replicate_api_key} onChange={(val) => setNewSecrets({ ...newSecrets, replicate_api_key: val })} />
<ApiKeyInput id="bria_api_key" label="Bria API Key" secret={secrets.bria_api_key} newValue={newSecrets.bria_api_key} onChange={(val) => setNewSecrets({ ...newSecrets, bria_api_key: val })} />
<ApiKeyInput id="huggingface_api_key" label="HuggingFace API Key" secret={secrets.huggingface_api_key} newValue={newSecrets.huggingface_api_key} onChange={(val) => setNewSecrets({ ...newSecrets, huggingface_api_key: val })} />
<ApiKeyInput id="aimlapi_api_key" label="AIMLAPI API Key" secret={secrets.aimlapi_api_key} newValue={newSecrets.aimlapi_api_key} onChange={(val) => setNewSecrets({ ...newSecrets, aimlapi_api_key: val })} />
</TabsContent>
<TabsContent value="groups" className="space-y-4 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-[300px] overflow-y-auto pr-2">
{allGroups.map(group => {
const isMember = userGroups.some(ug => ug.id === group.id);
return (
<div
key={group.id}
className={cn(
"flex items-center space-x-3 p-3 rounded-xl border transition-all cursor-pointer",
isMember ? "bg-primary/5 border-primary/30 shadow-sm" : "bg-card hover:bg-muted/50"
)}
onClick={async () => {
if (!profile?.user_id) return;
try {
if (isMember) {
await removeGroupMember(group.id, profile.user_id);
} else {
await addGroupMember(group.id, profile.user_id);
}
queryClient.invalidateQueries({ queryKey: ['user-groups', profile.user_id] });
queryClient.invalidateQueries({ queryKey: ['group-members', group.id] });
} catch (err: any) {
toast.error(err.message || "Failed to update group membership");
}
}}
>
<div className={cn(
"flex h-5 w-5 items-center justify-center rounded border",
isMember ? "bg-primary border-primary text-primary-foreground" : "border-input"
)}>
{isMember && <Check className="h-3 w-3" />}
</div>
<div className="flex-1">
<Label className="text-sm font-bold cursor-pointer">{group.name}</Label>
{group.native_type && <div className="text-[10px] text-muted-foreground uppercase">{group.native_type}</div>}
</div>
</div>
);
})}
</div>
{allGroups.length === 0 && (
<div className="text-center py-8 text-muted-foreground italic">
<T>No global groups defined.</T>
</div>
)}
</TabsContent>
</Tabs>
<Button onClick={handleUpdate} disabled={updating}>
@ -121,15 +187,36 @@ export const EditUserDialog = ({ user, open, onOpenChange, onUserUpdate }: EditU
);
};
const ApiKeyInput = ({ id, label, secrets, setSecrets }: { id: string; label: string; secrets: Record<string, string>; setSecrets: (s: Record<string, string>) => void; }) => (
const ApiKeyInput = ({ id, label, secret, newValue, onChange }: {
id: string;
label: string;
secret?: { masked: string | null; has_key: boolean };
newValue?: string;
onChange: (val: string) => void;
}) => (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type="password"
value={secrets[id] || ''}
onChange={(e) => setSecrets({ ...secrets, [id]: e.target.value })}
placeholder={`Enter ${label}`}
/>
<div className="flex justify-between items-center">
<Label htmlFor={id}>{label}</Label>
{secret?.has_key && (
<Badge variant="outline" className="text-[10px] h-4 py-0 bg-green-500/10 text-green-600 border-green-500/20">
<Check className="h-2 w-2 mr-1" /> <T>Configured</T>
</Badge>
)}
</div>
<div className="relative">
<Input
id={id}
type="password"
value={newValue || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={secret?.masked || `Enter ${label}`}
className={cn(newValue && "border-primary/50 ring-1 ring-primary/20")}
/>
{newValue && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-3 w-3 animate-spin text-primary opacity-50" />
</div>
)}
</div>
</div>
);

View File

@ -0,0 +1,400 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
fetchGlobalGroups,
putGlobalGroup,
deleteGlobalGroup,
fetchGroupMembers,
addGroupMember,
removeGroupMember,
GlobalAclGroup,
GroupMember
} from "@/modules/user/client-acl";
import { UserPicker } from "@/components/admin/UserPicker";
import { toast } from "sonner";
import {
Plus,
Edit2,
Trash2,
Users,
Shield,
X,
Loader2,
ChevronRight,
ChevronDown,
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";
import { Badge } from "@/components/ui/badge";
export const GroupManager = () => {
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [editingGroup, setEditingGroup] = useState<Partial<GlobalAclGroup> | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const queryClient = useQueryClient();
// Fetch Groups
const { data: groups = [], isLoading: loadingGroups } = useQuery({
queryKey: ['global-acl-groups'],
queryFn: () => fetchGlobalGroups()
});
// Fetch Members for selected group
const { data: members = [], isLoading: loadingMembers } = useQuery({
queryKey: ['group-members', selectedGroupId],
queryFn: () => selectedGroupId ? fetchGroupMembers(selectedGroupId) : Promise.resolve([]),
enabled: !!selectedGroupId
});
const handleCreateStart = (parentId?: string) => {
setIsCreating(true);
setEditingGroup({
name: "",
description: "",
parent_id: parentId,
native_type: "",
settings: {}
});
};
const handleEditStart = (group: GlobalAclGroup) => {
setIsCreating(false);
setEditingGroup({ ...group });
};
const handleSave = async () => {
if (!editingGroup?.name) {
toast.error(translate("Group name is required"));
return;
}
setActionLoading(true);
try {
await putGlobalGroup(editingGroup);
toast.success(isCreating ? translate("Group created") : translate("Group updated"));
setEditingGroup(null);
queryClient.invalidateQueries({ queryKey: ['global-acl-groups'] });
} catch (error: any) {
toast.error(error.message || translate("Failed to save group"));
} finally {
setActionLoading(false);
}
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(translate("Are you sure you want to delete this group?"))) return;
setActionLoading(true);
try {
await deleteGlobalGroup(id);
toast.success(translate("Group deleted"));
queryClient.invalidateQueries({ queryKey: ['global-acl-groups'] });
if (selectedGroupId === id) setSelectedGroupId(null);
} catch (error: any) {
toast.error(error.message || translate("Failed to delete group"));
} finally {
setActionLoading(false);
}
};
const handleAddMember = async (userId: string) => {
if (!selectedGroupId) return;
// Check if already a member to provide instant feedback
if (members.some(m => m.user_id === userId)) {
toast.info(translate("User is already a member of this group"));
return;
}
try {
await addGroupMember(selectedGroupId, userId);
toast.success(translate("Member added to group"));
queryClient.invalidateQueries({ queryKey: ['group-members', selectedGroupId] });
// Also invalidate user groups to update the UserManager table if visible
queryClient.invalidateQueries({ queryKey: ['user-groups', userId] });
queryClient.invalidateQueries({ queryKey: ['user-groups-effective', userId] });
} catch (error: any) {
console.error("Add member error:", error);
toast.error(error.message || translate("Failed to add member"));
}
};
const handleRemoveMember = async (userId: string) => {
if (!selectedGroupId) return;
try {
await removeGroupMember(selectedGroupId, userId);
toast.success(translate("Member removed from group"));
queryClient.invalidateQueries({ queryKey: ['group-members', selectedGroupId] });
queryClient.invalidateQueries({ queryKey: ['user-groups', userId] });
queryClient.invalidateQueries({ queryKey: ['user-groups-effective', userId] });
} catch (error: any) {
console.error("Remove member error:", error);
toast.error(error.message || translate("Failed to remove member"));
}
};
// Tree Rendering Logic
const renderGroupItem = (group: GlobalAclGroup, level: number = 0) => {
const isSelected = selectedGroupId === group.id;
const children = groups.filter(g => g.parent_id === group.id);
return (
<div key={group.id}>
<div
className={cn(
"flex items-center justify-between p-2 rounded hover:bg-muted/50 cursor-pointer group transition-all",
isSelected && "bg-primary/10 border-l-2 border-primary"
)}
style={{ marginLeft: `${level * 20}px` }}
onClick={() => setSelectedGroupId(group.id)}
>
<div className="flex items-center gap-2">
<Users className={cn("h-4 w-4", isSelected ? "text-primary" : "text-muted-foreground")} />
<span className={cn("text-sm", isSelected && "font-semibold")}>{group.name}</span>
{group.native_type && (
<Badge variant="outline" className="text-[10px] px-1 h-4">{group.native_type}</Badge>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleCreateStart(group.id); }}>
<Plus className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleEditStart(group); }}>
<Edit2 className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={(e) => handleDelete(group.id, e)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{children.map(child => renderGroupItem(child, level + 1))}
</div>
);
};
const selectedGroup = groups.find(g => g.id === selectedGroupId);
return (
<div className="flex flex-col md:flex-row gap-6 h-[calc(100vh-200px)]">
{/* Left Column: Group Tree */}
<div className="w-full md:w-1/3 flex flex-col border rounded-xl bg-card shadow-sm overflow-hidden">
<div className="p-4 border-b bg-muted/30 flex justify-between items-center">
<h3 className="font-bold text-sm flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<T>ACL Groups</T>
</h3>
<Button variant="outline" size="sm" onClick={() => handleCreateStart()}>
<Plus className="h-3 w-3 mr-1" />
<T>New Root</T>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{loadingGroups ? (
<div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin text-primary/30" /></div>
) : (
<>
{groups.filter(g => !g.parent_id).map(g => renderGroupItem(g))}
{groups.length === 0 && (
<div className="text-center py-12 text-muted-foreground italic">
<T>No groups defined.</T>
</div>
)}
</>
)}
</div>
</div>
{/* Right Column: Details & Members */}
<div className="flex-1 flex flex-col border rounded-xl bg-card shadow-sm overflow-hidden">
{editingGroup ? (
<div className="p-6 space-y-6 overflow-y-auto">
<div className="flex items-center justify-between border-b pb-4">
<h3 className="text-xl font-bold">
{isCreating ? translate("Create New Group") : translate("Edit Group")}
</h3>
<Button variant="ghost" size="icon" onClick={() => setEditingGroup(null)}>
<X className="h-5 w-5" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-xs font-bold uppercase text-muted-foreground"><T>Group Name</T></Label>
<Input
value={editingGroup.name}
onChange={e => setEditingGroup({...editingGroup, name: e.target.value})}
placeholder={translate("e.g. Content Managers")}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase text-muted-foreground"><T>Native Type (Optional)</T></Label>
<Input
value={editingGroup.native_type || ""}
onChange={e => setEditingGroup({...editingGroup, native_type: e.target.value})}
placeholder={translate("e.g. page, post")}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-bold uppercase text-muted-foreground"><T>Parent Group</T></Label>
<Select
value={editingGroup.parent_id || "none"}
onValueChange={val => setEditingGroup({...editingGroup, parent_id: val === "none" ? null : val})}
>
<SelectTrigger>
<SelectValue placeholder={translate("Select parent group")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<div className="flex items-center gap-2 italic text-muted-foreground">
<X className="h-3 w-3" />
<T>No Parent (Root)</T>
</div>
</SelectItem>
{groups
.filter(g => g.id !== editingGroup.id) // Prevent self-parenting
.map(g => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
))
}
</SelectContent>
</Select>
</div>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-bold uppercase text-muted-foreground"><T>Description</T></Label>
<Input
value={editingGroup.description || ""}
onChange={e => setEditingGroup({...editingGroup, description: e.target.value})}
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<Button variant="outline" onClick={() => setEditingGroup(null)}><T>Cancel</T></Button>
<Button onClick={handleSave} disabled={actionLoading}>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<T>Save Group</T>
</Button>
</div>
</div>
) : selectedGroup ? (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="p-6 border-b bg-muted/10">
<div className="flex items-center justify-between mb-2">
<h3 className="text-2xl font-black tracking-tight">{selectedGroup.name}</h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => handleEditStart(selectedGroup)}>
<Edit2 className="h-3 w-3 mr-1" />
<T>Edit</T>
</Button>
<Button variant="destructive" size="sm" onClick={(e) => handleDelete(selectedGroup.id, e as any)}>
<Trash2 className="h-3 w-3 mr-1" />
<T>Delete</T>
</Button>
</div>
</div>
<p className="text-muted-foreground text-sm">{selectedGroup.description || translate("No description.")}</p>
<div className="flex gap-4 mt-4 text-xs font-medium uppercase text-muted-foreground">
<div><T>ID</T>: <span className="font-mono text-foreground">{selectedGroup.id}</span></div>
{selectedGroup.native_type && <div><T>Type</T>: <span className="text-primary">{selectedGroup.native_type}</span></div>}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{/* Members Section */}
<section>
<h4 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-4 flex items-center gap-2">
<Users className="h-4 w-4" />
<T>Group Members</T>
<Badge variant="secondary" className="ml-2">{members.length}</Badge>
</h4>
<div className="space-y-4">
<div className="relative">
<UserPicker
onSelect={(userId) => handleAddMember(userId)}
disabled={actionLoading}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{members.map(member => (
<div key={member.user_id} className="flex items-center justify-between p-3 border rounded-xl bg-muted/20 hover:bg-muted/40 transition-colors group/member">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 border-2 border-background shadow-sm">
<AvatarImage src={member.profiles?.avatar_url} />
<AvatarFallback>{member.profiles?.display_name?.[0] || "?"}</AvatarFallback>
</Avatar>
<div>
<div className="text-sm font-bold">{member.profiles?.display_name || translate("Unknown User")}</div>
<div className="text-[10px] text-muted-foreground font-mono">{member.user_id.slice(0, 8)}...</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover/member:opacity-100 transition-opacity"
onClick={() => handleRemoveMember(member.user_id)}
>
<UserMinus className="h-4 w-4" />
</Button>
</div>
))}
{members.length === 0 && (
<div className="col-span-full py-8 text-center border-2 border-dashed rounded-xl text-muted-foreground">
<T>No members in this group.</T>
</div>
)}
</div>
</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>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-12 text-muted-foreground">
<Shield className="h-16 w-16 mb-4 opacity-10" />
<h3 className="text-lg font-bold mb-1"><T>No Group Selected</T></h3>
<p className="text-sm max-w-xs"><T>Select a group from the tree to manage its members, settings, and permissions.</T></p>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,335 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Plus, Package } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { fetchProducts, createProduct, updateProduct, deleteProduct, Product } from '@/modules/ecommerce/client-products';
const ProductsManager = () => {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
// Form states
const [formData, setFormData] = useState({
name: '',
slug: '',
description: '',
enabled: true,
default_cost_units: 0,
default_rate_limit: '',
default_rate_window: ''
});
useEffect(() => {
loadProducts();
}, []);
const loadProducts = async () => {
try {
setLoading(true);
const data = await fetchProducts();
setProducts(data || []);
} catch (error) {
console.error('Error fetching products:', error);
toast.error(translate('Failed to load products'));
} finally {
setLoading(false);
}
};
const handleCreateOpen = () => {
setFormData({
name: '',
slug: '',
description: '',
enabled: true,
default_cost_units: 0,
default_rate_limit: '',
default_rate_window: ''
});
setIsCreateOpen(true);
};
const handleEditOpen = (product: Product) => {
setFormData({
name: product.name,
slug: product.slug, // Can't typically change slug but we might allow or hide
description: product.description || '',
enabled: product.settings?.enabled ?? true,
default_cost_units: product.settings?.default_cost_units ?? 0,
default_rate_limit: product.settings?.default_rate_limit?.toString() ?? '',
default_rate_window: product.settings?.default_rate_window?.toString() ?? ''
});
setEditingProduct(product);
};
const handleSave = async () => {
try {
const settings = {
enabled: formData.enabled,
default_cost_units: Number(formData.default_cost_units),
...(formData.default_rate_limit ? { default_rate_limit: Number(formData.default_rate_limit) } : {}),
...(formData.default_rate_window ? { default_rate_window: Number(formData.default_rate_window) } : {})
};
if (editingProduct) {
await updateProduct(editingProduct.slug, {
name: formData.name,
description: formData.description,
settings
});
toast.success(translate('Product updated successfully'));
setIsCreateOpen(false);
setEditingProduct(null);
} else {
await createProduct({
name: formData.name,
slug: formData.slug || undefined,
description: formData.description,
settings
});
toast.success(translate('Product created successfully'));
setIsCreateOpen(false);
setEditingProduct(null);
}
loadProducts();
} catch (error) {
console.error('Error saving product:', error);
toast.error(translate('Failed to save product'));
}
};
const handleDelete = async () => {
if (!deletingProduct) return;
try {
const success = await deleteProduct(deletingProduct.slug);
if (success) {
toast.success(translate('Product deleted successfully'));
loadProducts();
} else {
toast.error(translate('Failed to delete product'));
}
} catch (error) {
console.error('Error deleting product:', error);
toast.error(translate('Failed to delete product'));
} finally {
setDeletingProduct(null);
}
};
if (loading) {
return <div><T>Loading products...</T></div>;
}
return (
<div>
<div className="flex justify-end mb-4">
<Button onClick={handleCreateOpen}>
<Plus className="mr-2 h-4 w-4" />
<T>Create Product</T>
</Button>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead><T>Name</T></TableHead>
<TableHead><T>Slug</T></TableHead>
<TableHead><T>Status</T></TableHead>
<TableHead><T>Cost Units</T></TableHead>
<TableHead className="text-right"><T>Actions</T></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map(product => (
<TableRow key={product.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="bg-muted p-2 rounded-md">
<Package className="h-4 w-4" />
</div>
<div>
<div className="font-medium">{product.name}</div>
<div className="text-sm text-muted-foreground">{product.description || <span className="opacity-50 italic">No description</span>}</div>
</div>
</div>
</TableCell>
<TableCell className="font-mono text-sm">
{product.slug}
</TableCell>
<TableCell>
{product.settings?.enabled ? (
<span className="text-green-600 dark:text-green-400 font-medium text-sm">Active</span>
) : (
<span className="text-muted-foreground font-medium text-sm">Disabled</span>
)}
</TableCell>
<TableCell>
{product.settings?.default_cost_units ?? 0}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEditOpen(product)}><T>Edit</T></DropdownMenuItem>
<DropdownMenuItem
className="text-red-500 hover:text-red-600 focus:text-red-600"
onClick={() => setDeletingProduct(product)}
>
<T>Delete</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{products.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
<T>No products found. Create one to get started.</T>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Editor Dialog */}
<Dialog open={isCreateOpen || !!editingProduct} onOpenChange={(open) => {
if (!open) {
setIsCreateOpen(false);
setEditingProduct(null);
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingProduct ? <T>Edit Product</T> : <T>Create Product</T>}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"><T>Name</T></Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. Premium Subscription"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="slug"><T>Slug</T> {editingProduct ? '(Read Only)' : '(Optional)'}</Label>
<Input
id="slug"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
disabled={!!editingProduct}
placeholder="Autogenerated if left empty"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description"><T>Description</T></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Brief description of the product"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="cost"><T>Default Cost Units</T></Label>
<Input
id="cost"
type="number"
value={formData.default_cost_units}
onChange={(e) => setFormData({ ...formData, default_cost_units: Number(e.target.value) })}
/>
</div>
<div className="flex items-center gap-2 mt-8">
<Switch
id="enabled"
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
<Label htmlFor="enabled"><T>Enabled</T></Label>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-2">
<div className="grid gap-2">
<Label htmlFor="rate_limit"><T>Rate Limit</T> <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Input
id="rate_limit"
type="number"
value={formData.default_rate_limit}
onChange={(e) => setFormData({ ...formData, default_rate_limit: e.target.value })}
placeholder="e.g. 100"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="rate_window"><T>Rate Window Time</T> <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Input
id="rate_window"
type="number"
value={formData.default_rate_window}
onChange={(e) => setFormData({ ...formData, default_rate_window: e.target.value })}
placeholder="Seconds e.g. 3600"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setIsCreateOpen(false);
setEditingProduct(null);
}}>
<T>Cancel</T>
</Button>
<Button onClick={handleSave} disabled={!formData.name}>
<T>Save</T>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<Dialog open={!!deletingProduct} onOpenChange={(open) => !open && setDeletingProduct(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle><T>Delete Product</T></DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-muted-foreground">
{translate("Are you sure you want to delete")} <span className="font-semibold text-foreground">{deletingProduct?.name}</span>?
{translate("This action cannot be undone and will disconnect any associated ACLs.")}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingProduct(null)}>
<T>Cancel</T>
</Button>
<Button variant="destructive" onClick={handleDelete}>
<T>Delete</T>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default ProductsManager;

View File

@ -4,7 +4,8 @@ import { T, translate } from '@/i18n';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { User, MoreHorizontal, Plus } from 'lucide-react';
import { User, Users, MoreHorizontal, Plus, Edit2, Trash2, ShieldAlert, ShieldCheck } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Tables } from '@/integrations/supabase/types';
import { Button } from '../ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu';
@ -12,6 +13,10 @@ import { EditUserDialog } from './EditUserDialog';
import { DeleteUserDialog } from './DeleteUserDialog';
import { CreateUserDialog } from './CreateUserDialog';
import { fetchAdminUsersAPI, deleteAdminUserAPI } from '@/modules/user/client-user';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { GroupManager } from './GroupManager';
import { fetchUserGroups, fetchUserEffectiveGroups } from '@/modules/user/client-acl';
import { cn } from '@/lib/utils';
type AdminUserView = Tables<'profiles'> & {
email?: string;
@ -65,88 +70,163 @@ const UserManager = () => {
}
return (
<div>
<div className="flex justify-end mb-4">
<Button onClick={() => setCreateUserOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
<T>Create User</T>
</Button>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead><T>User</T></TableHead>
<TableHead><T>Username</T></TableHead>
<TableHead><T>Joined Date</T></TableHead>
<TableHead className="text-right"><T>Actions</T></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={user.avatar_url ?? undefined} alt={user.display_name ?? ''} />
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{user.display_name || 'N/A'}</div>
<div className="text-sm text-muted-foreground">{user.email}</div>
</div>
</div>
</TableCell>
<TableCell>
{user.username ? (
<Badge variant="secondary">@{user.username}</Badge>
) : (
<span className="text-muted-foreground">N/A</span>
)}
</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setEditingUser(user)}><T>Edit</T></DropdownMenuItem>
<DropdownMenuItem
className="text-red-500"
onClick={() => setDeletingUser(user)}
>
<T>Delete</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<EditUserDialog
user={editingUser}
open={!!editingUser}
onOpenChange={() => setEditingUser(null)}
onUserUpdate={handleUserUpdate}
/>
<DeleteUserDialog
open={!!deletingUser}
onOpenChange={() => setDeletingUser(null)}
onConfirm={handleDeleteUser}
username={deletingUser?.display_name || deletingUser?.username || undefined}
/>
<CreateUserDialog
open={isCreateUserOpen}
onOpenChange={setCreateUserOpen}
onUserCreated={fetchUsers}
/>
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-2">
<div>
<h1 className="text-3xl font-black tracking-tight flex items-center gap-3">
<Users className="h-8 w-8 text-primary" />
<T>Identity & Access</T>
</h1>
<p className="text-muted-foreground text-sm">
<T>Manage users, global resource groups, and hierarchical permissions.</T>
</p>
</div>
</div>
<Tabs defaultValue="users" className="w-full">
<TabsList className="grid w-full grid-cols-2 max-w-[400px] mb-8 bg-muted/50 p-1">
<TabsTrigger value="users" className="data-[state=active]:bg-background data-[state=active]:shadow-sm">
<Users className="mr-2 h-4 w-4" />
<T>Users</T>
</TabsTrigger>
<TabsTrigger value="groups" className="data-[state=active]:bg-background data-[state=active]:shadow-sm">
<ShieldCheck className="mr-2 h-4 w-4" />
<T>Global Groups</T>
</TabsTrigger>
</TabsList>
<TabsContent value="users" className="space-y-4 focus-visible:outline-none">
<div className="flex justify-end mb-4">
<Button onClick={() => setCreateUserOpen(true)} className="rounded-full shadow-lg hover:shadow-xl transition-all">
<Plus className="mr-2 h-4 w-4" />
<T>Create User</T>
</Button>
</div>
<div className="border rounded-2xl bg-card shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="font-bold"><T>User</T></TableHead>
<TableHead className="font-bold"><T>Username</T></TableHead>
<TableHead className="font-bold"><T>Groups</T></TableHead>
<TableHead className="font-bold text-right"><T>Actions</T></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow key={user.id} className="hover:bg-muted/20 transition-colors">
<TableCell className="py-4">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 border-2 border-background shadow-sm">
<AvatarImage src={user.avatar_url ?? undefined} alt={user.display_name ?? ''} />
<AvatarFallback><User className="h-5 w-5" /></AvatarFallback>
</Avatar>
<div>
<div className="font-bold text-sm leading-tight text-foreground">{user.display_name || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{user.email}</div>
</div>
</div>
</TableCell>
<TableCell>
{user.username ? (
<Badge variant="secondary" className="font-mono text-[11px]">@{user.username}</Badge>
) : (
<span className="text-muted-foreground text-xs italic">N/A</span>
)}
</TableCell>
<TableCell>
<UserGroupsList userId={user.user_id} />
</TableCell>
<TableCell className="text-right py-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-muted rounded-full">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 rounded-xl shadow-xl">
<DropdownMenuItem onClick={() => setEditingUser(user)} className="cursor-pointer">
<Edit2 className="mr-2 h-3.5 w-3.5" />
<T>Edit Profile</T>
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-500 cursor-pointer focus:bg-red-50 focus:text-red-600 dark:focus:bg-red-950/20"
onClick={() => setDeletingUser(user)}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
<T>Delete</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="groups" className="focus-visible:outline-none">
<GroupManager />
</TabsContent>
</Tabs>
<EditUserDialog
user={editingUser}
open={!!editingUser}
onOpenChange={() => setEditingUser(null)}
onUserUpdate={handleUserUpdate}
/>
<DeleteUserDialog
open={!!deletingUser}
onOpenChange={() => setDeletingUser(null)}
onConfirm={handleDeleteUser}
username={deletingUser?.display_name || deletingUser?.username || undefined}
/>
<CreateUserDialog
open={isCreateUserOpen}
onOpenChange={setCreateUserOpen}
onUserCreated={fetchUsers}
/>
</div>
);
};
const UserGroupsList = ({ userId }: { userId: string }) => {
const { data: directGroups = [] } = useQuery({
queryKey: ['user-groups', userId],
queryFn: () => fetchUserGroups(userId)
});
const { data: effectiveGroups = [], isLoading } = useQuery({
queryKey: ['user-groups-effective', userId],
queryFn: () => fetchUserEffectiveGroups(userId)
});
if (isLoading) return <div className="animate-pulse h-4 w-20 bg-muted rounded"></div>;
if (effectiveGroups.length === 0) return <span className="text-[10px] text-muted-foreground italic"><T>No groups</T></span>;
return (
<div className="flex flex-wrap gap-1 max-w-[200px]">
{effectiveGroups.map(g => {
const isDirect = directGroups.some(dg => dg.id === g.id);
return (
<Badge
key={g.id}
variant={isDirect ? "default" : "outline"}
className={cn(
"text-[10px] px-1.5 h-5 transition-all",
isDirect
? "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20"
: "text-muted-foreground border-dashed opacity-70 hover:opacity-100"
)}
title={isDirect ? "Direct Member" : "Inherited Member"}
>
{g.name}
</Badge>
);
})}
</div>
);
};

View File

@ -39,6 +39,79 @@ export type Database = {
}
public: {
Tables: {
acl_group_members: {
Row: {
created_at: string | null
created_by: string | null
group_id: string
user_id: string
}
Insert: {
created_at?: string | null
created_by?: string | null
group_id: string
user_id: string
}
Update: {
created_at?: string | null
created_by?: string | null
group_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "acl_group_members_group_id_fkey"
columns: ["group_id"]
isOneToOne: false
referencedRelation: "acl_groups"
referencedColumns: ["id"]
},
]
}
acl_groups: {
Row: {
created_at: string | null
created_by: string | null
description: string | null
id: string
modified_at: string | null
name: string
native_type: string | null
parent_id: string | null
settings: Json | null
}
Insert: {
created_at?: string | null
created_by?: string | null
description?: string | null
id?: string
modified_at?: string | null
name: string
native_type?: string | null
parent_id?: string | null
settings?: Json | null
}
Update: {
created_at?: string | null
created_by?: string | null
description?: string | null
id?: string
modified_at?: string | null
name?: string
native_type?: string | null
parent_id?: string | null
settings?: Json | null
}
Relationships: [
{
foreignKeyName: "acl_groups_parent_id_fkey"
columns: ["parent_id"]
isOneToOne: false
referencedRelation: "acl_groups"
referencedColumns: ["id"]
},
]
}
campaigns: {
Row: {
completed_at: string | null
@ -672,6 +745,7 @@ export type Database = {
request: Json
result: Json | null
run_id: string
settings: Json | null
status: string
updated_at: string
user_id: string
@ -683,6 +757,7 @@ export type Database = {
request: Json
result?: Json | null
run_id: string
settings?: Json | null
status?: string
updated_at?: string
user_id: string
@ -694,6 +769,7 @@ export type Database = {
request?: Json
result?: Json | null
run_id?: string
settings?: Json | null
status?: string
updated_at?: string
user_id?: string
@ -1284,6 +1360,36 @@ export type Database = {
}
Relationships: []
}
products: {
Row: {
created_at: string
description: string | null
id: string
name: string
settings: Json | null
slug: string
updated_at: string
}
Insert: {
created_at?: string
description?: string | null
id?: string
name: string
settings?: Json | null
slug: string
updated_at?: string
}
Update: {
created_at?: string
description?: string | null
id?: string
name?: string
settings?: Json | null
slug?: string
updated_at?: string
}
Relationships: []
}
profiles: {
Row: {
aimlapi_api_key: string | null

View File

@ -0,0 +1,78 @@
import { fetchWithDeduplication, apiClient } from "@/lib/db";
// --- Types ---
export interface ProductSettings {
enabled?: boolean;
default_cost_units?: number;
default_rate_limit?: number;
default_rate_window?: number;
[key: string]: any;
}
export interface Product {
id: string;
name: string;
slug: string;
description?: string | null;
settings: ProductSettings;
created_at: string;
updated_at: string;
}
export interface ProductCreatePayload {
name: string;
slug?: string;
description?: string;
settings?: ProductSettings;
}
export interface ProductUpdatePayload {
name?: string;
description?: string;
settings?: Partial<ProductSettings>;
}
// --- API Methods ---
/**
* Fetch all products (Admin only)
*/
export const fetchProducts = async (): Promise<Product[]> => {
const res = await apiClient<Product[]>('/api/admin/products');
return res || [];
};
/**
* Create a new product (Admin only)
*/
export const createProduct = async (payload: ProductCreatePayload): Promise<Product> => {
return apiClient<Product>('/api/admin/products', {
method: 'POST',
body: JSON.stringify(payload)
});
};
/**
* Update an existing product (Admin only)
*/
export const updateProduct = async (slug: string, payload: ProductUpdatePayload): Promise<Product> => {
return apiClient<Product>(`/api/admin/products/${encodeURIComponent(slug)}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
};
/**
* Delete a product (Admin only)
*/
export const deleteProduct = async (slug: string): Promise<boolean> => {
try {
await apiClient<any>(`/api/admin/products/${encodeURIComponent(slug)}`, {
method: 'DELETE'
});
return true;
} catch (e) {
console.error(`Failed to delete product ${slug}:`, e);
return false;
}
};

View File

@ -25,6 +25,30 @@ export interface AclSettings {
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
// =============================================
@ -130,3 +154,159 @@ export const revokeAclPermission = async (
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();
};

View File

@ -2,6 +2,8 @@ import { useState } from "react";
import UserManager from "@/components/admin/UserManager";
import StorageManager from "@/components/admin/StorageManager";
import VideoManager from "@/components/admin/VideoManager";
import ProductsManager from "@/components/admin/ProductsManager";
import { AclPlayground } from "@/components/admin/AclPlayground";
import { useAuth } from "@/hooks/useAuth";
import { T, translate } from "@/i18n";
import { SidebarProvider } from "@/components/ui/sidebar";
@ -54,6 +56,8 @@ const AdminPage = () => {
} />
<Route path="storage" element={<StorageManager />} />
<Route path="videos" element={<VideoManager />} />
<Route path="products" element={<ProductsManagerSection />} />
<Route path="acl" element={<AclPlaygroundSection />} />
</Routes>
</div>
</main>
@ -69,6 +73,20 @@ const UserManagerSection = () => (
</div>
);
const ProductsManagerSection = () => (
<div>
<h1 className="text-2xl font-bold mb-4"><T>Products Management</T></h1>
<ProductsManager />
</div>
);
const AclPlaygroundSection = () => (
<div>
<h1 className="text-2xl font-bold mb-4"><T>ACL Playground</T></h1>
<AclPlayground />
</div>
);
const DashboardSection = () => (
<div>
<h1 className="text-2xl font-bold mb-4"><T>Dashboard</T></h1>