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 78a089b4d..5f39460f0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -1,974 +1,17 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { useAtomValue, useSetAtom } from "jotai"; -import { - Calendar, - Check, - ChevronDown, - ChevronFirst, - ChevronLast, - ChevronLeft, - ChevronRight, - Clock, - Copy, - Hash, - Link2, - ShieldUser, - Trash2, - User, - UserPlus, - Users, -} from "lucide-react"; -import { motion } from "motion/react"; -import Image from "next/image"; import { useParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { - createInviteMutationAtom, - deleteInviteMutationAtom, -} from "@/atoms/invites/invites-mutation.atoms"; -import { - deleteMemberMutationAtom, - updateMemberMutationAtom, -} from "@/atoms/members/members-mutation.atoms"; -import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; -import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { Calendar as CalendarComponent } from "@/components/ui/calendar"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Spinner } from "@/components/ui/spinner"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import type { - CreateInviteRequest, - DeleteInviteRequest, - Invite, -} from "@/contracts/types/invites.types"; -import type { - DeleteMembershipRequest, - Membership, - UpdateMembershipRequest, -} from "@/contracts/types/members.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"; - -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", -]; - -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]; -} - -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; -const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`); +import { TeamContent } from "./team-content"; export default function TeamManagementPage() { const params = useParams(); const searchSpaceId = Number(params.search_space_id); - const { data: access = null, isLoading: accessLoading } = 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: members = [], isLoading: membersLoading } = useAtomValue(membersAtom); - - const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom); - const { mutateAsync: deleteMember } = useAtomValue(deleteMemberMutationAtom); - const { mutateAsync: createInvite } = useAtomValue(createInviteMutationAtom); - const { mutateAsync: revokeInvite } = useAtomValue(deleteInviteMutationAtom); - - const handleRevokeInvite = useCallback( - async (inviteId: number): Promise => { - const request: DeleteInviteRequest = { - search_space_id: searchSpaceId, - invite_id: inviteId, - }; - await revokeInvite(request); - return true; - }, - [revokeInvite, searchSpaceId] - ); - - const handleCreateInvite = useCallback( - async (inviteData: CreateInviteRequest["data"]) => { - const request: CreateInviteRequest = { - search_space_id: searchSpaceId, - data: inviteData, - }; - return await createInvite(request); - }, - [createInvite, 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 }, - }; - return (await updateMember(request)) as Membership; - }, - [updateMember, searchSpaceId] - ); - - const handleRemoveMember = useCallback( - async (membershipId: number) => { - const request: DeleteMembershipRequest = { - search_space_id: searchSpaceId, - membership_id: membershipId, - }; - await deleteMember(request); - return true; - }, - [deleteMember, searchSpaceId] - ); - - const { data: roles = [] } = useQuery({ - queryKey: cacheKeys.roles.all(searchSpaceId.toString()), - queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }), - enabled: !!searchSpaceId, - }); - - const { data: invites = [] } = useQuery({ - queryKey: cacheKeys.invites.all(searchSpaceId.toString()), - queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }), - staleTime: 5 * 60 * 1000, - }); - - 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 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; - - useEffect(() => { - if (members.length > 0 && !membersLoading) { - const ownerCount = members.filter((m) => m.is_owner).length; - trackSearchSpaceUsersViewed(searchSpaceId, members.length, ownerCount); - } - }, [members, membersLoading, searchSpaceId]); - - if (accessLoading || membersLoading) { - return ( - -
-
-
- - -
-
- - - - - - - - - - -
- -
-
-
-
- - {SKELETON_KEYS.map((id) => ( - - -
- -
- - -
-
-
- - - - -
- -
-
-
- ))} -
-
-
-
-
-
- ); - } - return ( - +
-
- {/* Header row: Invite button on left, member count on right */} -
-
- {canInvite && ( - - )} - {canInvite && activeInvites.length > 0 && ( - - )} -
-

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

-
- - {/* Members & Invites Table */} -
- - - - - - - Name - - - - - - Last logged in - - - - - - Role - - - - - - {owners.map((member, index) => ( - - ))} - {paginatedMembers.map((member, index) => ( - - ))} - {members.length === 0 && ( - - -
- -

No members yet

-
-
-
- )} -
-
-
- - {/* Pagination */} - {totalItems > PAGE_SIZE && ( - - - {displayStart}-{displayEnd} of {totalItems} - -
- - - - -
-
- )} -
+
- - ); -} - -// ============ Member Row ============ - -function MemberRow({ - member, - roles, - canManageRoles, - canRemove, - onUpdateRole, - onRemoveMember, - index, -}: { - member: Membership; - roles: Role[]; - canManageRoles: boolean; - canRemove: boolean; - onUpdateRole: (membershipId: number, roleId: number | null) => Promise; - onRemoveMember: (membershipId: number) => Promise; - index: number; -}) { - const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); - 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 ? ( - {displayName} - ) : ( -
- {initials} -
- )} -
-
-

