"use client"; import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { Bot, Edit2, FileText, Globe, Logs, type LucideIcon, MessageCircle, MessageSquare, Mic, MoreHorizontal, Plug, Settings, Shield, Trash2, Users, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; import { createRoleMutationAtom, deleteRoleMutationAtom, updateRoleMutationAtom, } from "@/atoms/roles/roles-mutation.atoms"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; import type { PermissionInfo } from "@/contracts/types/permissions.types"; import type { CreateRoleRequest, DeleteRoleRequest, Role, UpdateRoleRequest, } from "@/contracts/types/roles.types"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; const CATEGORY_CONFIG: Record< string, { label: string; icon: LucideIcon; description: string; order: number } > = { documents: { label: "Documents", icon: FileText, description: "Manage files, notes, and content", order: 1, }, chats: { label: "AI Chats", icon: MessageSquare, description: "Create and manage AI conversations", order: 2, }, comments: { label: "Comments", icon: MessageCircle, description: "Add annotations to documents", order: 3, }, llm_configs: { label: "AI Models", icon: Bot, description: "Configure AI model settings", order: 4, }, podcasts: { label: "Podcasts", icon: Mic, description: "Generate AI podcasts from content", order: 5, }, connectors: { label: "Integrations", icon: Plug, description: "Connect external data sources", order: 6, }, logs: { label: "Activity Logs", icon: Logs, description: "View and manage audit trail", order: 7, }, members: { label: "Team Members", icon: Users, description: "Manage team membership", order: 8, }, roles: { label: "Roles", icon: Shield, description: "Configure role permissions", order: 9, }, settings: { label: "Settings", icon: Settings, description: "Manage search space settings", order: 10, }, public_sharing: { label: "Public Chat Sharing", icon: Globe, description: "Share chats publicly via links", order: 11, }, }; const ACTION_LABELS: Record = { create: "Create", read: "Read", update: "Update", delete: "Delete", invite: "Invite", view: "View", remove: "Remove", manage_roles: "Manage Roles", }; const ROLE_PRESETS = { editor: { name: "Editor", description: "Create, read, and edit content. No delete or admin access.", permissions: [ "documents:create", "documents:read", "documents:update", "chats:create", "chats:read", "chats:update", "comments:create", "comments:read", "llm_configs:create", "llm_configs:read", "llm_configs:update", "podcasts:create", "podcasts:read", "podcasts:update", "connectors:create", "connectors:read", "connectors:update", "logs:read", "members:invite", "members:view", "roles:read", "settings:view", ], }, viewer: { name: "Viewer", description: "Read-only access with ability to add comments", permissions: [ "documents:read", "chats:read", "comments:create", "comments:read", "llm_configs:read", "podcasts:read", "connectors:read", "logs:read", "members:view", "roles:read", "settings:view", ], }, contributor: { name: "Contributor", description: "Can add and manage their own content", permissions: [ "documents:create", "documents:read", "documents:update", "chats:create", "chats:read", "comments:create", "comments:read", "llm_configs:read", "podcasts:read", "connectors:read", "logs:read", "members:view", "roles:read", "settings:view", ], }, }; type PermissionWithDescription = PermissionInfo; // ============ Roles Manager (for Settings page) ============ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) { const { data: access = null } = useAtomValue(myAccessAtom); const hasPermission = useCallback( (permission: string) => { if (!access) return false; if (access.is_owner) return true; return access.permissions?.includes(permission) ?? false; }, [access] ); const { data: roles = [], isLoading: rolesLoading } = useQuery({ queryKey: cacheKeys.roles.all(searchSpaceId.toString()), queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }), enabled: !!searchSpaceId, }); const { data: permissionsData } = useAtomValue(permissionsAtom); const permissions = permissionsData?.permissions || []; const groupedPermissions = useMemo(() => { const groups: Record = {}; for (const perm of permissions) { if (!groups[perm.category]) { groups[perm.category] = []; } groups[perm.category].push(perm); } return groups; }, [permissions]); const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom); const handleUpdateRole = useCallback( async ( roleId: number, data: { name?: string; description?: string | null; permissions?: string[]; is_default?: boolean; } ): Promise => { const request: UpdateRoleRequest = { search_space_id: searchSpaceId, role_id: roleId, data: data, }; return await updateRole(request); }, [updateRole, searchSpaceId] ); const handleDeleteRole = useCallback( async (roleId: number): Promise => { const request: DeleteRoleRequest = { search_space_id: searchSpaceId, role_id: roleId, }; await deleteRole(request); return true; }, [deleteRole, searchSpaceId] ); const handleCreateRole = useCallback( async (roleData: CreateRoleRequest["data"]): Promise => { const request: CreateRoleRequest = { search_space_id: searchSpaceId, data: roleData, }; return await createRole(request); }, [createRole, searchSpaceId] ); return ( ); } // ============ Role Permissions Display ============ function RolePermissionsDialog({ permissions, roleName, children, }: { permissions: string[]; roleName: string; children: React.ReactNode; }) { const isFullAccess = permissions.includes("*"); const grouped: Record = {}; if (!isFullAccess) { for (const perm of permissions) { const [category, action] = perm.split(":"); if (!grouped[category]) grouped[category] = []; grouped[category].push(action); } } const sortedCategories = Object.keys(grouped).sort((a, b) => { const orderA = CATEGORY_CONFIG[a]?.order ?? 99; const orderB = CATEGORY_CONFIG[b]?.order ?? 99; return orderA - orderB; }); const categoryCount = sortedCategories.length; return ( {children} {roleName} — Permissions {isFullAccess ? "This role has unrestricted access to all resources" : `${permissions.length} permissions across ${categoryCount} categories`} {isFullAccess ? (

Full access

All permissions granted across every category

) : (
{sortedCategories.map((category) => { const actions = grouped[category]; const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText, }; const IconComponent = config.icon; return (
{config.label}
{actions.map((action) => ( {ACTION_LABELS[action] || action.replace(/_/g, " ")} ))}
); })}
)}
); } function PermissionsBadge({ permissions }: { permissions: string[] }) { if (permissions.includes("*")) { return (
Full access
); } return (
{permissions.length} permissions
); } // ============ Roles Content ============ function RolesContent({ roles, groupedPermissions, loading, onUpdateRole, onDeleteRole, onCreateRole, canUpdate, canDelete, canCreate, }: { roles: Role[]; groupedPermissions: Record; loading: boolean; onUpdateRole: ( roleId: number, data: { name?: string; description?: string | null; permissions?: string[]; is_default?: boolean; } ) => Promise; onDeleteRole: (roleId: number) => Promise; onCreateRole: (data: CreateRoleRequest["data"]) => Promise; canUpdate: boolean; canDelete: boolean; canCreate: boolean; }) { const [showCreateRole, setShowCreateRole] = useState(false); const [editingRoleId, setEditingRoleId] = useState(null); if (loading) { return (
); } const editingRole = editingRoleId !== null ? roles.find((r) => r.id === editingRoleId) : null; return (
{canCreate && (
)} {editingRole && ( { if (!open) setEditingRoleId(null); }} role={editingRole} groupedPermissions={groupedPermissions} onUpdateRole={onUpdateRole} /> )}
{roles.map((role) => (
e.preventDefault()}> {canUpdate && ( setEditingRoleId(role.id)}> Edit Role )} {canDelete && ( <> e.preventDefault()}> Delete Role Delete role? This will permanently delete the "{role.name}" role. Members with this role will lose their permissions. Cancel onDeleteRole(role.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > Delete )}
)}
))}
); } // ============ Permissions Editor (shared by Create and Edit) ============ function PermissionsEditor({ groupedPermissions, selectedPermissions, onTogglePermission, onToggleCategory, }: { groupedPermissions: Record; selectedPermissions: string[]; onTogglePermission: (perm: string) => void; onToggleCategory: (category: string) => void; }) { const [expandedCategories, setExpandedCategories] = useState([]); const sortedCategories = useMemo(() => { return Object.keys(groupedPermissions).sort((a, b) => { const orderA = CATEGORY_CONFIG[a]?.order ?? 99; const orderB = CATEGORY_CONFIG[b]?.order ?? 99; return orderA - orderB; }); }, [groupedPermissions]); const toggleCategoryExpanded = useCallback((category: string) => { setExpandedCategories((prev) => prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category] ); }, []); const getCategoryStats = useCallback( (category: string) => { const perms = groupedPermissions[category] || []; const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length; return { selected, total: perms.length, allSelected: selected === perms.length, }; }, [groupedPermissions, selectedPermissions] ); return (
{sortedCategories.map((category) => { const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText, description: "", order: 99, }; const IconComponent = config.icon; const stats = getCategoryStats(category); const isExpanded = expandedCategories.includes(category); const perms = groupedPermissions[category] || []; return (
{isExpanded && (
{perms.map((perm) => { const action = perm.value.split(":")[1]; const actionLabel = ACTION_LABELS[action] || action.replace(/_/g, " "); const isSelected = selectedPermissions.includes(perm.value); return ( ); })}
)}
); })}
); } // ============ Create Role Dialog ============ function CreateRoleDialog({ open, onOpenChange, groupedPermissions, onCreateRole, }: { open: boolean; onOpenChange: (open: boolean) => void; groupedPermissions: Record; onCreateRole: (data: CreateRoleRequest["data"]) => Promise; }) { const [creating, setCreating] = useState(false); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [selectedPermissions, setSelectedPermissions] = useState([]); const [isDefault, setIsDefault] = useState(false); const handleClose = () => { onOpenChange(false); setName(""); setDescription(""); setSelectedPermissions([]); setIsDefault(false); }; const handleCreate = async () => { if (!name.trim()) { toast.error("Please enter a role name"); return; } setCreating(true); try { await onCreateRole({ name: name.trim(), description: description.trim() || null, permissions: selectedPermissions, is_default: isDefault, }); handleClose(); } catch (error) { console.error("Failed to create role:", error); } finally { setCreating(false); } }; const togglePermission = useCallback((perm: string) => { setSelectedPermissions((prev) => prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] ); }, []); const toggleCategory = useCallback( (category: string) => { const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || []; const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p)); if (allSelected) { setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p))); } else { setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]); } }, [groupedPermissions, selectedPermissions] ); const applyPreset = useCallback( (presetKey: keyof typeof ROLE_PRESETS) => { const preset = ROLE_PRESETS[presetKey]; setSelectedPermissions(preset.permissions); if (!name.trim()) { setName(preset.name); setDescription(preset.description); } toast.success(`Applied ${preset.name} preset`); }, [name] ); return ( (v ? onOpenChange(true) : handleClose())}> Create Custom Role Define permissions for a new role in this search space
{Object.entries(ROLE_PRESETS).map(([key, preset]) => ( ))}
setName(e.target.value)} />
setDescription(e.target.value)} />
setIsDefault(checked === true)} />

