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 01/18] 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": "您的搜索空间的唯一名称。", From d76380334f91f64459a26a938f4bb0d84cffff09 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:19:41 +0530 Subject: [PATCH 02/18] feat: implement pagination and loading skeletons for team management page --- .../dashboard/[search_space_id]/team/page.tsx | 458 ++++++++++++------ 1 file changed, 317 insertions(+), 141 deletions(-) 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 a9213654a..426c73e60 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -6,19 +6,22 @@ import { Calendar, Check, ChevronDown, + ChevronFirst, + ChevronLast, + ChevronLeft, + ChevronRight, Clock, Copy, - Crown, Hash, - Shield, + ShieldUser, Trash2, - UserMinus, UserPlus, + User, Users, } from "lucide-react"; import { motion } from "motion/react"; import Image from "next/image"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -69,6 +72,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Table, @@ -143,6 +147,8 @@ function getInviteInitials(invite: Invite): string { return "IN"; } +const PAGE_SIZE = 10; + export default function TeamManagementPage() { const params = useParams(); const searchSpaceId = Number(params.search_space_id); @@ -237,6 +243,39 @@ export default function TeamManagementPage() { const canManageRoles = hasPermission("members:manage_roles"); const canRemove = hasPermission("members:remove"); + const owners = useMemo(() => members.filter((m) => m.is_owner), [members]); + const nonOwnerMembers = useMemo(() => members.filter((m) => !m.is_owner), [members]); + + const [pageIndex, setPageIndex] = useState(0); + const totalItems = nonOwnerMembers.length + activeInvites.length; + const lastPage = Math.max(0, Math.ceil(totalItems / PAGE_SIZE) - 1); + + useEffect(() => { + if (pageIndex > lastPage) setPageIndex(lastPage); + }, [pageIndex, lastPage]); + + const paginatedMembers = useMemo(() => { + const start = pageIndex * PAGE_SIZE; + const end = start + PAGE_SIZE; + return nonOwnerMembers.slice( + Math.min(start, nonOwnerMembers.length), + Math.min(end, nonOwnerMembers.length) + ); + }, [nonOwnerMembers, pageIndex]); + + const paginatedInvites = useMemo(() => { + const start = pageIndex * PAGE_SIZE; + const end = start + PAGE_SIZE; + const inviteStart = Math.max(0, start - nonOwnerMembers.length); + const inviteEnd = Math.max(0, end - nonOwnerMembers.length); + return activeInvites.slice(inviteStart, inviteEnd); + }, [activeInvites, nonOwnerMembers.length, pageIndex]); + + const displayStart = totalItems > 0 ? pageIndex * PAGE_SIZE + 1 : 0; + const displayEnd = Math.min((pageIndex + 1) * PAGE_SIZE, totalItems); + const canPrev = pageIndex > 0; + const canNext = displayEnd < totalItems; + useEffect(() => { if (members.length > 0 && !membersLoading) { const ownerCount = members.filter((m) => m.is_owner).length; @@ -246,16 +285,66 @@ export default function TeamManagementPage() { if (accessLoading || membersLoading) { return ( -
- - -

Loading team data...

