From 9f08f7c9c279ff60173feb57ecd0cf317f683c20 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 21 Feb 2026 14:26:56 +0530 Subject: [PATCH] feat: add team roles management feature with UI components and localization support --- .../[search_space_id]/settings/page.tsx | 11 + .../dashboard/[search_space_id]/team/page.tsx | 2316 +++-------------- .../components/settings/roles-manager.tsx | 1396 ++++++++++ surfsense_web/messages/en.json | 2 + surfsense_web/messages/es.json | 2 + surfsense_web/messages/hi.json | 2 + surfsense_web/messages/pt.json | 2 + surfsense_web/messages/zh.json | 2 + 8 files changed, 1767 insertions(+), 1966 deletions(-) create mode 100644 surfsense_web/components/settings/roles-manager.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx index d6319c46a..36d0c5753 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -12,6 +12,7 @@ import { Menu, MessageSquare, Settings, + Shield, X, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; @@ -24,6 +25,7 @@ import { ImageModelManager } from "@/components/settings/image-model-manager"; import { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager"; import { PromptConfigManager } from "@/components/settings/prompt-config-manager"; +import { RolesManager } from "@/components/settings/roles-manager"; import { Button } from "@/components/ui/button"; import { trackSettingsViewed } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; @@ -72,6 +74,12 @@ const settingsNavItems: SettingsNavItem[] = [ descriptionKey: "nav_public_links_desc", icon: Globe, }, + { + id: "team-roles", + labelKey: "nav_team_roles", + descriptionKey: "nav_team_roles_desc", + icon: Shield, + }, ]; function SettingsSidebar({ @@ -298,6 +306,9 @@ function SettingsContent({ {activeSection === "public-links" && ( )} + {activeSection === "team-roles" && ( + + )} diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 55b511957..a9213654a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -3,31 +3,14 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { - Bot, Calendar, Check, + ChevronDown, Clock, Copy, Crown, - Edit2, - FileText, - Globe, Hash, - Link2, - LinkIcon, - Logs, - type LucideIcon, - MessageCircle, - MessageSquare, - Mic, - MoreHorizontal, - Plug, - Plus, - RefreshCw, - Search, - Settings, Shield, - ShieldCheck, Trash2, UserMinus, UserPlus, @@ -47,12 +30,6 @@ import { updateMemberMutationAtom, } from "@/atoms/members/members-mutation.atoms"; import { membersAtom, 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, @@ -64,11 +41,8 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Calendar as CalendarComponent } from "@/components/ui/calendar"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -88,7 +62,6 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, @@ -105,7 +78,6 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { CreateInviteRequest, DeleteInviteRequest, @@ -116,46 +88,64 @@ import type { Membership, UpdateMembershipRequest, } from "@/contracts/types/members.types"; -import type { PermissionInfo } from "@/contracts/types/permissions.types"; -import type { - CreateRoleRequest, - DeleteRoleRequest, - Role, - UpdateRoleRequest, -} from "@/contracts/types/roles.types"; +import type { Role } from "@/contracts/types/roles.types"; import { invitesApiService } from "@/lib/apis/invites-api.service"; import { rolesApiService } from "@/lib/apis/roles-api.service"; +import { formatRelativeDate } from "@/lib/format-date"; import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; -// Animation variants -const fadeInUp = { - hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" as const } }, -}; +const AVATAR_COLORS = [ + "bg-amber-600", + "bg-blue-600", + "bg-emerald-600", + "bg-violet-600", + "bg-rose-600", + "bg-cyan-600", + "bg-orange-600", + "bg-teal-600", + "bg-pink-600", + "bg-indigo-600", +]; -const staggerContainer = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { staggerChildren: 0.1 }, - }, -}; +function getAvatarColor(identifier: string): string { + let hash = 0; + for (let i = 0; i < identifier.length; i++) { + hash = identifier.charCodeAt(i) + ((hash << 5) - hash); + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; +} -const cardVariants = { - hidden: { opacity: 0, scale: 0.95 }, - visible: { - opacity: 1, - scale: 1, - transition: { type: "spring" as const, stiffness: 300, damping: 30 }, - }, -}; +function getAvatarInitials(member: Membership): string { + if (member.user_display_name) { + const parts = member.user_display_name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return member.user_display_name.slice(0, 2).toUpperCase(); + } + if (member.user_email) { + const emailName = member.user_email.split("@")[0]; + return emailName.slice(0, 2).toUpperCase(); + } + return "U"; +} + +function getInviteInitials(invite: Invite): string { + if (invite.name) { + const parts = invite.name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return invite.name.slice(0, 2).toUpperCase(); + } + return "IN"; +} export default function TeamManagementPage() { const params = useParams(); const searchSpaceId = Number(params.search_space_id); - const [activeTab, setActiveTab] = useState("members"); const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom); @@ -171,14 +161,9 @@ export default function TeamManagementPage() { const { data: members = [], isLoading: membersLoading, - refetch: fetchMembers, } = useAtomValue(membersAtom); - const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); - const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); - const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom); const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom); - const { mutateAsync: deleteMember } = useAtomValue(deleteMemberMutationAtom); const { mutateAsync: createInvite } = useAtomValue(createInviteMutationAtom); const { mutateAsync: revokeInvite } = useAtomValue(deleteInviteMutationAtom); @@ -206,57 +191,12 @@ export default function TeamManagementPage() { [createInvite, searchSpaceId] ); - 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] - ); - const handleUpdateMember = useCallback( async (membershipId: number, roleId: number | null): Promise => { const request: UpdateMembershipRequest = { search_space_id: searchSpaceId, membership_id: membershipId, - data: { - role_id: roleId, - }, + data: { role_id: roleId }, }; return (await updateMember(request)) as Membership; }, @@ -270,51 +210,33 @@ export default function TeamManagementPage() { membership_id: membershipId, }; await deleteMember(request); - return true; }, [deleteMember, searchSpaceId] ); + const { data: roles = [], - isLoading: rolesLoading, - refetch: fetchRoles, } = useQuery({ queryKey: cacheKeys.roles.all(searchSpaceId.toString()), queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }), enabled: !!searchSpaceId, }); + const { data: invites = [], - isLoading: invitesLoading, - refetch: fetchInvites, } = useQuery({ queryKey: cacheKeys.invites.all(searchSpaceId.toString()), queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }), staleTime: 5 * 60 * 1000, }); - 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 activeInvites = useMemo(() => invites.filter((i) => i.is_active), [invites]); const canInvite = hasPermission("members:invite"); + const canManageRoles = hasPermission("members:manage_roles"); + const canRemove = hasPermission("members:remove"); - const handleRefresh = useCallback(async () => { - await Promise.all([fetchMembers(), fetchRoles(), fetchInvites()]); - toast.success("Team data refreshed"); - }, [fetchMembers, fetchRoles, fetchInvites]); - - // Track users per search space when team page is viewed useEffect(() => { if (members.length > 0 && !membersLoading) { const ownerCount = members.filter((m) => m.is_owner).length; @@ -322,7 +244,7 @@ export default function TeamManagementPage() { } }, [members, membersLoading, searchSpaceId]); - if (accessLoading) { + if (accessLoading || membersLoading) { return (
-
- - {/* Header */} -
-
-
-
- -
-
-