New members without a specific role will be assigned this role

); } // ============ Edit Role Dialog ============ function EditRoleDialog({ open, onOpenChange, role, groupedPermissions, onUpdateRole, }: { open: boolean; onOpenChange: (open: boolean) => void; role: Role; groupedPermissions: Record; onUpdateRole: ( roleId: number, data: { name?: string; description?: string | null; permissions?: string[]; is_default?: boolean; } ) => Promise; }) { const [saving, setSaving] = useState(false); const [name, setName] = useState(role.name); const [description, setDescription] = useState(role.description || ""); const [selectedPermissions, setSelectedPermissions] = useState(role.permissions); const [isDefault, setIsDefault] = useState(role.is_default); useEffect(() => { if (open) { setName(role.name); setDescription(role.description || ""); setSelectedPermissions(role.permissions); setIsDefault(role.is_default); } }, [open, role]); const handleSave = async () => { if (!name.trim()) { toast.error("Please enter a role name"); return; } setSaving(true); try { await onUpdateRole(role.id, { name: name.trim(), description: description.trim() || null, permissions: selectedPermissions, is_default: isDefault, }); toast.success("Role updated successfully"); onOpenChange(false); } catch (error) { console.error("Failed to update role:", error); toast.error("Failed to update role"); } finally { setSaving(false); } }; const togglePermission = useCallback((perm: string) => { setSelectedPermissions((prev) => prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] ); }, []); const toggleCategory = useCallback( (category: string) => { const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || []; const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p)); if (allSelected) { setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p))); } else { setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]); } }, [groupedPermissions, selectedPermissions] ); return ( Edit Role Modify permissions for "{role.name}"
setName(e.target.value)} />
setDescription(e.target.value)} />
setIsDefault(checked === true)} />

New members without a specific role will be assigned this role

); }