From 6e60dfc9fbb1279e190b2f427cbfd28b56c31dcc Mon Sep 17 00:00:00 2001 From: Babayaga Date: Wed, 1 Apr 2026 21:21:02 +0200 Subject: [PATCH] iam / acl - groups 2/2 --- .../ui/src/components/admin/AclPlayground.tsx | 211 +++++++++ .../ui/src/components/admin/AdminSidebar.tsx | 6 +- .../src/components/admin/EditUserDialog.tsx | 131 +++++- .../ui/src/components/admin/GroupManager.tsx | 400 ++++++++++++++++++ .../src/components/admin/ProductsManager.tsx | 335 +++++++++++++++ .../ui/src/components/admin/UserManager.tsx | 244 +++++++---- .../ui/src/integrations/supabase/types.ts | 106 +++++ .../src/modules/ecommerce/client-products.ts | 78 ++++ packages/ui/src/modules/user/client-acl.ts | 180 ++++++++ packages/ui/src/pages/AdminPage.tsx | 18 + 10 files changed, 1603 insertions(+), 106 deletions(-) create mode 100644 packages/ui/src/components/admin/AclPlayground.tsx create mode 100644 packages/ui/src/components/admin/GroupManager.tsx create mode 100644 packages/ui/src/components/admin/ProductsManager.tsx create mode 100644 packages/ui/src/modules/ecommerce/client-products.ts diff --git a/packages/ui/src/components/admin/AclPlayground.tsx b/packages/ui/src/components/admin/AclPlayground.tsx new file mode 100644 index 00000000..52a7d30d --- /dev/null +++ b/packages/ui/src/components/admin/AclPlayground.tsx @@ -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([]); + 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(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 ( + + + + + ACL Playground + + + Test and configure advanced Access Control settings and groups. + + + + + + + + Groups + + + + Evaluation + + + + {/* GROUPS TAB */} + +
+
+ + setGroupType(e.target.value)} placeholder="vfs, category, etc." /> +
+
+ + setGroupMount(e.target.value)} placeholder="docs, media, etc." /> +
+
+ +
+ + setGroupName(e.target.value)} placeholder="e.g. editors" /> +
+ +
+

Group Members

+
+ {groupMembers.length === 0 ? ( + No members selected. + ) : ( + groupMembers.map((id) => ( + + {id.slice(0, 8)}... + handleRemoveMember(id)} + /> + + )) + )} +
+
+
+ handleAddMember(id)} + /> +
+
+
+ + +
+ + {/* EVALUATE TAB */} + +
+
+ + setEvalType(e.target.value)} placeholder="vfs, category, etc." /> +
+
+ + setEvalMount(e.target.value)} placeholder="docs, media, etc." /> +
+
+ +
+ + setEvalPath(e.target.value)} placeholder="/some/path" /> +

Optional path string to test hierarchical evaluation.