- Team Management -

-

- Manage members, roles, and invite links for your search space -

-
-
-
- -
-
+
+
+ {/* Header row: Invite button on left, member count on right */} +
+ {canInvite && ( + + )} + {!canInvite &&
} +

+ {members.length} {members.length === 1 ? "member" : "members"} +

- {/* Summary Cards */} - - - -
- - Total Members - - - -
- {members.length} -
-

- {members.filter((m) => m.is_owner).length} owner - {members.filter((m) => m.is_owner).length !== 1 ? "s" : ""} -

-
- - - - - -
- - Active Roles - - - -
- {roles.length} -
-

- {roles.filter((r) => r.is_system_role).length} system roles -

-
- - - - - -
- - Active Invites - - - -
- {invites.filter((i) => i.is_active).length} -
-

- {invites.reduce((acc, i) => acc + i.uses_count, 0)} total uses -

-
- - - - - {/* Tabs Content */} - -
-
- - - - Members - - {members.length} - - - - - Roles - - {roles.length} - - - - - Invites - - {invites.filter((i) => i.is_active).length} - - - -
- - {activeTab === "invites" && canInvite && ( - - )} -
- - - - - - - - - - - - -
- + {/* Members & Invites Table */} +
+ + + + + Name + + + Last logged in + + + Role + + + + + {members.map((member) => ( + + ))} + {activeInvites.map((invite) => ( + + ))} + {members.length === 0 && activeInvites.length === 0 && ( + + +
+ +

No members yet

+
+
+
+ )} +
+
+
+
); } -// ============ Members Tab ============ +// ============ Member Row ============ -// Helper function to get avatar initials -function getAvatarInitials(member: Membership): string { - // Try display name first - if (member.user_display_name) { - const parts = member.user_display_name.trim().split(/\s+/); - if (parts.length >= 2) { - return (parts[0][0] + parts[1][0]).toUpperCase(); - } - return member.user_display_name.slice(0, 2).toUpperCase(); - } - // Try email - if (member.user_email) { - const emailName = member.user_email.split("@")[0]; - return emailName.slice(0, 2).toUpperCase(); - } - // Fallback - return "U"; -} - -function MembersTab({ - members, +function MemberRow({ + member, roles, - loading, - onUpdateRole, - onRemoveMember, canManageRoles, canRemove, + onUpdateRole, + onRemoveMember, }: { - members: Membership[]; + member: Membership; roles: Role[]; - loading: boolean; - onUpdateRole: (membershipId: number, roleId: number | null) => Promise; - onRemoveMember: (membershipId: number) => Promise; canManageRoles: boolean; canRemove: boolean; + onUpdateRole: (membershipId: number, roleId: number | null) => Promise; + onRemoveMember: (membershipId: number) => Promise; }) { - const [searchQuery, setSearchQuery] = useState(""); - - const filteredMembers = useMemo(() => { - if (!searchQuery) return members; - const query = searchQuery.toLowerCase(); - return members.filter( - (m) => - m.user_email?.toLowerCase().includes(query) || m.role?.name.toLowerCase().includes(query) - ); - }, [members, searchQuery]); - - if (loading) { - return ( -
- -
- ); - } + const initials = getAvatarInitials(member); + const avatarColor = getAvatarColor(member.user_id); + const displayName = member.user_display_name || member.user_email || "Unknown"; + const roleName = member.is_owner ? "Owner" : (member.role?.name || "No role"); + const showActions = canManageRoles || canRemove; return ( - - {/* Search */} -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
-
- - {/* Members List */} -
- - - - -
- - Member -
-
- -
- - Role -
-
- -
- - Joined -
-
- -
- - Actions -
-
-
-
- - {filteredMembers.length === 0 ? ( - - -
- -

No members found

-
-
-
+ + {/* Name + Avatar */} + +
+
+ {member.user_avatar_url ? ( + ) : ( - filteredMembers.map((member, index) => ( - - -
-
- {member.user_avatar_url ? ( - - ) : ( -
- - {getAvatarInitials(member)} - -
- )} - {member.is_owner && ( -
- -
- )} -
-
-

- {member.user_display_name || member.user_email || "Unknown"} -

- {member.user_display_name && member.user_email && ( -

- {member.user_email} -

- )} - {member.is_owner && ( - - Owner - - )} -
-
-
- - {canManageRoles && !member.is_owner ? ( - - ) : ( - - {member.role?.name || "No role"} - - )} - - - - {new Date(member.joined_at).toLocaleDateString()} - - - - {canRemove && !member.is_owner && ( - - - - - - - Remove member? - - This will remove{" "} - {member.user_email} from this - search space. They will lose access to all resources. - - - - Cancel - onRemoveMember(member.id)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Remove - - - - - )} - -
- )) - )} - -
-
-
- ); -} - -// ============ Role Permissions Display ============ - -// Unified category configuration used across all role-related components -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", -}; - -function RolePermissionsDisplay({ permissions }: { permissions: string[] }) { - if (permissions.includes("*")) { - return ( -
-
- -
-
-

Full Access

-

All permissions granted

-
-
- ); - } - - // Group permissions by category - const grouped: Record = {}; - for (const perm of permissions) { - const [category, action] = perm.split(":"); - if (!grouped[category]) grouped[category] = []; - grouped[category].push(action); - } - - // Sort categories by predefined order - 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 ( - - - - - - - - - Role Permissions - - - {permissions.length} permissions across {categoryCount} categories - - - -
- {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, " ")} - - ))} -
-
- ); - })} -
-
-
-
- ); -} - -// ============ Roles Tab ============ - -function RolesTab({ - 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 ( -
- -
- ); - } - - return ( - - {/* Create Role Button / Section */} - {canCreate && !showCreateRole && ( - - - - )} - - {/* Create Role Form */} - {showCreateRole && ( - setShowCreateRole(false)} - /> - )} - - {/* Edit Role Form */} - {editingRoleId !== null && - (() => { - const roleToEdit = roles.find((r) => r.id === editingRoleId); - if (!roleToEdit) return null; - return ( - setEditingRoleId(null)} - /> - ); - })()} - - {/* Roles Grid */} -
- {roles.map((role, index) => ( - - - {role.is_system_role && ( -
- System Role -
- )} - -
-
-
- -
-
- {role.name} - {role.is_default && ( - - Default - - )} -
-
- {!role.is_system_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 - - - - - - )} - - - )} -
- {role.description && ( - {role.description} +
- - - - - - ))} -
-
+ > + {initials} +
+ )} + {member.is_owner && ( +
+ +
+ )} +
+
+

