mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat: implement team management dialog and refactor team page structure
- Introduced `TeamDialog` component to manage team members within a modal. - Created `TeamContent` component to encapsulate team management logic and UI. - Refactored `TeamManagementPage` to utilize the new `TeamContent` component, improving code organization. - Added `teamDialogAtom` for managing the open state of the team dialog. - Updated `LayoutDataProvider` to integrate the new team dialog functionality.
This commit is contained in:
parent
b7d684ca8d
commit
03aa653646
8 changed files with 1000 additions and 1046 deletions
|
|
@ -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<boolean> => {
|
||||
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<Membership> => {
|
||||
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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-background select-none"
|
||||
>
|
||||
<div className="container max-w-5xl mx-auto p-4 md:p-6 lg:p-8 pt-20 md:pt-24 lg:pt-28">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-36 rounded-md" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/40">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[30%] px-4 md:px-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SKELETON_KEYS.map((id) => (
|
||||
<TableRow key={id} className="border-b border-border/40 hover:bg-transparent">
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-[60%]" />
|
||||
<Skeleton className="h-3 w-[40%]" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 border-r border-border/40">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[30%] py-2.5 px-4 md:px-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-background select-none"
|
||||
>
|
||||
<div className="bg-background select-none">
|
||||
<div className="container max-w-5xl mx-auto p-4 md:p-6 lg:p-8 pt-20 md:pt-24 lg:pt-28">
|
||||
<div className="space-y-6">
|
||||
{/* Header row: Invite button on left, member count on right */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{canInvite && (
|
||||
<CreateInviteDialog
|
||||
roles={roles}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
)}
|
||||
{canInvite && activeInvites.length > 0 && (
|
||||
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
||||
)}
|
||||
</div>
|
||||
<p className="hidden md:block text-sm text-muted-foreground">
|
||||
{members.length} {members.length === 1 ? "member" : "members"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Members & Invites Table */}
|
||||
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/40">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<User size={14} className="opacity-60 text-muted-foreground" />
|
||||
Name
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Clock size={14} className="opacity-60 text-muted-foreground" />
|
||||
Last logged in
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[30%] px-4 md:px-6">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70 justify-end">
|
||||
<ShieldUser size={14} className="opacity-60 text-muted-foreground" />
|
||||
Role
|
||||
</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{owners.map((member, index) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
canRemove={canRemove}
|
||||
onUpdateRole={handleUpdateMember}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{paginatedMembers.map((member, index) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
canRemove={canRemove}
|
||||
onUpdateRole={handleUpdateMember}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
index={owners.length + index}
|
||||
/>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">No members yet</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalItems > PAGE_SIZE && (
|
||||
<motion.div
|
||||
className="flex items-center justify-end gap-3 py-3 px-2"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.3 }}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{displayStart}-{displayEnd} of {totalItems}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex(0)}
|
||||
disabled={!canPrev}
|
||||
aria-label="Go to first page"
|
||||
>
|
||||
<ChevronFirst size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={!canPrev}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex((i) => (canNext ? i + 1 : i))}
|
||||
disabled={!canNext}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<ChevronRight size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex(lastPage)}
|
||||
disabled={!canNext}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<ChevronLast size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<TeamContent searchSpaceId={searchSpaceId} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 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<Membership>;
|
||||
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
||||
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 (
|
||||
<motion.tr
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.2, delay: index * 0.02 } }}
|
||||
className="border-b border-border/40 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0">
|
||||
{member.user_avatar_url ? (
|
||||
<Image
|
||||
src={member.user_avatar_url}
|
||||
alt={displayName}
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-full flex items-center justify-center text-white font-medium text-sm",
|
||||
avatarColor
|
||||
)}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate select-text">{displayName}</p>
|
||||
{member.user_display_name && member.user_email && (
|
||||
<p className="text-xs text-muted-foreground truncate select-text">
|
||||
{member.user_email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
|
||||
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
|
||||
{showActions ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{roleName}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => 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) => (
|
||||
<DropdownMenuItem
|
||||
key={role.id}
|
||||
onClick={() => onUpdateRole(member.id, role.id)}
|
||||
>
|
||||
Make {role.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{canRemove && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove <span className="font-medium">{member.user_email}</span>{" "}
|
||||
from this search space. They will lose access to all resources.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRemoveMember(member.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<DropdownMenuSeparator className="dark:bg-white/5" />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setSearchSpaceSettingsDialog({
|
||||
open: true,
|
||||
initialTab: "team-roles",
|
||||
})
|
||||
}
|
||||
>
|
||||
Manage Roles
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{roleName}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Create Invite Dialog ============
|
||||
|
||||
function CreateInviteDialog({
|
||||
roles,
|
||||
onCreateInvite,
|
||||
searchSpaceId,
|
||||
}: {
|
||||
roles: Role[];
|
||||
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
|
||||
searchSpaceId: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [roleId, setRoleId] = useState<string>("");
|
||||
const [maxUses, setMaxUses] = useState<string>("");
|
||||
const [expiresAt, setExpiresAt] = useState<Date | undefined>(undefined);
|
||||
const [createdInvite, setCreatedInvite] = useState<Invite | null>(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 (
|
||||
<Dialog open={open} onOpenChange={(v) => (v ? setOpen(true) : handleClose())}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-black text-white dark:bg-white dark:text-black hover:bg-black/90 dark:hover:bg-white/90"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="w-[92vw] max-w-[92vw] sm:max-w-md p-4 md:p-6 select-none"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{createdInvite ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5 text-emerald-500" />
|
||||
Invite Created!
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share this link to invite people to your search space.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg">
|
||||
<code className="flex-1 min-w-0 text-sm break-all">
|
||||
{window.location.origin}/invite/{createdInvite.invite_code}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={copyLink} className="shrink-0">
|
||||
{copiedLink ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
{createdInvite.role?.name || "Default role"}
|
||||
</span>
|
||||
{createdInvite.max_uses && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="h-3 w-3" />
|
||||
Max {createdInvite.max_uses} uses
|
||||
</span>
|
||||
)}
|
||||
{createdInvite.expires_at && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Expires {new Date(createdInvite.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Members</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a link to invite people to this search space.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-name">Name (optional)</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
placeholder="e.g., Marketing team invite"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-role">Role</Label>
|
||||
<Select value={roleId} onValueChange={setRoleId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Assign a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{assignableRoles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
<span className="flex items-center gap-2">
|
||||
{role.name}
|
||||
{role.is_default && (
|
||||
<span className="text-xs text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col md:grid md:grid-cols-2 gap-3 md:gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-uses">Max uses (optional)</Label>
|
||||
<Input
|
||||
id="max-uses"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Unlimited"
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Expires on (optional)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal bg-transparent",
|
||||
!expiresAt && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
{expiresAt ? expiresAt.toLocaleDateString() : "Never"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<CalendarComponent
|
||||
mode="single"
|
||||
selected={expiresAt}
|
||||
onSelect={setExpiresAt}
|
||||
disabled={(date) => date < new Date()}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating
|
||||
</>
|
||||
) : (
|
||||
"Create Invite"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ All Invites Dialog ============
|
||||
|
||||
function AllInvitesDialog({
|
||||
invites,
|
||||
onRevokeInvite,
|
||||
}: {
|
||||
invites: Invite[];
|
||||
onRevokeInvite: (inviteId: number) => Promise<boolean>;
|
||||
}) {
|
||||
const [copiedId, setCopiedId] = useState<number | null>(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 (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" className="gap-2">
|
||||
<Link2 className="h-4 w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200 text-xs font-medium">
|
||||
{invites.length}
|
||||
</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[92vw] max-w-[92vw] sm:max-w-lg p-4 md:p-6 select-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">Active Invite Links</DialogTitle>
|
||||
<DialogDescription>
|
||||
{invites.length} active {invites.length === 1 ? "invite" : "invites"}. Copy a link or
|
||||
revoke access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[320px] overflow-y-auto -mx-1 px-1 space-y-3 py-2">
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="rounded-lg border border-border/40 p-3 space-y-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{invite.name || "Unnamed invite"}</p>
|
||||
<div className="flex flex-wrap gap-x-2 text-xs text-muted-foreground shrink-0">
|
||||
{invite.role?.name && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5">{invite.role.name}</span>
|
||||
)}
|
||||
{invite.max_uses != null && (
|
||||
<span className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5">
|
||||
<Hash className="h-3 w-3" />
|
||||
{invite.uses_count}/{invite.max_uses}
|
||||
</span>
|
||||
)}
|
||||
{invite.expires_at && (
|
||||
<span className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(invite.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke invite?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this invite link. Anyone with this link will no
|
||||
longer be able to join.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRevokeInvite(invite.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
|
||||
<div className="flex-1 min-w-0 overflow-x-auto scrollbar-hide">
|
||||
<code className="text-sm select-all whitespace-nowrap">
|
||||
{typeof window !== "undefined"
|
||||
? `${window.location.origin}/invite/${invite.invite_code}`
|
||||
: `/invite/${invite.invite_code}`}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => copyLink(invite)}
|
||||
>
|
||||
{copiedId === invite.id ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> => {
|
||||
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<Membership> => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-36 rounded-md" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/40">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[30%] px-4 md:px-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SKELETON_KEYS.map((id) => (
|
||||
<TableRow key={id} className="border-b border-border/40 hover:bg-transparent">
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-[60%]" />
|
||||
<Skeleton className="h-3 w-[40%]" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 border-r border-border/40">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[30%] py-2.5 px-4 md:px-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canInvite && (
|
||||
<CreateInviteDialog
|
||||
roles={roles}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
)}
|
||||
{canInvite && activeInvites.length > 0 && (
|
||||
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
||||
)}
|
||||
<p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{members.length} {members.length === 1 ? "member" : "members"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/40">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<User size={14} className="opacity-60 text-muted-foreground" />
|
||||
Name
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Clock size={14} className="opacity-60 text-muted-foreground" />
|
||||
Last logged in
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[30%] px-4 md:px-6">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70 justify-end">
|
||||
<ShieldUser size={14} className="opacity-60 text-muted-foreground" />
|
||||
Role
|
||||
</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{owners.map((member) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
canRemove={canRemove}
|
||||
onUpdateRole={handleUpdateMember}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
))}
|
||||
{paginatedMembers.map((member) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
canRemove={canRemove}
|
||||
onUpdateRole={handleUpdateMember}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">No members yet</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalItems > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-end gap-3 py-3 px-2">
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{displayStart}-{displayEnd} of {totalItems}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex(0)}
|
||||
disabled={!canPrev}
|
||||
aria-label="Go to first page"
|
||||
>
|
||||
<ChevronFirst size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={!canPrev}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex((i) => (canNext ? i + 1 : i))}
|
||||
disabled={!canNext}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<ChevronRight size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex(lastPage)}
|
||||
disabled={!canNext}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<ChevronLast size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({
|
||||
member,
|
||||
roles,
|
||||
canManageRoles,
|
||||
canRemove,
|
||||
onUpdateRole,
|
||||
onRemoveMember,
|
||||
}: {
|
||||
member: Membership;
|
||||
roles: Role[];
|
||||
canManageRoles: boolean;
|
||||
canRemove: boolean;
|
||||
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
|
||||
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
||||
}) {
|
||||
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 (
|
||||
<TableRow className="border-b border-border/40 transition-colors hover:bg-muted/30">
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0">
|
||||
{member.user_avatar_url ? (
|
||||
<Image
|
||||
src={member.user_avatar_url}
|
||||
alt={displayName}
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-full flex items-center justify-center text-white font-medium text-sm",
|
||||
avatarColor
|
||||
)}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate select-text">{displayName}</p>
|
||||
{member.user_display_name && member.user_email && (
|
||||
<p className="text-xs text-muted-foreground truncate select-text">
|
||||
{member.user_email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
|
||||
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
|
||||
{showActions ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{roleName}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => 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) => (
|
||||
<DropdownMenuItem
|
||||
key={role.id}
|
||||
onClick={() => onUpdateRole(member.id, role.id)}
|
||||
>
|
||||
Make {role.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{canRemove && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove <span className="font-medium">{member.user_email}</span>{" "}
|
||||
from this search space. They will lose access to all resources.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRemoveMember(member.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<DropdownMenuSeparator className="dark:bg-white/5" />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setSearchSpaceSettingsDialog({
|
||||
open: true,
|
||||
initialTab: "team-roles",
|
||||
})
|
||||
}
|
||||
>
|
||||
Manage Roles
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{roleName}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateInviteDialog({
|
||||
roles,
|
||||
onCreateInvite,
|
||||
searchSpaceId,
|
||||
}: {
|
||||
roles: Role[];
|
||||
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
|
||||
searchSpaceId: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [roleId, setRoleId] = useState<string>("");
|
||||
const [maxUses, setMaxUses] = useState<string>("");
|
||||
const [expiresAt, setExpiresAt] = useState<Date | undefined>(undefined);
|
||||
const [createdInvite, setCreatedInvite] = useState<Invite | null>(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 (
|
||||
<Dialog open={open} onOpenChange={(v) => (v ? setOpen(true) : handleClose())}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black hover:bg-black/90 dark:hover:bg-white/90"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="w-[92vw] max-w-[92vw] sm:max-w-md p-4 md:p-6 select-none"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{createdInvite ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5 text-emerald-500" />
|
||||
Invite Created!
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share this link to invite people to your search space.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg">
|
||||
<code className="flex-1 min-w-0 text-sm break-all">
|
||||
{window.location.origin}/invite/{createdInvite.invite_code}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={copyLink} className="shrink-0">
|
||||
{copiedLink ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
{createdInvite.role?.name || "Default role"}
|
||||
</span>
|
||||
{createdInvite.max_uses && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="h-3 w-3" />
|
||||
Max {createdInvite.max_uses} uses
|
||||
</span>
|
||||
)}
|
||||
{createdInvite.expires_at && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Expires {new Date(createdInvite.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Members</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a link to invite people to this search space.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-name">Name (optional)</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
placeholder="e.g., Marketing team invite"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-role">Role</Label>
|
||||
<Select value={roleId} onValueChange={setRoleId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Assign a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{assignableRoles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
<span className="flex items-center gap-2">
|
||||
{role.name}
|
||||
{role.is_default && (
|
||||
<span className="text-xs text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col md:grid md:grid-cols-2 gap-3 md:gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-uses">Max uses (optional)</Label>
|
||||
<Input
|
||||
id="max-uses"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Unlimited"
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Expires on (optional)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal bg-transparent",
|
||||
!expiresAt && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
{expiresAt ? expiresAt.toLocaleDateString() : "Never"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<CalendarComponent
|
||||
mode="single"
|
||||
selected={expiresAt}
|
||||
onSelect={setExpiresAt}
|
||||
disabled={(date) => date < new Date()}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating
|
||||
</>
|
||||
) : (
|
||||
"Create Invite"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AllInvitesDialog({
|
||||
invites,
|
||||
onRevokeInvite,
|
||||
}: {
|
||||
invites: Invite[];
|
||||
onRevokeInvite: (inviteId: number) => Promise<boolean>;
|
||||
}) {
|
||||
const [copiedId, setCopiedId] = useState<number | null>(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 (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" size="sm" className="gap-1.5 md:gap-2 text-xs md:text-sm">
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200 text-[10px] md:text-xs font-medium">
|
||||
{invites.length}
|
||||
</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[92vw] max-w-[92vw] sm:max-w-lg p-4 md:p-6 select-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">Active Invite Links</DialogTitle>
|
||||
<DialogDescription>
|
||||
{invites.length} active {invites.length === 1 ? "invite" : "invites"}. Copy a link or
|
||||
revoke access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[320px] overflow-y-auto -mx-1 px-1 space-y-3 py-2">
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="rounded-lg border border-border/40 p-3 space-y-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{invite.name || "Unnamed invite"}</p>
|
||||
<div className="flex flex-wrap gap-x-2 text-xs text-muted-foreground shrink-0">
|
||||
{invite.role?.name && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5">{invite.role.name}</span>
|
||||
)}
|
||||
{invite.max_uses != null && (
|
||||
<span className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5">
|
||||
<Hash className="h-3 w-3" />
|
||||
{invite.uses_count}/{invite.max_uses}
|
||||
</span>
|
||||
)}
|
||||
{invite.expires_at && (
|
||||
<span className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(invite.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke invite?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this invite link. Anyone with this link will no
|
||||
longer be able to join.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRevokeInvite(invite.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
|
||||
<div className="flex-1 min-w-0 overflow-x-auto scrollbar-hide">
|
||||
<code className="text-sm select-all whitespace-nowrap">
|
||||
{typeof window !== "undefined"
|
||||
? `${window.location.origin}/invite/${invite.invite_code}`
|
||||
: `/invite/${invite.invite_code}`}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => copyLink(invite)}
|
||||
>
|
||||
{copiedId === invite.id ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,3 +19,5 @@ export const userSettingsDialogAtom = atom<UserSettingsDialogState>({
|
|||
open: false,
|
||||
initialTab: "profile",
|
||||
});
|
||||
|
||||
export const teamDialogAtom = atom<boolean>(false);
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
<UserSettingsDialog />
|
||||
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
|
|
@ -160,39 +152,17 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || saving}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
{t("general_reset")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving || !name.trim()}
|
||||
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
{saving ? t("general_saving") : t("general_save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<Alert
|
||||
variant="default"
|
||||
className="bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800 py-3 md:py-4"
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end pt-3 md:pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving || !name.trim()}
|
||||
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||
>
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-500 shrink-0" />
|
||||
<AlertDescription className="text-blue-800 dark:text-blue-300 text-xs md:text-sm">
|
||||
{t("general_unsaved_changes")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{saving ? t("general_saving") : t("general_save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
|
|
@ -184,38 +177,16 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || saving}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Reset Changes
|
||||
</Button>
|
||||
<div className="flex justify-end pt-3 md:pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
{saving ? "Saving" : "Save Instructions"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<Alert
|
||||
variant="default"
|
||||
className="bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800 py-3 md:py-4"
|
||||
>
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-500 shrink-0" />
|
||||
<AlertDescription className="text-blue-800 dark:text-blue-300 text-xs md:text-sm">
|
||||
You have unsaved changes. Click "Save Instructions" to apply them.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
32
surfsense_web/components/settings/team-dialog.tsx
Normal file
32
surfsense_web/components/settings/team-dialog.tsx
Normal file
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">{t("manage_members")}</DialogTitle>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="px-6 md:px-8 py-6 min-w-0">
|
||||
<TeamContent searchSpaceId={searchSpaceId} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -562,17 +562,17 @@ export function LLMConfigForm({
|
|||
compact ? "justify-end" : "justify-center sm:justify-end"
|
||||
)}
|
||||
>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue