feat: implement pagination and loading skeletons for team management page

This commit is contained in:
Anish Sarkar 2026-02-21 19:19:41 +05:30
parent 9f08f7c9c2
commit d76380334f

View file

@ -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>