{displayName}

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

- {member.user_email} -

- )} -
-
-
- - - {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} - - - - {showActions ? ( - - - - - e.preventDefault()} - className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5" - > - {canManageRoles && - roles - .filter((r) => r.name !== "Owner") - .map((role) => ( - onUpdateRole(member.id, role.id)} - > - Make {role.name} - - ))} - {canRemove && ( - - - e.preventDefault()} - className="text-destructive focus:text-destructive" - > - 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 - - - - - )} - - - setSearchSpaceSettingsDialog({ - open: true, - initialTab: "team-roles", - }) - } - > - Manage Roles - - - - ) : ( - {roleName} - )} - -
- ); -} - -// ============ Create Invite Dialog ============ - -function CreateInviteDialog({ - roles, - onCreateInvite, - searchSpaceId, -}: { - roles: Role[]; - onCreateInvite: (data: CreateInviteRequest["data"]) => Promise; - searchSpaceId: number; -}) { - const [open, setOpen] = useState(false); - const [creating, setCreating] = useState(false); - const [name, setName] = useState(""); - const [roleId, setRoleId] = useState(""); - const [maxUses, setMaxUses] = useState(""); - const [expiresAt, setExpiresAt] = useState(undefined); - 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) 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 ? roles.find((r) => r.id.toString() === roleId)?.name : undefined; - trackSearchSpaceInviteSent(searchSpaceId, { - roleName, - hasExpiry: !!expiresAt, - hasMaxUses: !!maxUses, - }); - } catch (error) { - console.error("Failed to create invite:", error); - } finally { - setCreating(false); - } - }; - - const handleClose = () => { - setOpen(false); - setName(""); - setRoleId(defaultRole?.id.toString() ?? ""); - setMaxUses(""); - setExpiresAt(undefined); - setCreatedInvite(null); - setCopiedLink(false); - }; - - const copyLink = () => { - if (!createdInvite) return; - const link = `${window.location.origin}/invite/${createdInvite.invite_code}`; - navigator.clipboard.writeText(link); - setCopiedLink(true); - toast.success("Invite link copied to clipboard"); - }; - - return ( - (v ? setOpen(true) : handleClose())}> - - - - e.preventDefault()} - > - {createdInvite ? ( - <> - - - - Invite Created! - - - Share this link to invite people to your search space. - - -
-
- - {window.location.origin}/invite/{createdInvite.invite_code} - - -
-
- - {createdInvite.role?.name || "Default role"} - - {createdInvite.max_uses && ( - - - Max {createdInvite.max_uses} uses - - )} - {createdInvite.expires_at && ( - - - Expires {new Date(createdInvite.expires_at).toLocaleDateString()} - - )} -
-
- - - - - ) : ( - <> - - Invite Members - - Create a link to invite people to this search space. - - -
-
- - setName(e.target.value)} - /> -
-
- - -
-
-
- - setMaxUses(e.target.value)} - /> -
-
- - - - - - - date < new Date()} - initialFocus - /> - - -
-
-
- - - - - - )} -
-
- ); -} - -// ============ 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/app/dashboard/[search_space_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx new file mode 100644 index 000000000..75400fee5 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx @@ -0,0 +1,932 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue, useSetAtom } from "jotai"; +import { + Calendar, + Check, + ChevronDown, + ChevronFirst, + ChevronLast, + ChevronLeft, + ChevronRight, + Clock, + Copy, + Hash, + Link2, + ShieldUser, + Trash2, + User, + UserPlus, + Users, +} from "lucide-react"; +import Image from "next/image"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { + createInviteMutationAtom, + deleteInviteMutationAtom, +} from "@/atoms/invites/invites-mutation.atoms"; +import { + deleteMemberMutationAtom, + updateMemberMutationAtom, +} from "@/atoms/members/members-mutation.atoms"; +import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Calendar as CalendarComponent } from "@/components/ui/calendar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { + CreateInviteRequest, + DeleteInviteRequest, + Invite, +} from "@/contracts/types/invites.types"; +import type { + DeleteMembershipRequest, + Membership, + UpdateMembershipRequest, +} from "@/contracts/types/members.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"; + +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", +]; + +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]; +} + +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; +const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`); + +interface TeamContentProps { + searchSpaceId: number; +} + +export function TeamContent({ searchSpaceId }: TeamContentProps) { + const { data: access = null, isLoading: accessLoading } = 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: members = [], isLoading: membersLoading } = useAtomValue(membersAtom); + + const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom); + const { mutateAsync: deleteMember } = useAtomValue(deleteMemberMutationAtom); + const { mutateAsync: createInvite } = useAtomValue(createInviteMutationAtom); + const { mutateAsync: revokeInvite } = useAtomValue(deleteInviteMutationAtom); + + const handleRevokeInvite = useCallback( + async (inviteId: number): Promise => { + const request: DeleteInviteRequest = { + search_space_id: searchSpaceId, + invite_id: inviteId, + }; + await revokeInvite(request); + return true; + }, + [revokeInvite, searchSpaceId] + ); + + const handleCreateInvite = useCallback( + async (inviteData: CreateInviteRequest["data"]) => { + const request: CreateInviteRequest = { + search_space_id: searchSpaceId, + data: inviteData, + }; + return await createInvite(request); + }, + [createInvite, 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 }, + }; + return (await updateMember(request)) as Membership; + }, + [updateMember, searchSpaceId] + ); + + const handleRemoveMember = useCallback( + async (membershipId: number) => { + const request: DeleteMembershipRequest = { + search_space_id: searchSpaceId, + membership_id: membershipId, + }; + await deleteMember(request); + return true; + }, + [deleteMember, searchSpaceId] + ); + + const { data: roles = [] } = useQuery({ + queryKey: cacheKeys.roles.all(searchSpaceId.toString()), + queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }), + enabled: !!searchSpaceId, + }); + + const { data: invites = [] } = useQuery({ + queryKey: cacheKeys.invites.all(searchSpaceId.toString()), + queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }), + staleTime: 5 * 60 * 1000, + }); + + 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 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; + + useEffect(() => { + if (members.length > 0 && !membersLoading) { + const ownerCount = members.filter((m) => m.is_owner).length; + trackSearchSpaceUsersViewed(searchSpaceId, members.length, ownerCount); + } + }, [members, membersLoading, searchSpaceId]); + + if (accessLoading || membersLoading) { + return ( +
+
+ + +
+
+ + + + + + + + + + +
+ +
+
+
+
+ + {SKELETON_KEYS.map((id) => ( + + +
+ +
+ + +
+
+
+ + + + +
+ +
+
+
+ ))} +
+
+
+
+ ); + } + + return ( +
+
+ {canInvite && ( + + )} + {canInvite && activeInvites.length > 0 && ( + + )} +

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

+
+ +
+ + + + + + + Name + + + + + + Last logged in + + + + + + Role + + + + + + {owners.map((member) => ( + + ))} + {paginatedMembers.map((member) => ( + + ))} + {members.length === 0 && ( + + +
+ +

No members yet

+
+
+
+ )} +
+
+
+ + {totalItems > PAGE_SIZE && ( +
+ + {displayStart}-{displayEnd} of {totalItems} + +
+ + + + +
+
+ )} +
+ ); +} + +function MemberRow({ + member, + roles, + canManageRoles, + canRemove, + onUpdateRole, + onRemoveMember, +}: { + member: Membership; + roles: Role[]; + canManageRoles: boolean; + canRemove: boolean; + onUpdateRole: (membershipId: number, roleId: number | null) => Promise; + onRemoveMember: (membershipId: number) => Promise; +}) { + const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); + 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 ? ( + {displayName} + ) : ( +
+ {initials} +
+ )} +
+
+

