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] 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 && (
+
+
+
+
-
-
- {members.map((member) => (
-
- ))}
- {activeInvites.map((invite) => (
-
- ))}
- {members.length === 0 && activeInvites.length === 0 && (
-
-
-
-
-
- )}
-
-
+ )}
+
+
+
+ {/* 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 ? (
e.preventDefault()}>
- {canManageRoles && (
- <>
- {roles.map((role) => (
+ {canManageRoles &&
+ roles
+ .filter((r) => r.name !== "Owner")
+ .map((role) => (
onUpdateRole(member.id, role.id)}
- className={cn(
- member.role_id === role.id && "bg-accent"
- )}
>
- {role.name}
+ Make {role.name}
))}
- onUpdateRole(member.id, null)}
- className={cn(!member.role_id && "bg-accent")}
- >
- No role
-
- >
- )}
- {canManageRoles && canRemove && }
{canRemove && (
@@ -448,41 +608,45 @@ function MemberRow({
className="text-destructive focus:text-destructive"
onSelect={(e) => e.preventDefault()}
>
-
- Remove member
+ 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`)}
+ >
+ Manage Roles
+
) : (
-
+
{roleName}
- {!member.is_owner && }
)}
-
+
);
}
@@ -492,18 +656,24 @@ 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 (
-
-
+
+
-
+
Never
-
+
{canRevoke ? (
@@ -548,7 +718,7 @@ function InviteRow({
toast.success("Invite link copied");
}}
>
-
+
Copy invite link
@@ -558,7 +728,7 @@ function InviteRow({
className="text-destructive focus:text-destructive"
onSelect={(e) => e.preventDefault()}
>
-
+
Revoke invite
@@ -587,7 +757,7 @@ function InviteRow({
Invited
)}
-
+
);
}
@@ -611,22 +781,30 @@ 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);
- 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,
@@ -642,7 +820,7 @@ function CreateInviteDialog({
const handleClose = () => {
setOpen(false);
setName("");
- setRoleId("");
+ setRoleId(defaultRole?.id.toString() ?? "");
setMaxUses("");
setExpiresAt(undefined);
setCreatedInvite(null);
@@ -692,7 +870,6 @@ function CreateInviteDialog({
-
{createdInvite.role?.name || "Default role"}
{createdInvite.max_uses && (
@@ -735,20 +912,19 @@ function CreateInviteDialog({