{displayName}

+ {member.user_display_name && member.user_email && ( +

{member.user_email}

+ )} +
+
+ + + {/* Last logged in */} + + {formatRelativeDate(member.joined_at)} + + + {/* Role */} + + {showActions && !member.is_owner ? ( + + + + + e.preventDefault()}> + {canManageRoles && ( + <> + {roles.map((role) => ( + onUpdateRole(member.id, role.id)} + className={cn( + member.role_id === role.id && "bg-accent" + )} + > + {role.name} + + ))} + onUpdateRole(member.id, null)} + className={cn(!member.role_id && "bg-accent")} + > + No role + + + )} + {canManageRoles && canRemove && } + {canRemove && ( + + + e.preventDefault()} + > + + Remove member + + + + + Remove member? + + This will remove{" "} + {member.user_email}{" "} + from this search space. They will lose access to all resources. + + + + Cancel + onRemoveMember(member.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Remove + + + + + )} + + + ) : ( + + {roleName} + {!member.is_owner && } + + )} + + ); } -// ============ Invites Tab ============ +// ============ Invite Row ============ -function InvitesTab({ - invites, - loading, - onRevokeInvite, +function InviteRow({ + invite, canRevoke, + onRevokeInvite, }: { - invites: Invite[]; - loading: boolean; - onRevokeInvite: (inviteId: number) => Promise; + invite: Invite; canRevoke: boolean; + onRevokeInvite: (inviteId: number) => Promise; }) { - const [copiedId, setCopiedId] = useState(null); - - const copyInviteLink = useCallback((invite: Invite) => { - const link = `${window.location.origin}/invite/${invite.invite_code}`; - navigator.clipboard.writeText(link); - setCopiedId(invite.id); - toast.success("Invite link copied to clipboard"); - setTimeout(() => setCopiedId(null), 2000); - }, []); - - if (loading) { - return ( -
- -
- ); - } - - if (invites.length === 0) { - return ( - -
- -
-

No invite links

-

- Create an invite link to allow others to join your search space with specific roles. -

-
- ); - } + const initials = getInviteInitials(invite); + const avatarColor = getAvatarColor(invite.invite_code); + const displayName = invite.name || "Unnamed Invite"; return ( - - {invites.map((invite, index) => { - const isExpired = invite.expires_at && new Date(invite.expires_at) < new Date(); - const isMaxedOut = invite.max_uses && invite.uses_count >= invite.max_uses; - const isInactive = !invite.is_active || isExpired || isMaxedOut; - - return ( - + +
+
- - -
-
-
+
+

{displayName}

+ {invite.role?.name && ( +

+ Will join as {invite.role.name} +

+ )} +
+
+ + + + Never + + + + {canRevoke ? ( + + + + + e.preventDefault()}> + { + const link = `${window.location.origin}/invite/${invite.invite_code}`; + navigator.clipboard.writeText(link); + toast.success("Invite link copied"); + }} + > + + Copy invite link + + + + + e.preventDefault()} + > + + Revoke invite + + + + + Revoke invite? + + This will permanently delete this invite link. Anyone with this link + will no longer be able to join. + + + + Cancel + onRevokeInvite(invite.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - -
-
-
-

{invite.name || "Unnamed Invite"}

- {isExpired && ( - - Expired - - )} - {isMaxedOut && ( - - Maxed - - )} - {!invite.is_active && !isExpired && !isMaxedOut && ( - - Inactive - - )} -
-
- - - {invite.role?.name || "Default role"} - - - - {invite.uses_count} - {invite.max_uses ? ` / ${invite.max_uses} uses` : " uses"} - - {invite.expires_at && ( - - - {isExpired - ? "Expired" - : `Exp: ${new Date(invite.expires_at).toLocaleDateString()}`} - - )} -
-
-
-
- - {canRevoke && ( - - - - - - - Revoke invite? - - This will permanently delete this invite link. Anyone with this link - will no longer be able to join. - - - - Cancel - onRevokeInvite(invite.id)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Revoke - - - - - )} -
-
- - - - ); - })} - + Revoke + + + + + + + ) : ( + Invited + )} + + ); } @@ -1353,12 +597,10 @@ function CreateInviteDialog({ roles, onCreateInvite, searchSpaceId, - className, }: { roles: Role[]; onCreateInvite: (data: CreateInviteRequest["data"]) => Promise; searchSpaceId: number; - className?: string; }) { const [open, setOpen] = useState(false); const [creating, setCreating] = useState(false); @@ -1381,7 +623,6 @@ function CreateInviteDialog({ const invite = await onCreateInvite(data); setCreatedInvite(invite); - // Track invite sent event const roleName = roleId && roleId !== "default" ? roles.find((r) => r.id.toString() === roleId)?.name @@ -1419,9 +660,9 @@ function CreateInviteDialog({ return ( (v ? setOpen(true) : handleClose())}> - @@ -1475,7 +716,7 @@ function CreateInviteDialog({ ) : ( <> - Create Invite Link + Invite Members Create a link to invite people to this search space. @@ -1572,860 +813,3 @@ function CreateInviteDialog({ ); } - -// ============ Create Role Section ============ - -// Preset permission sets for quick role creation -const ROLE_PRESETS = { - editor: { - name: "Editor", - description: "Can create, read, and update content, but cannot delete or manage team settings", - 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", - ], - }, -}; - -// Action display labels -const ACTION_DISPLAY: Record = { - create: { label: "Create", color: "text-emerald-600 bg-emerald-500/10" }, - read: { label: "View", color: "text-blue-600 bg-blue-500/10" }, - update: { label: "Edit", color: "text-amber-600 bg-amber-500/10" }, - delete: { label: "Delete", color: "text-red-600 bg-red-500/10" }, - invite: { label: "Invite", color: "text-violet-600 bg-violet-500/10" }, - view: { label: "View", color: "text-blue-600 bg-blue-500/10" }, - remove: { label: "Remove", color: "text-red-600 bg-red-500/10" }, - manage_roles: { label: "Manage Roles", color: "text-violet-600 bg-violet-500/10" }, -}; - -// Use the imported PermissionInfo type which now includes description -type PermissionWithDescription = PermissionInfo; - -function CreateRoleSection({ - groupedPermissions, - onCreateRole, - onCancel, -}: { - groupedPermissions: Record; - onCreateRole: (data: CreateRoleRequest["data"]) => Promise; - onCancel: () => void; -}) { - const [creating, setCreating] = useState(false); - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [selectedPermissions, setSelectedPermissions] = useState([]); - const [isDefault, setIsDefault] = useState(false); - const [expandedCategories, setExpandedCategories] = useState([]); - - // Sort categories by order - 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 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, - }); - onCancel(); - } 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 toggleCategoryExpanded = useCallback((category: string) => { - setExpandedCategories((prev) => - prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category] - ); - }, []); - - 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] - ); - - 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 ( - - - -
-
-
- -
-
- Create Custom Role - - Define permissions for a new role in this search space - -
-
- -
-
- - {/* Quick Start with Presets */} -
- -
- {Object.entries(ROLE_PRESETS).map(([key, preset]) => ( - - ))} -
-
- - {/* Role Details */} -
-
- - setName(e.target.value)} - /> -
-
- - setDescription(e.target.value)} - /> -
-
- - {/* Default Role Checkbox */} -
- setIsDefault(checked === true)} - /> -
- -