{displayName}

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

+ {member.user_email} +

+ )} +
+
+
+ + + {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} + + + + {showActions ? ( + + + + + e.preventDefault()} + className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5" + > + {canManageRoles && + roles + .filter((r) => r.name !== "Owner") + .map((role) => ( + onUpdateRole(member.id, role.id)} + > + Make {role.name} + + ))} + {canRemove && ( + + + e.preventDefault()} + className="text-destructive focus:text-destructive" + > + 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 + + + + + )} + + + setSearchSpaceSettingsDialog({ + open: true, + initialTab: "team-roles", + }) + } + > + Manage Roles + + + + ) : ( + {roleName} + )} + +
+ ); +} + +function CreateInviteDialog({ + roles, + onCreateInvite, + searchSpaceId, +}: { + roles: Role[]; + onCreateInvite: (data: CreateInviteRequest["data"]) => Promise; + searchSpaceId: number; +}) { + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [name, setName] = useState(""); + const [roleId, setRoleId] = useState(""); + const [maxUses, setMaxUses] = useState(""); + const [expiresAt, setExpiresAt] = useState(undefined); + 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) 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 ? roles.find((r) => r.id.toString() === roleId)?.name : undefined; + trackSearchSpaceInviteSent(searchSpaceId, { + roleName, + hasExpiry: !!expiresAt, + hasMaxUses: !!maxUses, + }); + } catch (error) { + console.error("Failed to create invite:", error); + } finally { + setCreating(false); + } + }; + + const handleClose = () => { + setOpen(false); + setName(""); + setRoleId(defaultRole?.id.toString() ?? ""); + setMaxUses(""); + setExpiresAt(undefined); + setCreatedInvite(null); + setCopiedLink(false); + }; + + const copyLink = () => { + if (!createdInvite) return; + const link = `${window.location.origin}/invite/${createdInvite.invite_code}`; + navigator.clipboard.writeText(link); + setCopiedLink(true); + toast.success("Invite link copied to clipboard"); + }; + + return ( + (v ? setOpen(true) : handleClose())}> + + + + e.preventDefault()} + > + {createdInvite ? ( + <> + + + + Invite Created! + + + Share this link to invite people to your search space. + + +
+
+ + {window.location.origin}/invite/{createdInvite.invite_code} + + +
+
+ + {createdInvite.role?.name || "Default role"} + + {createdInvite.max_uses && ( + + + Max {createdInvite.max_uses} uses + + )} + {createdInvite.expires_at && ( + + + Expires {new Date(createdInvite.expires_at).toLocaleDateString()} + + )} +
+
+ + + + + ) : ( + <> + + Invite Members + + Create a link to invite people to this search space. + + +
+
+ + setName(e.target.value)} + /> +
+
+ + +
+
+
+ + setMaxUses(e.target.value)} + /> +
+
+ + + + + + + date < new Date()} + initialFocus + /> + + +
+
+
+ + + + + + )} +
+
+ ); +} + +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/atoms/settings/settings-dialog.atoms.ts b/surfsense_web/atoms/settings/settings-dialog.atoms.ts index 4b573808c..3b49f1f06 100644 --- a/surfsense_web/atoms/settings/settings-dialog.atoms.ts +++ b/surfsense_web/atoms/settings/settings-dialog.atoms.ts @@ -19,3 +19,5 @@ export const userSettingsDialogAtom = atom({ open: false, initialTab: "profile", }); + +export const teamDialogAtom = atom(false); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 5de9ab2cd..10a5bacfe 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -16,6 +16,7 @@ import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-spac import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { searchSpaceSettingsDialogAtom, + teamDialogAtom, userSettingsDialogAtom, } from "@/atoms/settings/settings-dialog.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; @@ -52,6 +53,7 @@ import { cleanupElectric } from "@/lib/electric/client"; import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog"; +import { TeamDialog } from "@/components/settings/team-dialog"; import { UserSettingsDialog } from "@/components/settings/user-settings-dialog"; import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types"; import { CreateSearchSpaceDialog } from "../ui/dialogs"; @@ -398,6 +400,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); + const setTeamDialogOpen = useSetAtom(teamDialogAtom); const handleUserSettings = useCallback(() => { setUserSettingsDialog({ open: true, initialTab: "profile" }); @@ -595,8 +598,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid }, [setSearchSpaceSettingsDialog]); const handleManageMembers = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/team`); - }, [router, searchSpaceId]); + setTeamDialogOpen(true); + }, [setTeamDialogOpen]); const handleLogout = useCallback(async () => { try { @@ -947,6 +950,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid {/* Settings Dialogs */} + ); } diff --git a/surfsense_web/components/settings/general-settings-manager.tsx b/surfsense_web/components/settings/general-settings-manager.tsx index 8c9a43780..a9482001d 100644 --- a/surfsense_web/components/settings/general-settings-manager.tsx +++ b/surfsense_web/components/settings/general-settings-manager.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { Info, RotateCcw, Save } from "lucide-react"; +import { Info } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -81,14 +81,6 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager } }; - const handleReset = () => { - if (searchSpace) { - setName(searchSpace.name || ""); - setDescription(searchSpace.description || ""); - setHasChanges(false); - } - }; - if (loading) { return (
@@ -160,39 +152,17 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager - {/* Action Buttons */} -
- - -
- - {hasChanges && ( - + +
); } diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx index 91db93994..49da0a43e 100644 --- a/surfsense_web/components/settings/prompt-config-manager.tsx +++ b/surfsense_web/components/settings/prompt-config-manager.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { AlertTriangle, Info, RotateCcw, Save } from "lucide-react"; +import { AlertTriangle, Info } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -83,13 +83,6 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) } }; - const handleReset = () => { - if (searchSpace) { - setCustomInstructions(searchSpace.qna_custom_instructions || ""); - setHasChanges(false); - } - }; - if (loading) { return (
@@ -184,38 +177,16 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {/* Action Buttons */} -
- +
- - {hasChanges && ( - - - - You have unsaved changes. Click "Save Instructions" to apply them. - - - )}
); } diff --git a/surfsense_web/components/settings/team-dialog.tsx b/surfsense_web/components/settings/team-dialog.tsx new file mode 100644 index 000000000..681964afc --- /dev/null +++ b/surfsense_web/components/settings/team-dialog.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useAtom } from "jotai"; +import { useTranslations } from "next-intl"; +import { teamDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { TeamContent } from "@/app/dashboard/[search_space_id]/team/team-content"; + +interface TeamDialogProps { + searchSpaceId: number; +} + +export function TeamDialog({ searchSpaceId }: TeamDialogProps) { + const t = useTranslations("sidebar"); + const [open, setOpen] = useAtom(teamDialogAtom); + + return ( + + e.preventDefault()} + > + {t("manage_members")} +
+
+ +
+
+
+
+ ); +} diff --git a/surfsense_web/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx index 42dd5e036..c423edf33 100644 --- a/surfsense_web/components/shared/llm-config-form.tsx +++ b/surfsense_web/components/shared/llm-config-form.tsx @@ -562,17 +562,17 @@ export function LLMConfigForm({ compact ? "justify-end" : "justify-center sm:justify-end" )} > - {onCancel && ( - - )} + {onCancel && ( + + )}