+
+ +
+ + setEvalUserId(id)} + /> +
+ + + + {evalResult !== null && ( +
+

Effective Permissions

+ {evalResult.length === 0 ? ( +

User has NO permissions for this resource/path.

+ ) : ( +
+ {evalResult.map((p) => ( + + {p} + + ))} +
+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/packages/ui/src/components/admin/AdminSidebar.tsx b/packages/ui/src/components/admin/AdminSidebar.tsx index 9ffd9554..d4ec6839 100644 --- a/packages/ui/src/components/admin/AdminSidebar.tsx +++ b/packages/ui/src/components/admin/AdminSidebar.tsx @@ -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 = () => { diff --git a/packages/ui/src/components/admin/EditUserDialog.tsx b/packages/ui/src/components/admin/EditUserDialog.tsx index fb0ff0be..edbca0eb 100644 --- a/packages/ui/src/components/admin/EditUserDialog.tsx +++ b/packages/ui/src/components/admin/EditUserDialog.tsx @@ -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(null); - const [secrets, setSecrets] = useState>({}); + const [secrets, setSecrets] = useState>({}); + const [newSecrets, setNewSecrets] = useState>({}); 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 Edit User: {profile.display_name || profile.username} - - General - API Keys + + General + Groups + API Keys
@@ -104,12 +124,58 @@ export const EditUserDialog = ({ user, open, onOpenChange, onUserUpdate }: EditU
- - - - - - + setNewSecrets({ ...newSecrets, google_api_key: val })} /> + setNewSecrets({ ...newSecrets, openai_api_key: val })} /> + setNewSecrets({ ...newSecrets, replicate_api_key: val })} /> + setNewSecrets({ ...newSecrets, bria_api_key: val })} /> + setNewSecrets({ ...newSecrets, huggingface_api_key: val })} /> + setNewSecrets({ ...newSecrets, aimlapi_api_key: val })} /> + + +
+ {allGroups.map(group => { + const isMember = userGroups.some(ug => ug.id === group.id); + return ( +
{ + 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"); + } + }} + > +
+ {isMember && } +
+
+ + {group.native_type &&
{group.native_type}
} +
+
+ ); + })} +
+ {allGroups.length === 0 && ( +
+ No global groups defined. +
+ )}
+ + + + + {children.map(child => renderGroupItem(child, level + 1))} + + ); + }; + + const selectedGroup = groups.find(g => g.id === selectedGroupId); + + return ( +
+ {/* Left Column: Group Tree */} +
+
+

+ + ACL Groups +

+ +
+
+ {loadingGroups ? ( +
+ ) : ( + <> + {groups.filter(g => !g.parent_id).map(g => renderGroupItem(g))} + {groups.length === 0 && ( +
+ No groups defined. +
+ )} + + )} +
+
+ + {/* Right Column: Details & Members */} +
+ {editingGroup ? ( +
+
+

+ {isCreating ? translate("Create New Group") : translate("Edit Group")} +

+ +
+ +
+
+ + setEditingGroup({...editingGroup, name: e.target.value})} + placeholder={translate("e.g. Content Managers")} + /> +
+
+ + setEditingGroup({...editingGroup, native_type: e.target.value})} + placeholder={translate("e.g. page, post")} + /> +
+
+ + +
+
+ + setEditingGroup({...editingGroup, description: e.target.value})} + /> +
+
+ +
+ + +
+
+ ) : selectedGroup ? ( +
+
+
+

{selectedGroup.name}

+
+ + +
+
+

{selectedGroup.description || translate("No description.")}

+
+
ID: {selectedGroup.id}
+ {selectedGroup.native_type &&
Type: {selectedGroup.native_type}
} +
+
+ +
+ {/* Members Section */} +
+

+ + Group Members + {members.length} +

+ +
+
+ handleAddMember(userId)} + disabled={actionLoading} + /> +
+ +
+ {members.map(member => ( +
+
+ + + {member.profiles?.display_name?.[0] || "?"} + +
+
{member.profiles?.display_name || translate("Unknown User")}
+
{member.user_id.slice(0, 8)}...
+
+
+ +
+ ))} + {members.length === 0 && ( +
+ No members in this group. +
+ )} +
+
+
+ + {/* Permissions Section */} + + + Permissions & ACLs +
+ } + initiallyOpen={false} + minimal + className="bg-muted/5 rounded-xl border p-2" + > +
+
+ These permissions apply to resources where this group is explicitly granted access. +
+ +
+ +
+
+ ) : ( +
+ +

No Group Selected

+

Select a group from the tree to manage its members, settings, and permissions.

+
+ )} +
+ + ); +}; diff --git a/packages/ui/src/components/admin/ProductsManager.tsx b/packages/ui/src/components/admin/ProductsManager.tsx new file mode 100644 index 00000000..4e6d04a2 --- /dev/null +++ b/packages/ui/src/components/admin/ProductsManager.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [deletingProduct, setDeletingProduct] = useState(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
Loading products...
; + } + + return ( +
+
+ +
+ +
+ + + + Name + Slug + Status + Cost Units + Actions + + + + {products.map(product => ( + + +
+
+ +
+
+
{product.name}
+
{product.description || No description}
+
+
+
+ + {product.slug} + + + {product.settings?.enabled ? ( + Active + ) : ( + Disabled + )} + + + {product.settings?.default_cost_units ?? 0} + + + + + + + + handleEditOpen(product)}>Edit + setDeletingProduct(product)} + > + Delete + + + + +
+ ))} + {products.length === 0 && ( + + + No products found. Create one to get started. + + + )} +
+
+
+ + {/* Editor Dialog */} + { + if (!open) { + setIsCreateOpen(false); + setEditingProduct(null); + } + }}> + + + {editingProduct ? Edit Product : Create Product} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g. Premium Subscription" + /> +
+
+ + setFormData({ ...formData, slug: e.target.value })} + disabled={!!editingProduct} + placeholder="Autogenerated if left empty" + /> +
+
+ +