iam / acl - groups 2/2
This commit is contained in:
parent
ecf2cb1836
commit
6e60dfc9fb
211
packages/ui/src/components/admin/AclPlayground.tsx
Normal file
211
packages/ui/src/components/admin/AclPlayground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
400
packages/ui/src/components/admin/GroupManager.tsx
Normal file
400
packages/ui/src/components/admin/GroupManager.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
335
packages/ui/src/components/admin/ProductsManager.tsx
Normal file
335
packages/ui/src/components/admin/ProductsManager.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
78
packages/ui/src/modules/ecommerce/client-products.ts
Normal file
78
packages/ui/src/modules/ecommerce/client-products.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user