-
-
+ +
+
+
+ + +
+
+ + + + + + + + + + +
+ +
+
+
+
+ + {Array.from({ length: PAGE_SIZE }).map((_, i) => ( + + +
+ +
+ + +
+
+
+ + + + +
+ +
+
+
+ ))} +
+
+
+
+
+
); } @@ -266,7 +355,7 @@ export default function TeamManagementPage() { transition={{ duration: 0.3 }} className="min-h-screen bg-background" > -
+
{/* Header row: Invite button on left, member count on right */}
@@ -284,54 +373,135 @@ export default function TeamManagementPage() {
{/* Members & Invites Table */} -
- - - - +
+
+ + + + + Name - - + + + + + Last logged in - - + + + + + Role - + + + + + + {owners.map((member, index) => ( + + ))} + {paginatedMembers.map((member, index) => ( + + ))} + {paginatedInvites.map((invite, index) => ( + + ))} + {members.length === 0 && activeInvites.length === 0 && ( + + +
+ +

No members yet

+
+
- - - {members.map((member) => ( - - ))} - {activeInvites.map((invite) => ( - - ))} - {members.length === 0 && activeInvites.length === 0 && ( - - -
- -

No members yet

-
-
-
- )} -
-
+ )} + +
+ + {/* Pagination */} + {totalItems > PAGE_SIZE && ( + + + {displayStart}-{displayEnd} of {totalItems} + +
+ + + + +
+
+ )}
@@ -347,6 +517,8 @@ function MemberRow({ canRemove, onUpdateRole, onRemoveMember, + searchSpaceId, + index, }: { member: Membership; roles: Role[]; @@ -354,19 +526,25 @@ function MemberRow({ canRemove: boolean; onUpdateRole: (membershipId: number, roleId: number | null) => Promise; onRemoveMember: (membershipId: number) => Promise; + searchSpaceId: number; + index: number; }) { + const router = useRouter(); 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; + const showActions = !member.is_owner && (canManageRoles || canRemove); return ( - - {/* Name + Avatar */} - + +
-
+
{member.user_avatar_url ? ( )} - {member.is_owner && ( -
- -
- )}

{displayName}

@@ -400,14 +573,12 @@ function MemberRow({
- {/* Last logged in */} - + {formatRelativeDate(member.joined_at)} - {/* Role */} - - {showActions && !member.is_owner ? ( + + {showActions ? ( - + {createdInvite ? ( <> diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 5c6399ce0..d907ca6d1 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -196,7 +196,7 @@ export function DashboardBreadcrumb() { } return ( - + {breadcrumbs.map((item, index) => ( From 802b4fe44ee7057f35fade81dd2b100e49e27080 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:33:31 +0530 Subject: [PATCH 04/18] feat: refactor team management page by removing invite handling logic and enhancing UI components for better user experience --- .../dashboard/[search_space_id]/team/page.tsx | 275 +++++++++--------- .../components/layout/ui/sidebar/Sidebar.tsx | 2 +- .../layout/ui/sidebar/SidebarHeader.tsx | 8 +- .../layout/ui/sidebar/SidebarUserProfile.tsx | 24 +- .../components/new-chat/model-selector.tsx | 4 +- 5 files changed, 149 insertions(+), 164 deletions(-) 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 7b8fe49a5..34ad273e1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -13,6 +13,7 @@ import { Clock, Copy, Hash, + Link2, ShieldUser, Trash2, UserPlus, @@ -136,17 +137,6 @@ function getAvatarInitials(member: Membership): string { 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"; -} - const PAGE_SIZE = 10; export default function TeamManagementPage() { @@ -247,7 +237,7 @@ export default function TeamManagementPage() { const nonOwnerMembers = useMemo(() => members.filter((m) => !m.is_owner), [members]); const [pageIndex, setPageIndex] = useState(0); - const totalItems = nonOwnerMembers.length + activeInvites.length; + const totalItems = nonOwnerMembers.length; const lastPage = Math.max(0, Math.ceil(totalItems / PAGE_SIZE) - 1); useEffect(() => { @@ -263,14 +253,6 @@ export default function TeamManagementPage() { ); }, [nonOwnerMembers, pageIndex]); - const paginatedInvites = useMemo(() => { - const start = pageIndex * PAGE_SIZE; - const end = start + PAGE_SIZE; - const inviteStart = Math.max(0, start - nonOwnerMembers.length); - const inviteEnd = Math.max(0, end - nonOwnerMembers.length); - return activeInvites.slice(inviteStart, inviteEnd); - }, [activeInvites, nonOwnerMembers.length, pageIndex]); - const displayStart = totalItems > 0 ? pageIndex * PAGE_SIZE + 1 : 0; const displayEnd = Math.min((pageIndex + 1) * PAGE_SIZE, totalItems); const canPrev = pageIndex > 0; @@ -358,7 +340,8 @@ export default function TeamManagementPage() {
{/* Header row: Invite button on left, member count on right */} -
+
+
{canInvite && ( )} - {!canInvite &&
} + {canInvite && activeInvites.length > 0 && ( + + )} +

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

@@ -424,16 +413,7 @@ export default function TeamManagementPage() { index={owners.length + index} /> ))} - {paginatedInvites.map((invite, index) => ( - - ))} - {members.length === 0 && activeInvites.length === 0 && ( + {members.length === 0 && (
@@ -650,117 +630,6 @@ function MemberRow({ ); } -// ============ Invite Row ============ - -function InviteRow({ - invite, - canRevoke, - onRevokeInvite, - index, -}: { - invite: Invite; - canRevoke: boolean; - onRevokeInvite: (inviteId: number) => Promise; - index: number; -}) { - const initials = getInviteInitials(invite); - const avatarColor = getAvatarColor(invite.invite_code); - const displayName = invite.name || "Unnamed Invite"; - - return ( - - -
-
- {initials} -
-
-

{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" - > - Revoke - - - - - - - ) : ( - Invited - )} - -
- ); -} - // ============ Create Invite Dialog ============ function CreateInviteDialog({ @@ -843,8 +712,8 @@ function CreateInviteDialog({ Invite members - - {createdInvite ? ( + e.preventDefault()}> + {createdInvite ? ( <> @@ -989,3 +858,119 @@ function CreateInviteDialog({ ); } + +// ============ All Invites Dialog ============ + +function AllInvitesDialog({ + invites, + onRevokeInvite, +}: { + invites: Invite[]; + onRevokeInvite: (inviteId: number) => Promise; +}) { + const [copiedId, setCopiedId] = useState(null); + + const copyLink = (invite: Invite) => { + const link = `${window.location.origin}/invite/${invite.invite_code}`; + navigator.clipboard.writeText(link); + setCopiedId(invite.id); + toast.success("Invite link copied"); + setTimeout(() => setCopiedId(null), 2000); + }; + + return ( + + + + + + + + Active Invite Links + + + {invites.length} active {invites.length === 1 ? "invite" : "invites"}. Copy a link or revoke access. + + +
+ {invites.map((invite) => ( +
+
+
+

+ {invite.name || "Unnamed invite"} +

+
+ {invite.role?.name && ( + {invite.role.name} + )} + {invite.max_uses != null && ( + + + {invite.uses_count}/{invite.max_uses} + + )} + {invite.expires_at && ( + + + {new Date(invite.expires_at).toLocaleDateString()} + + )} +
+
+ + + + + + + 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 + + + + +
+
+ + {typeof window !== "undefined" + ? `${window.location.origin}/invite/${invite.invite_code}` + : `/invite/${invite.invite_code}`} + + +
+
+ ))} +
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index aee98a290..4e95c381f 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -93,7 +93,7 @@ export function Sidebar({ return (
- + - + {t("manage_members")} - + {t("search_space_settings")} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 3ba7bb2a0..7f3a97f93 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -177,7 +177,7 @@ export function SidebarUserProfile({ {displayName} - +
@@ -191,14 +191,14 @@ export function SidebarUserProfile({ - + {t("user_settings")} {setTheme && ( - + {t("theme")} @@ -216,7 +216,7 @@ export function SidebarUserProfile({ isSelected && "text-primary" )} > - + {t(themeOption.value)} {isSelected && } @@ -229,7 +229,7 @@ export function SidebarUserProfile({ - + {t("language")} @@ -262,7 +262,7 @@ export function SidebarUserProfile({ {isLoggingOut ? ( ) : ( - + )} {isLoggingOut ? t("loggingOut") : t("logout")} @@ -299,7 +299,7 @@ export function SidebarUserProfile({ - +
@@ -313,14 +313,14 @@ export function SidebarUserProfile({ - + {t("user_settings")} {setTheme && ( - + {t("theme")} @@ -338,7 +338,7 @@ export function SidebarUserProfile({ isSelected && "text-primary" )} > - + {t(themeOption.value)} {isSelected && } @@ -351,7 +351,7 @@ export function SidebarUserProfile({ - + {t("language")} @@ -384,7 +384,7 @@ export function SidebarUserProfile({ {isLoggingOut ? ( ) : ( - + )} {isLoggingOut ? t("loggingOut") : t("logout")} diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index 98911fe38..d894fe252 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -226,7 +226,7 @@ export function ModelSelector({ size="sm" role="combobox" aria-expanded={open} - className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)} + className={cn("h-8 gap-2 px-3 text-sm border-border/60 select-none", className)} > {isLoading ? ( <> @@ -280,7 +280,7 @@ export function ModelSelector({ From c7736aa62c4621063a1c3018b81b60808d447ed5 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:30:41 +0530 Subject: [PATCH 05/18] feat: adjust pagination size and enhance UI elements in team management and connector popup components --- .../dashboard/[search_space_id]/team/page.tsx | 20 +- .../assistant-ui/connector-popup.tsx | 2 +- .../components/settings/roles-manager.tsx | 957 +++++++----------- 3 files changed, 375 insertions(+), 604 deletions(-) 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 34ad273e1..6ed061916 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -137,7 +137,7 @@ function getAvatarInitials(member: Membership): string { return "U"; } -const PAGE_SIZE = 10; +const PAGE_SIZE = 5; export default function TeamManagementPage() { const params = useParams(); @@ -271,7 +271,7 @@ export default function TeamManagementPage() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} - className="min-h-screen bg-background select-none" + className="bg-background select-none" >
@@ -335,7 +335,7 @@ export default function TeamManagementPage() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} - className="min-h-screen bg-background select-none" + className="bg-background select-none" >
@@ -356,9 +356,9 @@ export default function TeamManagementPage() { /> )}
-

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

+

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

{/* Members & Invites Table */} @@ -569,7 +569,7 @@ function MemberRow({ - e.preventDefault()}> + e.preventDefault()} className="min-w-[180px] dark:bg-neutral-800 dark:border dark:border-neutral-700"> {canManageRoles && roles .filter((r) => r.name !== "Owner") @@ -612,7 +612,7 @@ function MemberRow({ )} - + router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)} > @@ -707,7 +707,7 @@ function CreateInviteDialog({ return ( (v ? setOpen(true) : handleClose())}> - @@ -929,7 +929,7 @@ function AllInvitesDialog({
- diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 143508977..24eb5bc60 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -221,7 +221,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger )} - + Manage Connectors {/* YouTube Crawler View - shown when adding YouTube videos */} {isYouTubeView && searchSpaceId ? ( diff --git a/surfsense_web/components/settings/roles-manager.tsx b/surfsense_web/components/settings/roles-manager.tsx index a10f243d2..42301f99f 100644 --- a/surfsense_web/components/settings/roles-manager.tsx +++ b/surfsense_web/components/settings/roles-manager.tsx @@ -4,8 +4,6 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { Bot, - Check, - Crown, Edit2, FileText, Globe, @@ -19,12 +17,11 @@ import { Logs, Settings, Shield, - ShieldCheck, Trash2, Users, } from "lucide-react"; import { motion } from "motion/react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { createRoleMutationAtom, @@ -44,15 +41,7 @@ import { 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, @@ -167,25 +156,11 @@ const ACTION_LABELS: Record = { 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", + description: "Create, read, and edit content. No delete or admin access.", permissions: [ "documents:create", "documents:read", @@ -353,32 +328,24 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) { // ============ Role Permissions Display ============ -function RolePermissionsDisplay({ +function RolePermissionsDialog({ permissions, + roleName, + children, }: { permissions: string[]; + roleName: string; + children: React.ReactNode; }) { - if (permissions.includes("*")) { - return ( -
-
- -
-
-

Full Access

-

- All permissions granted -

-
-
- ); - } + const isFullAccess = permissions.includes("*"); const grouped: Record = {}; - for (const perm of permissions) { - const [category, action] = perm.split(":"); - if (!grouped[category]) grouped[category] = []; - grouped[category].push(action); + 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) => { @@ -392,78 +359,87 @@ function RolePermissionsDisplay({ return ( - + {children} - - - - Role Permissions + + + {roleName} — Permissions - {permissions.length} permissions across {categoryCount} categories + {isFullAccess + ? "This role has unrestricted access to all resources" + : `${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, " ")} - - ))} -
-
- ); - })} + {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({ @@ -506,6 +482,8 @@ function RolesContent({ ); } + const editingRole = editingRoleId !== null ? roles.find((r) => r.id === editingRoleId) : null; + return ( - {canCreate && !showCreateRole && ( - - - +
)} - {showCreateRole && ( - + + {editingRole && ( + { if (!open) setEditingRoleId(null); }} + role={editingRole} groupedPermissions={groupedPermissions} - onCreateRole={onCreateRole} - onCancel={() => setShowCreateRole(false)} + onUpdateRole={onUpdateRole} /> )} - {editingRoleId !== null && - (() => { - const roleToEdit = roles.find((r) => r.id === editingRoleId); - if (!roleToEdit) return null; - return ( - setEditingRoleId(null)} - /> - ); - })()} - -
- {roles.map((role, index) => ( +
+ {roles.map((role, index) => ( - - {role.is_system_role && ( -
- System Role -
- )} - -
-
-
- -
-
- - {role.name} - - {role.is_default && ( - - Default - - )} -
+ + - e.preventDefault() - } + onCloseAutoFocus={(e) => e.preventDefault()} > {canUpdate && ( - - setEditingRoleId(role.id) - } - > + setEditingRoleId(role.id)}> Edit Role @@ -641,9 +584,7 @@ function RolesContent({ - e.preventDefault() - } + onSelect={(e) => e.preventDefault()} > Delete Role @@ -651,28 +592,16 @@ function RolesContent({ - - Delete role? - + Delete role? - This will permanently - delete the " - {role.name}" role. - Members with this role - will lose their - permissions. + This will permanently delete the "{role.name}" role. + Members with this role will lose their permissions. - - Cancel - + Cancel - onDeleteRole( - role.id - ) - } + onClick={() => onDeleteRole(role.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > Delete @@ -684,20 +613,10 @@ function RolesContent({ )} - )} -
- {role.description && ( - - {role.description} - +
)} -
- - - -
+ +
))}
@@ -776,7 +695,7 @@ function PermissionsEditor({
-
+
{sortedCategories.map((category) => { const config = CATEGORY_CONFIG[category] || { label: category, @@ -792,68 +711,29 @@ function PermissionsEditor({ return (
); })} @@ -959,16 +807,18 @@ function PermissionsEditor({ ); } -// ============ Create Role Section ============ +// ============ Create Role Dialog ============ -function CreateRoleSection({ +function CreateRoleDialog({ + open, + onOpenChange, groupedPermissions, onCreateRole, - onCancel, }: { + open: boolean; + onOpenChange: (open: boolean) => void; groupedPermissions: Record; onCreateRole: (data: CreateRoleRequest["data"]) => Promise; - onCancel: () => void; }) { const [creating, setCreating] = useState(false); const [name, setName] = useState(""); @@ -976,6 +826,14 @@ function CreateRoleSection({ 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"); @@ -990,7 +848,7 @@ function CreateRoleSection({ permissions: selectedPermissions, is_default: isDefault, }); - onCancel(); + handleClose(); } catch (error) { console.error("Failed to create role:", error); } finally { @@ -1041,175 +899,117 @@ function CreateRoleSection({ ); 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]) => ( + (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)} - /> +
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
-
- - setDescription(e.target.value)} - /> -
-
-
- - setIsDefault(checked === true) - } +
+ setIsDefault(checked === true)} + /> +
+ +

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

+
+
+ + -
- -

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

-
- - - -
- - -
- - - +
+
+ + +
+ +
); } -// ============ Edit Role Section ============ +// ============ Edit Role Dialog ============ -function EditRoleSection({ +function EditRoleDialog({ + open, + onOpenChange, role, groupedPermissions, onUpdateRole, - onCancel, }: { + open: boolean; + onOpenChange: (open: boolean) => void; role: Role; groupedPermissions: Record; onUpdateRole: ( @@ -1221,16 +1021,22 @@ function EditRoleSection({ 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 [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"); @@ -1246,7 +1052,7 @@ function EditRoleSection({ is_default: isDefault, }); toast.success("Role updated successfully"); - onCancel(); + onOpenChange(false); } catch (error) { console.error("Failed to update role:", error); toast.error("Failed to update role"); @@ -1285,112 +1091,77 @@ function EditRoleSection({ ); return ( - - - -
-
-
- + + + + Edit Role + + Modify permissions for "{role.name}" + + +
+
+
+
+ + setName(e.target.value)} + />
-
- - Edit Role - - - Modify permissions for "{role.name} - " - +
+ + setDescription(e.target.value)} + />
- -
- - -
-
- - setName(e.target.value)} - /> -
-
- - setDescription(e.target.value)} - /> -
-
-
- - setIsDefault(checked === true) - } +
+ setIsDefault(checked === true)} + /> +
+ +

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

+
+
+ + -
- -

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

-
- - - -
- - -
-
- - +
+
+ + +
+ +
); } From ccf8c063da1bebd7c0138b01501b88649bf6c49b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:55:54 +0530 Subject: [PATCH 06/18] feat: update settings navigation to include section parameters for improved user experience --- .../[search_space_id]/onboard/page.tsx | 2 +- .../[search_space_id]/settings/page.tsx | 22 ++++++++++++++----- .../dashboard/[search_space_id]/team/page.tsx | 22 ++++++++++--------- .../assistant-ui/connector-popup.tsx | 2 +- .../assistant-ui/document-upload-popup.tsx | 2 +- .../layout/providers/LayoutDataProvider.tsx | 4 ++-- .../layout/ui/sidebar/ChatListItem.tsx | 4 ++-- .../components/new-chat/chat-share-button.tsx | 2 +- .../public-chat-snapshot-row.tsx | 18 ++++++++------- 9 files changed, 47 insertions(+), 31 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index 8709a4491..17db12ab7 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -259,7 +259,7 @@ export default function OnboardPage() { You can add more configurations and customize settings anytime in{" "} @@ -954,14 +954,16 @@ function AllInvitesDialog({
- - {typeof window !== "undefined" - ? `${window.location.origin}/invite/${invite.invite_code}` - : `/invite/${invite.invite_code}`} - - - e.preventDefault()} className="min-w-[120px] bg-muted dark:border dark:border-neutral-700"> + e.preventDefault()} + className="min-w-[120px] bg-muted dark:border dark:border-neutral-700" + > {canManageRoles && roles .filter((r) => r.name !== "Owner") @@ -590,40 +586,39 @@ function MemberRow({ > Remove - - - - 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 - - - - + + + + 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 + + + + )} router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)} + onClick={() => + router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`) + } > Manage Roles ) : ( - - {roleName} - + {roleName} )} @@ -671,9 +666,7 @@ function CreateInviteDialog({ const invite = await onCreateInvite(data); setCreatedInvite(invite); - const roleName = roleId - ? roles.find((r) => r.id.toString() === roleId)?.name - : undefined; + const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined; trackSearchSpaceInviteSent(searchSpaceId, { roleName, hasExpiry: !!expiresAt, @@ -707,13 +700,19 @@ function CreateInviteDialog({ return ( (v ? setOpen(true) : handleClose())}> - - e.preventDefault()}> - {createdInvite ? ( + e.preventDefault()} + > + {createdInvite ? ( <> @@ -730,11 +729,7 @@ function CreateInviteDialog({ {window.location.origin}/invite/{createdInvite.invite_code}
@@ -891,24 +886,18 @@ function AllInvitesDialog({ - - Active Invite Links - + Active Invite Links - {invites.length} active {invites.length === 1 ? "invite" : "invites"}. Copy a link or revoke access. + {invites.length} active {invites.length === 1 ? "invite" : "invites"}. Copy a link or + revoke access.
{invites.map((invite) => ( -
+
-

- {invite.name || "Unnamed invite"} -

+

{invite.name || "Unnamed invite"}

{invite.role?.name && ( {invite.role.name} @@ -929,7 +918,11 @@ function AllInvitesDialog({
- @@ -937,8 +930,8 @@ function AllInvitesDialog({ Revoke invite? - This will permanently delete this invite link. Anyone with this link - will no longer be able to join. + This will permanently delete this invite link. Anyone with this link will no + longer be able to join. @@ -961,7 +954,12 @@ function AllInvitesDialog({ : `/invite/${invite.invite_code}`}
- diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index 37b335103..36a20cf3f 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -409,19 +409,19 @@ export function AllPrivateChatsSidebar({ {t("more_options") || "More options"} - - {!thread.archived && ( + + {!thread.archived && ( + handleStartRename(thread.id, thread.title || "New Chat")} + > + + {t("rename") || "Rename"} + + )} handleStartRename(thread.id, thread.title || "New Chat")} + onClick={() => handleToggleArchive(thread.id, thread.archived)} + disabled={isArchiving} > - - {t("rename") || "Rename"} - - )} - handleToggleArchive(thread.id, thread.archived)} - disabled={isArchiving} - > {thread.archived ? ( <> diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index feee30447..108ba4cde 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -409,20 +409,20 @@ export function AllSharedChatsSidebar({ {t("more_options") || "More options"} - - {!thread.archived && ( + + {!thread.archived && ( + handleStartRename(thread.id, thread.title || "New Chat")} + > + + {t("rename") || "Rename"} + + )} handleStartRename(thread.id, thread.title || "New Chat")} + onClick={() => handleToggleArchive(thread.id, thread.archived)} + disabled={isArchiving} > - - {t("rename") || "Rename"} - - )} - handleToggleArchive(thread.id, thread.archived)} - disabled={isArchiving} - > - {thread.archived ? ( + {thread.archived ? ( <> {t("unarchive") || "Restore"} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 7f3a97f93..1a0fac9e9 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -381,11 +381,7 @@ export function SidebarUserProfile({ - {isLoggingOut ? ( - - ) : ( - - )} + {isLoggingOut ? : } {isLoggingOut ? t("loggingOut") : t("logout")} diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index ab04fc4c9..bfb6d91fc 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -144,7 +144,9 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS diff --git a/surfsense_web/components/settings/roles-manager.tsx b/surfsense_web/components/settings/roles-manager.tsx index 42301f99f..96ab2551f 100644 --- a/surfsense_web/components/settings/roles-manager.tsx +++ b/surfsense_web/components/settings/roles-manager.tsx @@ -156,7 +156,6 @@ const ACTION_LABELS: Record = { manage_roles: "Manage Roles", }; - const ROLE_PRESETS = { editor: { name: "Editor", @@ -241,13 +240,9 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) { [access] ); - const { - data: roles = [], - isLoading: rolesLoading, - } = useQuery({ + const { data: roles = [], isLoading: rolesLoading } = useQuery({ queryKey: cacheKeys.roles.all(searchSpaceId.toString()), - queryFn: () => - rolesApiService.getRoles({ search_space_id: searchSpaceId }), + queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }), enabled: !!searchSpaceId, }); @@ -358,14 +353,10 @@ function RolePermissionsDialog({ return ( - - {children} - + {children} - - {roleName} — Permissions - + {roleName} — Permissions {isFullAccess ? "This role has unrestricted access to all resources" @@ -379,7 +370,9 @@ function RolePermissionsDialog({

Full access

-

All permissions granted across every category

+

+ All permissions granted across every category +

) : ( @@ -399,9 +392,7 @@ function RolePermissionsDialog({ >
- - {config.label} - + {config.label}
{actions.map((action) => ( @@ -409,8 +400,7 @@ function RolePermissionsDialog({ key={action} className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium" > - {ACTION_LABELS[action] || - action.replace(/_/g, " ")} + {ACTION_LABELS[action] || action.replace(/_/g, " ")} ))}
@@ -435,7 +425,9 @@ function PermissionsBadge({ permissions }: { permissions: string[] }) { } return (
- {permissions.length} permissions + + {permissions.length} permissions +
); } @@ -514,15 +506,17 @@ function RolesContent({ {editingRole && ( { if (!open) setEditingRoleId(null); }} + onOpenChange={(open) => { + if (!open) setEditingRoleId(null); + }} role={editingRole} groupedPermissions={groupedPermissions} onUpdateRole={onUpdateRole} /> )} -
- {roles.map((role, index) => ( +
+ {roles.map((role, index) => ( {!role.is_system_role && ( -
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > - e.preventDefault()} - > + e.preventDefault()}> {canUpdate && ( setEditingRoleId(role.id)}> @@ -649,18 +645,14 @@ function PermissionsEditor({ const toggleCategoryExpanded = useCallback((category: string) => { setExpandedCategories((prev) => - prev.includes(category) - ? prev.filter((c) => c !== category) - : [...prev, category] + 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; + const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length; return { selected, total: perms.length, @@ -683,15 +675,11 @@ function PermissionsEditor({ className="text-xs h-7" onClick={() => setExpandedCategories( - expandedCategories.length === sortedCategories.length - ? [] - : sortedCategories + expandedCategories.length === sortedCategories.length ? [] : sortedCategories ) } > - {expandedCategories.length === sortedCategories.length - ? "Collapse All" - : "Expand All"} + {expandedCategories.length === sortedCategories.length ? "Collapse All" : "Expand All"}
@@ -709,10 +697,7 @@ function PermissionsEditor({ const perms = groupedPermissions[category] || []; return ( -
+