- New members without a specific role will be assigned this role -

-
-
- - {/* Permissions Section */} -
-
- - -
- -
- {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 ( -
- {/* Category Header */} -
toggleCategoryExpanded(category)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleCategoryExpanded(category); - } - }} - tabIndex={0} - role="button" - > -
-
0 ? "bg-primary/10" : "bg-muted" - )} - > - 0 ? "text-primary" : "text-muted-foreground" - )} - /> -
-
-
- {config.label} - 0 ? "default" : "secondary"} - className="text-xs h-5" - > - {stats.selected}/{stats.total} - -
-

- {config.description} -

-
-
-
- toggleCategory(category)} - onClick={(e) => e.stopPropagation()} - aria-label={`Select all ${config.label} permissions`} - /> - - - - - -
-
- - {/* Permissions List */} - {isExpanded && ( - -
- {perms.map((perm) => { - const action = perm.value.split(":")[1]; - const actionConfig = ACTION_DISPLAY[action] || { - label: action, - color: "text-gray-600 bg-gray-500/10", - }; - const isSelected = selectedPermissions.includes(perm.value); - - return ( -
togglePermission(perm.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - togglePermission(perm.value); - } - }} - tabIndex={0} - role="checkbox" - aria-checked={isSelected} - > -
- togglePermission(perm.value)} - onClick={(e) => e.stopPropagation()} - /> -
-
- - {actionConfig.label} - -
-

- {perm.description} -

-
-
-
- ); - })} -
-
- )} -
- ); - })} -
-
- - {/* Actions */} -
- - -
-
-
-
- ); -} - -function EditRoleSection({ - role, - groupedPermissions, - onUpdateRole, - onCancel, -}: { - role: Role; - groupedPermissions: Record; - onUpdateRole: ( - roleId: number, - data: { - name?: string; - description?: string | null; - permissions?: string[]; - is_default?: boolean; - } - ) => Promise; - onCancel: () => void; -}) { - 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); - const [expandedCategories, setExpandedCategories] = useState([]); - - // Sort categories by order - 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 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"); - onCancel(); - } 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] - ); - - 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 ( - - - -
-
-
- -
-
- Edit Role - - Modify permissions for "{role.name}" - -
-
- -
-
- - {/* Role Details */} -
-
- - setName(e.target.value)} - /> -
-
- - setDescription(e.target.value)} - /> -
-
- - {/* Default Role Checkbox */} -
- setIsDefault(checked === true)} - /> -
- -

