mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat: implement pagination and loading skeletons for team management page
This commit is contained in:
parent
9f08f7c9c2
commit
d76380334f
1 changed files with 317 additions and 141 deletions
|
|
@ -6,19 +6,22 @@ import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronFirst,
|
||||||
|
ChevronLast,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
Crown,
|
|
||||||
Hash,
|
Hash,
|
||||||
Shield,
|
ShieldUser,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserMinus,
|
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
User,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -69,6 +72,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -143,6 +147,8 @@ function getInviteInitials(invite: Invite): string {
|
||||||
return "IN";
|
return "IN";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
export default function TeamManagementPage() {
|
export default function TeamManagementPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
|
|
@ -237,6 +243,39 @@ export default function TeamManagementPage() {
|
||||||
const canManageRoles = hasPermission("members:manage_roles");
|
const canManageRoles = hasPermission("members:manage_roles");
|
||||||
const canRemove = hasPermission("members:remove");
|
const canRemove = hasPermission("members:remove");
|
||||||
|
|
||||||
|
const owners = useMemo(() => members.filter((m) => m.is_owner), [members]);
|
||||||
|
const nonOwnerMembers = useMemo(() => members.filter((m) => !m.is_owner), [members]);
|
||||||
|
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const totalItems = nonOwnerMembers.length + activeInvites.length;
|
||||||
|
const lastPage = Math.max(0, Math.ceil(totalItems / PAGE_SIZE) - 1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageIndex > lastPage) setPageIndex(lastPage);
|
||||||
|
}, [pageIndex, lastPage]);
|
||||||
|
|
||||||
|
const paginatedMembers = useMemo(() => {
|
||||||
|
const start = pageIndex * PAGE_SIZE;
|
||||||
|
const end = start + PAGE_SIZE;
|
||||||
|
return nonOwnerMembers.slice(
|
||||||
|
Math.min(start, nonOwnerMembers.length),
|
||||||
|
Math.min(end, nonOwnerMembers.length)
|
||||||
|
);
|
||||||
|
}, [nonOwnerMembers, pageIndex]);
|
||||||
|
|
||||||
|
const paginatedInvites = useMemo(() => {
|
||||||
|
const start = pageIndex * PAGE_SIZE;
|
||||||
|
const end = start + PAGE_SIZE;
|
||||||
|
const inviteStart = Math.max(0, start - nonOwnerMembers.length);
|
||||||
|
const inviteEnd = Math.max(0, end - nonOwnerMembers.length);
|
||||||
|
return activeInvites.slice(inviteStart, inviteEnd);
|
||||||
|
}, [activeInvites, nonOwnerMembers.length, pageIndex]);
|
||||||
|
|
||||||
|
const displayStart = totalItems > 0 ? pageIndex * PAGE_SIZE + 1 : 0;
|
||||||
|
const displayEnd = Math.min((pageIndex + 1) * PAGE_SIZE, totalItems);
|
||||||
|
const canPrev = pageIndex > 0;
|
||||||
|
const canNext = displayEnd < totalItems;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (members.length > 0 && !membersLoading) {
|
if (members.length > 0 && !membersLoading) {
|
||||||
const ownerCount = members.filter((m) => m.is_owner).length;
|
const ownerCount = members.filter((m) => m.is_owner).length;
|
||||||
|
|
@ -246,16 +285,66 @@ export default function TeamManagementPage() {
|
||||||
|
|
||||||
if (accessLoading || membersLoading) {
|
if (accessLoading || membersLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<motion.div
|
||||||
<motion.div
|
initial={{ opacity: 0 }}
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
animate={{ opacity: 1 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="flex flex-col items-center gap-4"
|
className="min-h-screen bg-background"
|
||||||
>
|
>
|
||||||
<Spinner size="lg" className="text-primary" />
|
<div className="container max-w-5xl mx-auto p-4 md:p-6 lg:p-8 pt-20 md:pt-24 lg:pt-28">
|
||||||
<p className="text-muted-foreground">Loading team data...</p>
|
<div className="space-y-6">
|
||||||
</motion.div>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<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>
|
||||||
|
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
|
||||||
|
<TableRow
|
||||||
|
key={`skeleton-${i}`}
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,7 +355,7 @@ export default function TeamManagementPage() {
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="min-h-screen bg-background"
|
className="min-h-screen bg-background"
|
||||||
>
|
>
|
||||||
<div className="container max-w-5xl mx-auto p-4 md:p-6 lg:p-8">
|
<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="space-y-6">
|
||||||
{/* Header row: Invite button on left, member count on right */}
|
{/* Header row: Invite button on left, member count on right */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -284,54 +373,135 @@ export default function TeamManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members & Invites Table */}
|
{/* Members & Invites Table */}
|
||||||
<div className="rounded-xl border bg-card overflow-hidden">
|
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
||||||
<Table>
|
<Table className="table-fixed w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/30 hover:bg-muted/30">
|
<TableRow className="hover:bg-transparent border-b border-border/40">
|
||||||
<TableHead className="w-[40%] px-4 md:px-6 font-normal text-muted-foreground">
|
<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
|
Name
|
||||||
</TableHead>
|
</span>
|
||||||
<TableHead className="hidden md:table-cell font-normal text-muted-foreground">
|
</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
|
Last logged in
|
||||||
</TableHead>
|
</span>
|
||||||
<TableHead className="text-right px-4 md:px-6 font-normal text-muted-foreground">
|
</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
|
Role
|
||||||
</TableHead>
|
</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}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{paginatedMembers.map((member, index) => (
|
||||||
|
<MemberRow
|
||||||
|
key={`member-${member.id}`}
|
||||||
|
member={member}
|
||||||
|
roles={roles}
|
||||||
|
canManageRoles={canManageRoles}
|
||||||
|
canRemove={canRemove}
|
||||||
|
onUpdateRole={handleUpdateMember}
|
||||||
|
onRemoveMember={handleRemoveMember}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
index={owners.length + index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{paginatedInvites.map((invite, index) => (
|
||||||
|
<InviteRow
|
||||||
|
key={`invite-${invite.id}`}
|
||||||
|
invite={invite}
|
||||||
|
canRevoke={canInvite}
|
||||||
|
onRevokeInvite={handleRevokeInvite}
|
||||||
|
index={owners.length + paginatedMembers.length + index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{members.length === 0 && activeInvites.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>
|
</TableRow>
|
||||||
</TableHeader>
|
)}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{members.map((member) => (
|
</Table>
|
||||||
<MemberRow
|
|
||||||
key={`member-${member.id}`}
|
|
||||||
member={member}
|
|
||||||
roles={roles}
|
|
||||||
canManageRoles={canManageRoles}
|
|
||||||
canRemove={canRemove}
|
|
||||||
onUpdateRole={handleUpdateMember}
|
|
||||||
onRemoveMember={handleRemoveMember}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{activeInvites.map((invite) => (
|
|
||||||
<InviteRow
|
|
||||||
key={`invite-${invite.id}`}
|
|
||||||
invite={invite}
|
|
||||||
canRevoke={canInvite}
|
|
||||||
onRevokeInvite={handleRevokeInvite}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{members.length === 0 && activeInvites.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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -347,6 +517,8 @@ function MemberRow({
|
||||||
canRemove,
|
canRemove,
|
||||||
onUpdateRole,
|
onUpdateRole,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
|
searchSpaceId,
|
||||||
|
index,
|
||||||
}: {
|
}: {
|
||||||
member: Membership;
|
member: Membership;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
|
|
@ -354,19 +526,25 @@ function MemberRow({
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
|
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
|
||||||
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
||||||
|
searchSpaceId: number;
|
||||||
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
const initials = getAvatarInitials(member);
|
const initials = getAvatarInitials(member);
|
||||||
const avatarColor = getAvatarColor(member.user_id);
|
const avatarColor = getAvatarColor(member.user_id);
|
||||||
const displayName = member.user_display_name || member.user_email || "Unknown";
|
const displayName = member.user_display_name || member.user_email || "Unknown";
|
||||||
const roleName = member.is_owner ? "Owner" : (member.role?.name || "No role");
|
const roleName = member.is_owner ? "Owner" : (member.role?.name || "No role");
|
||||||
const showActions = canManageRoles || canRemove;
|
const showActions = !member.is_owner && (canManageRoles || canRemove);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow className="group hover:bg-muted/30">
|
<motion.tr
|
||||||
{/* Name + Avatar */}
|
initial={{ opacity: 0 }}
|
||||||
<TableCell className="py-3 px-4 md:px-6">
|
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="flex items-center gap-3">
|
||||||
<div className="relative shrink-0">
|
<div className="shrink-0">
|
||||||
{member.user_avatar_url ? (
|
{member.user_avatar_url ? (
|
||||||
<Image
|
<Image
|
||||||
src={member.user_avatar_url}
|
src={member.user_avatar_url}
|
||||||
|
|
@ -385,11 +563,6 @@ function MemberRow({
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{member.is_owner && (
|
|
||||||
<div className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-amber-500 flex items-center justify-center ring-2 ring-background">
|
|
||||||
<Crown className="h-2.5 w-2.5 text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-sm truncate">{displayName}</p>
|
<p className="font-medium text-sm truncate">{displayName}</p>
|
||||||
|
|
@ -400,14 +573,12 @@ function MemberRow({
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Last logged in */}
|
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
|
||||||
<TableCell className="hidden md:table-cell py-3 text-sm text-muted-foreground">
|
|
||||||
{formatRelativeDate(member.joined_at)}
|
{formatRelativeDate(member.joined_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Role */}
|
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
|
||||||
<TableCell className="text-right py-3 px-4 md:px-6">
|
{showActions ? (
|
||||||
{showActions && !member.is_owner ? (
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -419,28 +590,17 @@ function MemberRow({
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
|
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
{canManageRoles && (
|
{canManageRoles &&
|
||||||
<>
|
roles
|
||||||
{roles.map((role) => (
|
.filter((r) => r.name !== "Owner")
|
||||||
|
.map((role) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={role.id}
|
key={role.id}
|
||||||
onClick={() => onUpdateRole(member.id, role.id)}
|
onClick={() => onUpdateRole(member.id, role.id)}
|
||||||
className={cn(
|
|
||||||
member.role_id === role.id && "bg-accent"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{role.name}
|
Make {role.name}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onUpdateRole(member.id, null)}
|
|
||||||
className={cn(!member.role_id && "bg-accent")}
|
|
||||||
>
|
|
||||||
No role
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{canManageRoles && canRemove && <DropdownMenuSeparator />}
|
|
||||||
{canRemove && (
|
{canRemove && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
|
|
@ -448,41 +608,45 @@ function MemberRow({
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<UserMinus className="h-4 w-4 mr-2" />
|
Remove
|
||||||
Remove member
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will remove{" "}
|
This will remove{" "}
|
||||||
<span className="font-medium">{member.user_email}</span>{" "}
|
<span className="font-medium">{member.user_email}</span>{" "}
|
||||||
from this search space. They will lose access to all resources.
|
from this search space. They will lose access to all resources.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => onRemoveMember(member.id)}
|
onClick={() => onRemoveMember(member.id)}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)}
|
||||||
|
>
|
||||||
|
Manage Roles
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : (
|
) : (
|
||||||
<span className={cn("text-sm", member.is_owner ? "text-foreground" : "text-muted-foreground")}>
|
<span className="text-sm text-foreground">
|
||||||
{roleName}
|
{roleName}
|
||||||
{!member.is_owner && <ChevronDown className="inline h-4 w-4 ml-1" />}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</motion.tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -492,18 +656,24 @@ function InviteRow({
|
||||||
invite,
|
invite,
|
||||||
canRevoke,
|
canRevoke,
|
||||||
onRevokeInvite,
|
onRevokeInvite,
|
||||||
|
index,
|
||||||
}: {
|
}: {
|
||||||
invite: Invite;
|
invite: Invite;
|
||||||
canRevoke: boolean;
|
canRevoke: boolean;
|
||||||
onRevokeInvite: (inviteId: number) => Promise<boolean>;
|
onRevokeInvite: (inviteId: number) => Promise<boolean>;
|
||||||
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
const initials = getInviteInitials(invite);
|
const initials = getInviteInitials(invite);
|
||||||
const avatarColor = getAvatarColor(invite.invite_code);
|
const avatarColor = getAvatarColor(invite.invite_code);
|
||||||
const displayName = invite.name || "Unnamed Invite";
|
const displayName = invite.name || "Unnamed Invite";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow className="group hover:bg-muted/30">
|
<motion.tr
|
||||||
<TableCell className="py-3 px-4 md:px-6">
|
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="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -524,11 +694,11 @@ function InviteRow({
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="hidden md:table-cell py-3 text-sm text-muted-foreground">
|
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
|
||||||
Never
|
Never
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-right py-3 px-4 md:px-6">
|
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
|
||||||
{canRevoke ? (
|
{canRevoke ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
@ -548,7 +718,7 @@ function InviteRow({
|
||||||
toast.success("Invite link copied");
|
toast.success("Invite link copied");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
<Copy className="h-4 w-4" />
|
||||||
Copy invite link
|
Copy invite link
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
@ -558,7 +728,7 @@ function InviteRow({
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Revoke invite
|
Revoke invite
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
|
|
@ -587,7 +757,7 @@ function InviteRow({
|
||||||
<span className="text-sm text-muted-foreground/60">Invited</span>
|
<span className="text-sm text-muted-foreground/60">Invited</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</motion.tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -611,22 +781,30 @@ function CreateInviteDialog({
|
||||||
const [createdInvite, setCreatedInvite] = useState<Invite | null>(null);
|
const [createdInvite, setCreatedInvite] = useState<Invite | null>(null);
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
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 () => {
|
const handleCreate = async () => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const data: CreateInviteRequest["data"] = {};
|
const data: CreateInviteRequest["data"] = {};
|
||||||
if (name) data.name = name;
|
if (name) data.name = name;
|
||||||
if (roleId && roleId !== "default") data.role_id = Number(roleId);
|
if (roleId) data.role_id = Number(roleId);
|
||||||
if (maxUses) data.max_uses = Number(maxUses);
|
if (maxUses) data.max_uses = Number(maxUses);
|
||||||
if (expiresAt) data.expires_at = expiresAt.toISOString();
|
if (expiresAt) data.expires_at = expiresAt.toISOString();
|
||||||
|
|
||||||
const invite = await onCreateInvite(data);
|
const invite = await onCreateInvite(data);
|
||||||
setCreatedInvite(invite);
|
setCreatedInvite(invite);
|
||||||
|
|
||||||
const roleName =
|
const roleName = roleId
|
||||||
roleId && roleId !== "default"
|
? roles.find((r) => r.id.toString() === roleId)?.name
|
||||||
? roles.find((r) => r.id.toString() === roleId)?.name
|
: undefined;
|
||||||
: undefined;
|
|
||||||
trackSearchSpaceInviteSent(searchSpaceId, {
|
trackSearchSpaceInviteSent(searchSpaceId, {
|
||||||
roleName,
|
roleName,
|
||||||
hasExpiry: !!expiresAt,
|
hasExpiry: !!expiresAt,
|
||||||
|
|
@ -642,7 +820,7 @@ function CreateInviteDialog({
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setName("");
|
setName("");
|
||||||
setRoleId("");
|
setRoleId(defaultRole?.id.toString() ?? "");
|
||||||
setMaxUses("");
|
setMaxUses("");
|
||||||
setExpiresAt(undefined);
|
setExpiresAt(undefined);
|
||||||
setCreatedInvite(null);
|
setCreatedInvite(null);
|
||||||
|
|
@ -692,7 +870,6 @@ function CreateInviteDialog({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Shield className="h-3 w-3" />
|
|
||||||
{createdInvite.role?.name || "Default role"}
|
{createdInvite.role?.name || "Default role"}
|
||||||
</span>
|
</span>
|
||||||
{createdInvite.max_uses && (
|
{createdInvite.max_uses && (
|
||||||
|
|
@ -735,20 +912,19 @@ function CreateInviteDialog({
|
||||||
<Label htmlFor="invite-role">Role</Label>
|
<Label htmlFor="invite-role">Role</Label>
|
||||||
<Select value={roleId} onValueChange={setRoleId}>
|
<Select value={roleId} onValueChange={setRoleId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a role (default: Viewer)" />
|
<SelectValue placeholder="Assign a role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="default">Default role (Viewer)</SelectItem>
|
{assignableRoles.map((role) => (
|
||||||
{roles
|
<SelectItem key={role.id} value={role.id.toString()}>
|
||||||
.filter((r) => r.name !== "Owner")
|
<span className="flex items-center gap-2">
|
||||||
.map((role) => (
|
{role.name}
|
||||||
<SelectItem key={role.id} value={role.id.toString()}>
|
{role.is_default && (
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-xs text-muted-foreground">(default)</span>
|
||||||
<Shield className="h-3 w-3" />
|
)}
|
||||||
{role.name}
|
</span>
|
||||||
</div>
|
</SelectItem>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue