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{" "}
@@ -306,14 +315,27 @@ function SettingsContent({
);
}
+const VALID_SECTIONS = new Set(settingsNavItems.map((item) => item.id));
+const DEFAULT_SECTION = "general";
+
export default function SettingsPage() {
const router = useRouter();
const params = useParams();
+ const searchParams = useSearchParams();
const searchSpaceId = Number(params.search_space_id);
- const [activeSection, setActiveSection] = useState("general");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
- // Track settings section view
+ const sectionParam = searchParams.get("section");
+ const activeSection =
+ sectionParam && VALID_SECTIONS.has(sectionParam) ? sectionParam : DEFAULT_SECTION;
+
+ const handleSectionChange = useCallback(
+ (section: string) => {
+ router.replace(`/dashboard/${searchSpaceId}/settings?section=${section}`, { scroll: false });
+ },
+ [router, searchSpaceId]
+ );
+
useEffect(() => {
trackSettingsViewed(searchSpaceId, activeSection);
}, [searchSpaceId, activeSection]);
@@ -333,7 +355,7 @@ export default function SettingsPage() {
setIsSidebarOpen(false)}
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..c33c2e341 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
@@ -3,39 +3,26 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import {
- Bot,
Calendar,
Check,
+ ChevronDown,
+ ChevronFirst,
+ ChevronLast,
+ ChevronLeft,
+ ChevronRight,
Clock,
Copy,
- Crown,
- Edit2,
- FileText,
- Globe,
Hash,
Link2,
- LinkIcon,
- Logs,
- type LucideIcon,
- MessageCircle,
- MessageSquare,
- Mic,
- MoreHorizontal,
- Plug,
- Plus,
- RefreshCw,
- Search,
- Settings,
- Shield,
- ShieldCheck,
+ ShieldUser,
Trash2,
- UserMinus,
+ User,
UserPlus,
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 {
@@ -47,12 +34,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 +45,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 +66,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,
@@ -96,6 +73,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
@@ -105,7 +83,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
CreateInviteRequest,
DeleteInviteRequest,
@@ -116,46 +93,55 @@ 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";
+}
+
+const PAGE_SIZE = 5;
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);
@@ -168,17 +154,9 @@ export default function TeamManagementPage() {
[access]
);
- const {
- data: members = [],
- isLoading: membersLoading,
- refetch: fetchMembers,
- } = useAtomValue(membersAtom);
+ const { data: members = [], isLoading: membersLoading } = 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 +184,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 +203,54 @@ export default function TeamManagementPage() {
membership_id: membershipId,
};
await deleteMember(request);
-
return true;
},
[deleteMember, searchSpaceId]
);
- const {
- data: roles = [],
- isLoading: rolesLoading,
- refetch: fetchRoles,
- } = useQuery({
+
+ const { data: roles = [] } = useQuery({
queryKey: cacheKeys.roles.all(searchSpaceId.toString()),
queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }),
enabled: !!searchSpaceId,
});
- const {
- data: invites = [],
- isLoading: invitesLoading,
- refetch: fetchInvites,
- } = useQuery({
+
+ const { data: invites = [] } = 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]);
+ 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;
+ 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 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;
- // 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,1043 +258,383 @@ export default function TeamManagementPage() {
}
}, [members, membersLoading, searchSpaceId]);
- if (accessLoading) {
- return (
-
-
-
- Loading team data...
-
-
- );
- }
-
- return (
-
-
-
- {/* Header */}
-
-
-
-
-
-
-
-
- Team Management
-
-
- Manage members, roles, and invite links for your search space
-
-
-
-
-
-
-
-
-
- {/* 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 Tab ============
-
-// 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,
- roles,
- loading,
- onUpdateRole,
- onRemoveMember,
- canManageRoles,
- canRemove,
-}: {
- members: Membership[];
- roles: Role[];
- loading: boolean;
- onUpdateRole: (membershipId: number, roleId: number | null) => Promise;
- onRemoveMember: (membershipId: number) => Promise;
- canManageRoles: boolean;
- canRemove: boolean;
-}) {
- 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 (
-
-
-
- );
- }
-
- return (
-
- {/* Search */}
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-9"
- />
-
-
-
- {/* Members List */}
-
-
-
-
-
-
-
- Member
-
-
-
-
-
- Role
-
-
-
-
-
- Joined
-
-
-
-
-
- Actions
-
-
-
-
-
- {filteredMembers.length === 0 ? (
-
-
-
-
-
- ) : (
- 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 (
-
- );
-}
-
-// ============ 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}
- )}
-
-
-
-
-
-
- ))}
-
-
- );
-}
-
-// ============ Invites Tab ============
-
-function InvitesTab({
- invites,
- loading,
- onRevokeInvite,
- canRevoke,
-}: {
- invites: Invite[];
- loading: boolean;
- onRevokeInvite: (inviteId: number) => Promise;
- canRevoke: boolean;
-}) {
- 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) {
+ if (accessLoading || membersLoading) {
return (
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: PAGE_SIZE }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
-
No invite links
-
- Create an invite link to allow others to join your search space with specific roles.
-
);
}
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;
+
+
+ {/* Header row: Invite button on left, member count on right */}
+
+
+ {canInvite && (
+
+ )}
+ {canInvite && activeInvites.length > 0 && (
+
+ )}
+
+
+ {members.length} {members.length === 1 ? "member" : "members"}
+
+
- return (
-
-
+
+
+
+
+
+
+ Name
+
+
+
+
+
+ Last logged in
+
+
+
+
+
+ Role
+
+
+
+
+
+ {owners.map((member, index) => (
+
+ ))}
+ {paginatedMembers.map((member, index) => (
+
+ ))}
+ {members.length === 0 && (
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Pagination */}
+ {totalItems > PAGE_SIZE && (
+
-
-
-
-
-
-
-
-
-
{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
-
-
-
-
- )}
-
-
-
-
-
- );
- })}
+
+ {displayStart}-{displayEnd} of {totalItems}
+
+
+
+
+
+
+
+
+ )}
+
+
);
}
+// ============ Member Row ============
+
+function MemberRow({
+ member,
+ roles,
+ canManageRoles,
+ canRemove,
+ onUpdateRole,
+ onRemoveMember,
+ searchSpaceId,
+ index,
+}: {
+ member: Membership;
+ roles: Role[];
+ canManageRoles: boolean;
+ 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 = !member.is_owner && (canManageRoles || canRemove);
+
+ return (
+
+
+
+
+ {member.user_avatar_url ? (
+
+ ) : (
+
+ {initials}
+
+ )}
+
+
+
{displayName}
+ {member.user_display_name && member.user_email && (
+
+ {member.user_email}
+
+ )}
+
+
+
+
+
+ {formatRelativeDate(member.joined_at)}
+
+
+
+ {showActions ? (
+
+
+
+
+ e.preventDefault()}
+ className="min-w-[120px] bg-muted dark:border dark:border-neutral-700"
+ >
+ {canManageRoles &&
+ roles
+ .filter((r) => r.name !== "Owner")
+ .map((role) => (
+ onUpdateRole(member.id, role.id)}
+ >
+ Make {role.name}
+
+ ))}
+ {canRemove && (
+
+
+ e.preventDefault()}
+ >
+ 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`)
+ }
+ >
+ Manage Roles
+
+
+
+ ) : (
+ {roleName}
+ )}
+
+
+ );
+}
+
// ============ Create Invite Dialog ============
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);
@@ -1369,23 +645,28 @@ function CreateInviteDialog({
const [createdInvite, setCreatedInvite] = useState(null);
const [copiedLink, setCopiedLink] = useState(false);
+ const assignableRoles = useMemo(() => roles.filter((r) => r.name !== "Owner"), [roles]);
+ const defaultRole = useMemo(() => assignableRoles.find((r) => r.is_default), [assignableRoles]);
+
+ useEffect(() => {
+ if (defaultRole && !roleId) {
+ setRoleId(defaultRole.id.toString());
+ }
+ }, [defaultRole, roleId]);
+
const handleCreate = async () => {
setCreating(true);
try {
const data: CreateInviteRequest["data"] = {};
if (name) data.name = name;
- if (roleId && roleId !== "default") data.role_id = Number(roleId);
+ if (roleId) data.role_id = Number(roleId);
if (maxUses) data.max_uses = Number(maxUses);
if (expiresAt) data.expires_at = expiresAt.toISOString();
const invite = await onCreateInvite(data);
setCreatedInvite(invite);
- // Track invite sent event
- const roleName =
- roleId && roleId !== "default"
- ? 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,
@@ -1401,7 +682,7 @@ function CreateInviteDialog({
const handleClose = () => {
setOpen(false);
setName("");
- setRoleId("");
+ setRoleId(defaultRole?.id.toString() ?? "");
setMaxUses("");
setExpiresAt(undefined);
setCreatedInvite(null);
@@ -1419,12 +700,18 @@ function CreateInviteDialog({
return (
-
{createdInvite.role?.name || "Default role"}
{createdInvite.max_uses && (
@@ -1475,7 +757,7 @@ function CreateInviteDialog({
) : (
<>
- Create Invite Link
+ Invite Members
Create a link to invite people to this search space.
@@ -1494,20 +776,19 @@ function CreateInviteDialog({
@@ -1573,859 +854,123 @@ function CreateInviteDialog({
);
}
-// ============ Create Role Section ============
+// ============ All Invites Dialog ============
-// 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,
+function AllInvitesDialog({
+ invites,
+ onRevokeInvite,
}: {
- groupedPermissions: Record;
- onCreateRole: (data: CreateRoleRequest["data"]) => Promise;
- onCancel: () => void;
+ invites: Invite[];
+ onRevokeInvite: (inviteId: number) => Promise;
}) {
- 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([]);
+ const [copiedId, setCopiedId] = useState(null);
- // 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 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);
};
- 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 (
-
-
-
-
-
-
-
+
);
}
diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx
index 143508977..bac5c44a3 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 ? (
@@ -374,7 +374,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
-
+
Go to Settings
diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx
index b8f2eb1cf..5e2a0e437 100644
--- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx
+++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx
@@ -120,7 +120,7 @@ const DocumentUploadPopupContent: FC<{
return (