- New members without a specific role will be assigned this role -

-
-
- - {/* Permissions Section */} -
-
- - -
- -
- {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 ( -
- {/* Category Header */} -
toggleCategoryExpanded(category)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleCategoryExpanded(category); - } - }} - tabIndex={0} - role="button" - > -
-
0 ? "bg-primary/10" : "bg-muted" - )} - > - 0 ? "text-primary" : "text-muted-foreground" - )} - /> -
-
-
- {config.label} - 0 ? "default" : "secondary"} - className="text-xs h-5" - > - {stats.selected}/{stats.total} - -
-

- {config.description} -

-
-
-
- toggleCategory(category)} - onClick={(e) => e.stopPropagation()} - aria-label={`Select all ${config.label} permissions`} - /> - - - - - -
-
- - {/* Permissions List */} - {isExpanded && ( - -
- {perms.map((perm) => { - const action = perm.value.split(":")[1]; - const actionConfig = ACTION_DISPLAY[action] || { - label: action, - color: "text-gray-600 bg-gray-500/10", - }; - const isSelected = selectedPermissions.includes(perm.value); - - return ( -
togglePermission(perm.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - togglePermission(perm.value); - } - }} - tabIndex={0} - role="checkbox" - aria-checked={isSelected} - > -
- togglePermission(perm.value)} - onClick={(e) => e.stopPropagation()} - /> -
-
- - {actionConfig.label} - -
-

- {perm.description} -

-
-
-
- ); - })} -
-
- )} -
- ); - })} -
-
- - {/* Actions */} -
- - -
-
-
-
- ); -} diff --git a/surfsense_web/components/settings/roles-manager.tsx b/surfsense_web/components/settings/roles-manager.tsx new file mode 100644 index 000000000..a10f243d2 --- /dev/null +++ b/surfsense_web/components/settings/roles-manager.tsx @@ -0,0 +1,1396 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { + Bot, + Check, + Crown, + Edit2, + FileText, + Globe, + type LucideIcon, + MessageCircle, + MessageSquare, + Mic, + MoreHorizontal, + Plug, + Plus, + Logs, + Settings, + Shield, + ShieldCheck, + Trash2, + Users, +} from "lucide-react"; +import { motion } from "motion/react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { + createRoleMutationAtom, + deleteRoleMutationAtom, + updateRoleMutationAtom, +} from "@/atoms/roles/roles-mutation.atoms"; +import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; +import { myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +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 ACTION_DISPLAY: Record = { + create: { label: "Create", color: "text-emerald-600 bg-emerald-500/10" }, + read: { label: "View", color: "text-blue-600 bg-blue-500/10" }, + update: { label: "Edit", color: "text-amber-600 bg-amber-500/10" }, + delete: { label: "Delete", color: "text-red-600 bg-red-500/10" }, + invite: { label: "Invite", color: "text-violet-600 bg-violet-500/10" }, + view: { label: "View", color: "text-blue-600 bg-blue-500/10" }, + remove: { label: "Remove", color: "text-red-600 bg-red-500/10" }, + manage_roles: { + label: "Manage Roles", + color: "text-violet-600 bg-violet-500/10", + }, +}; + +const ROLE_PRESETS = { + editor: { + name: "Editor", + description: + "Can create, read, and update content, but cannot delete or manage team settings", + 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 RolePermissionsDisplay({ + permissions, +}: { + permissions: string[]; +}) { + if (permissions.includes("*")) { + return ( +
+
+ +
+
+

Full Access

+

+ All permissions granted +

+
+
+ ); + } + + const grouped: Record = {}; + 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 ( + + + + + + + + + Role Permissions + + + {permissions.length} permissions across {categoryCount} categories + + + +
+ {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, " ")} + + ))} +
+
+ ); + })} +
+
+
+
+ ); +} + +// ============ 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 ( +
+ +
+ ); + } + + return ( + + {canCreate && !showCreateRole && ( + + + + )} + + {showCreateRole && ( + setShowCreateRole(false)} + /> + )} + + {editingRoleId !== null && + (() => { + const roleToEdit = roles.find((r) => r.id === editingRoleId); + if (!roleToEdit) return null; + return ( + setEditingRoleId(null)} + /> + ); + })()} + +
+ {roles.map((role, index) => ( + + + {role.is_system_role && ( +
+ System Role +
+ )} + +
+
+
+ +
+
+ + {role.name} + + {role.is_default && ( + + Default + + )} +
+
+ {!role.is_system_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 + + + + + + )} + + + )} +
+ {role.description && ( + + {role.description} + + )} +
+ + + +
+
+ ))} +
+
+ ); +} + +// ============ 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 actionConfig = + ACTION_DISPLAY[action] || { + label: action, + color: "text-gray-600 bg-gray-500/10", + }; + const isSelected = + selectedPermissions.includes( + perm.value + ); + + return ( + + ); + })} +
+
+ )} +
+ ); + })} +
+
+ ); +} + +// ============ Create Role Section ============ + +function CreateRoleSection({ + groupedPermissions, + onCreateRole, + onCancel, +}: { + groupedPermissions: Record; + onCreateRole: (data: CreateRoleRequest["data"]) => Promise; + onCancel: () => void; +}) { + const [creating, setCreating] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [selectedPermissions, setSelectedPermissions] = useState([]); + const [isDefault, setIsDefault] = useState(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, + }); + onCancel(); + } 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 ( + + + +
+
+
+ +
+
+ + Create Custom Role + + + Define permissions for a new role in this + search space + +
+
+ +
+
+ + {/* Quick Start with Presets */} +
+ +
+ {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 Section ============ + +function EditRoleSection({ + role, + groupedPermissions, + onUpdateRole, + onCancel, +}: { + role: Role; + groupedPermissions: Record; + onUpdateRole: ( + roleId: number, + data: { + name?: string; + description?: string | null; + permissions?: string[]; + is_default?: boolean; + } + ) => Promise; + onCancel: () => void; +}) { + 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); + + 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"); + onCancel(); + } 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 +

+
+
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 20e665586..319595f8e 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -746,6 +746,8 @@ "nav_system_instructions_desc": "SearchSpace-wide AI instructions", "nav_public_links": "Public Chat Links", "nav_public_links_desc": "Manage publicly shared chat links", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "Name", "general_name_placeholder": "Enter search space name", "general_name_description": "A unique name for your search space.", diff --git a/surfsense_web/messages/es.json b/surfsense_web/messages/es.json index 9db0a99c9..acf0a184b 100644 --- a/surfsense_web/messages/es.json +++ b/surfsense_web/messages/es.json @@ -746,6 +746,8 @@ "nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda", "nav_public_links": "Enlaces de chat públicos", "nav_public_links_desc": "Administrar enlaces de chat compartidos públicamente", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "Nombre", "general_name_placeholder": "Ingresa el nombre del espacio de búsqueda", "general_name_description": "Un nombre único para tu espacio de búsqueda.", diff --git a/surfsense_web/messages/hi.json b/surfsense_web/messages/hi.json index 0c5370fd0..1c95ce203 100644 --- a/surfsense_web/messages/hi.json +++ b/surfsense_web/messages/hi.json @@ -746,6 +746,8 @@ "nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश", "nav_public_links": "सार्वजनिक चैट लिंक", "nav_public_links_desc": "सार्वजनिक रूप से साझा किए गए चैट लिंक प्रबंधित करें", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "नाम", "general_name_placeholder": "सर्च स्पेस का नाम दर्ज करें", "general_name_description": "आपके सर्च स्पेस के लिए एक अद्वितीय नाम।", diff --git a/surfsense_web/messages/pt.json b/surfsense_web/messages/pt.json index 3803e066c..aaf3b36f7 100644 --- a/surfsense_web/messages/pt.json +++ b/surfsense_web/messages/pt.json @@ -746,6 +746,8 @@ "nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa", "nav_public_links": "Links de chat públicos", "nav_public_links_desc": "Gerenciar links de chat compartilhados publicamente", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "Nome", "general_name_placeholder": "Insira o nome do espaço de pesquisa", "general_name_description": "Um nome único para seu espaço de pesquisa.", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 4c194a5dc..0096b68aa 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -730,6 +730,8 @@ "nav_system_instructions_desc": "搜索空间级别的 AI 指令", "nav_public_links": "公开聊天链接", "nav_public_links_desc": "管理公开分享的聊天链接", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "名称", "general_name_placeholder": "输入搜索空间名称", "general_name_description": "您的搜索空间的唯一名称。",