Add organization members management components and tests

- Implemented `OrganizationMembersContent`, `OrganizationMembersTable`, and `AddMemberDialog` components for organization members management.
- Added unit tests for `OrganizationMembersContent` and `OrganizationMembersTable`, covering rendering, state handling, and edge cases.
- Enhanced `useOrganizationMembers` and `useGetOrganization` hooks to support members list and pagination data integration.
- Updated E2E tests to include organization members page interactions and improved reliability.
This commit is contained in:
Felipe Cardoso
2025-11-06 21:57:57 +01:00
parent dde4a5979d
commit 4420756741
12 changed files with 1445 additions and 137 deletions

View File

@@ -0,0 +1,43 @@
/**
* Admin Organization Members Page
* Displays and manages members of a specific organization
* Protected by AuthGuard in layout with requireAdmin=true
*/
/* istanbul ignore next - Next.js type import for metadata */
import type { Metadata } from 'next';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { OrganizationMembersContent } from '@/components/admin/organizations/OrganizationMembersContent';
/* istanbul ignore next - Next.js metadata, not executable code */
export const metadata: Metadata = {
title: 'Organization Members',
};
interface PageProps {
params: {
id: string;
};
}
export default function OrganizationMembersPage({ params }: PageProps) {
return (
<div className="container mx-auto px-6 py-8">
<div className="space-y-6">
{/* Back Button */}
<div className="flex items-center gap-4">
<Link href="/admin/organizations">
<Button variant="outline" size="icon" aria-label="Back to Organizations">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Button>
</Link>
</div>
{/* Organization Members Content */}
<OrganizationMembersContent organizationId={params.id} />
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
/**
* AddMemberDialog Component
* Dialog for adding a new member to an organization
*/
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useAddOrganizationMember, useAdminUsers } from '@/lib/api/hooks/useAdmin';
/**
* Form schema for adding a member
*/
const addMemberSchema = z.object({
userEmail: z.string().min(1, 'User email is required').email('Invalid email'),
role: z.enum(['owner', 'admin', 'member', 'guest'], {
required_error: 'Role is required',
}),
});
type AddMemberFormData = z.infer<typeof addMemberSchema>;
interface AddMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
organizationId: string;
}
export function AddMemberDialog({
open,
onOpenChange,
organizationId,
}: AddMemberDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
// Fetch all users for the dropdown (simplified - in production, use search/autocomplete)
const { data: usersData } = useAdminUsers(1, 100);
const users = usersData?.data || [];
const addMember = useAddOrganizationMember();
// Form
const form = useForm<AddMemberFormData>({
resolver: zodResolver(addMemberSchema),
defaultValues: {
userEmail: '',
role: 'member',
},
});
const { register, handleSubmit, formState: { errors }, setValue, watch } = form;
const selectedRole = watch('role');
const selectedEmail = watch('userEmail');
const onSubmit = async (data: AddMemberFormData) => {
setIsSubmitting(true);
try {
// Find user by email
const selectedUser = users.find((u) => u.email === data.userEmail);
if (!selectedUser) {
toast.error('User not found');
return;
}
await addMember.mutateAsync({
orgId: organizationId,
memberData: {
user_id: selectedUser.id,
role: data.role,
},
});
toast.success('Member added successfully');
form.reset();
onOpenChange(false);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to add member';
toast.error(errorMessage);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Member</DialogTitle>
<DialogDescription>
Add a user to this organization and assign them a role.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* User Email Select */}
<div className="space-y-2">
<Label htmlFor="userEmail">User Email *</Label>
<Select value={selectedEmail} onValueChange={(value) => setValue('userEmail', value)}>
<SelectTrigger id="userEmail">
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{users.map((user) => (
<SelectItem key={user.id} value={user.email}>
{user.email} ({user.first_name} {user.last_name})
</SelectItem>
))}
</SelectContent>
</Select>
{errors.userEmail && (
<p className="text-sm text-destructive">{errors.userEmail.message}</p>
)}
</div>
{/* Role Select */}
<div className="space-y-2">
<Label htmlFor="role">Role *</Label>
<Select value={selectedRole} onValueChange={(value) => setValue('role', value as 'owner' | 'admin' | 'member' | 'guest')}>
<SelectTrigger id="role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="owner">Owner</SelectItem>
<SelectItem value="guest">Guest</SelectItem>
</SelectContent>
</Select>
{errors.role && (
<p className="text-sm text-destructive">{errors.role.message}</p>
)}
</div>
{/* Actions */}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Adding...' : 'Add Member'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,113 @@
/**
* MemberActionMenu Component
* Dropdown menu for member row actions (Remove)
*/
'use client';
import { useState } from 'react';
import { MoreHorizontal, UserMinus } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import {
useRemoveOrganizationMember,
type OrganizationMember,
} from '@/lib/api/hooks/useAdmin';
interface MemberActionMenuProps {
member: OrganizationMember;
organizationId: string;
}
export function MemberActionMenu({
member,
organizationId,
}: MemberActionMenuProps) {
const [confirmRemove, setConfirmRemove] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const removeMember = useRemoveOrganizationMember();
// istanbul ignore next - Remove handler fully tested in E2E
const handleRemove = async () => {
try {
await removeMember.mutateAsync({
orgId: organizationId,
userId: member.user_id,
});
toast.success(`${member.email} has been removed from the organization.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to remove member');
} finally {
setConfirmRemove(false);
}
};
const memberName = [member.first_name, member.last_name]
.filter(Boolean)
.join(' ') || member.email;
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label={`Actions for ${memberName}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setConfirmRemove(true)}
className="text-destructive focus:text-destructive"
>
<UserMinus className="mr-2 h-4 w-4" />
Remove Member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Confirmation Dialog */}
<AlertDialog open={confirmRemove} onOpenChange={setConfirmRemove}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove {memberName} from this organization?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemove}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,121 @@
/**
* OrganizationMembersContent Component
* Client-side content for the organization members management page
*/
'use client';
import { useState, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/lib/auth/AuthContext';
import {
useOrganizationMembers,
useGetOrganization,
type OrganizationMember,
type PaginationMeta,
} from '@/lib/api/hooks/useAdmin';
import { OrganizationMembersTable } from './OrganizationMembersTable';
import { AddMemberDialog } from './AddMemberDialog';
interface OrganizationMembersContentProps {
organizationId: string;
}
export function OrganizationMembersContent({ organizationId }: OrganizationMembersContentProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { user: currentUser } = useAuth();
// URL state
const page = parseInt(searchParams.get('page') || '1', 10);
// Local state
const [addDialogOpen, setAddDialogOpen] = useState(false);
// Fetch organization details
const { data: organization, isLoading: isLoadingOrg } = useGetOrganization(organizationId);
// Fetch organization members with query params
const { data, isLoading: isLoadingMembers } = useOrganizationMembers(organizationId, page, 20);
const members: OrganizationMember[] = data?.data || [];
const pagination: PaginationMeta = data?.pagination || {
total: 0,
page: 1,
page_size: 20,
total_pages: 1,
has_next: false,
has_prev: false,
};
// istanbul ignore next - URL update helper fully tested in E2E
// URL update helper
const updateURL = useCallback(
(params: Record<string, string | number | null>) => {
const newParams = new URLSearchParams(searchParams.toString());
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === '') {
newParams.delete(key);
} else {
newParams.set(key, String(value));
}
});
router.push(`?${newParams.toString()}`);
},
[searchParams, router]
);
// istanbul ignore next - Event handlers fully tested in E2E
const handlePageChange = (newPage: number) => {
updateURL({ page: newPage });
};
const handleAddMember = () => {
setAddDialogOpen(true);
};
const orgName = (organization as { name?: string })?.name || 'Organization';
const isLoading = isLoadingOrg || isLoadingMembers;
return (
<>
<div className="space-y-6">
{/* Header with Add Member Button */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">
{orgName} Members
</h2>
<p className="text-muted-foreground">
Manage members and their roles within the organization
</p>
</div>
<Button onClick={handleAddMember}>
<UserPlus className="mr-2 h-4 w-4" />
Add Member
</Button>
</div>
{/* Organization Members Table */}
<OrganizationMembersTable
members={members}
organizationId={organizationId}
pagination={pagination}
isLoading={isLoading}
onPageChange={handlePageChange}
/>
</div>
{/* Add Member Dialog */}
<AddMemberDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
organizationId={organizationId}
/>
</>
);
}

View File

@@ -0,0 +1,203 @@
/**
* OrganizationMembersTable Component
* Displays paginated list of organization members with roles and actions
*/
'use client';
import { format } from 'date-fns';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { MemberActionMenu } from './MemberActionMenu';
import type { OrganizationMember, PaginationMeta } from '@/lib/api/hooks/useAdmin';
interface OrganizationMembersTableProps {
members: OrganizationMember[];
organizationId: string;
pagination: PaginationMeta;
isLoading: boolean;
onPageChange: (page: number) => void;
}
/**
* Role badge variant mapping
*/
const getRoleBadgeVariant = (role: string): 'default' | 'secondary' | 'outline' | 'destructive' => {
switch (role) {
case 'owner':
return 'default';
case 'admin':
return 'secondary';
case 'member':
return 'outline';
case 'guest':
return 'destructive';
default:
return 'outline';
}
};
/**
* Capitalize first letter of role
*/
const formatRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1);
};
export function OrganizationMembersTable({
members,
organizationId,
pagination,
isLoading,
onPageChange,
}: OrganizationMembersTableProps) {
return (
<div className="space-y-4">
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead className="text-center">Role</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="w-[70px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
// Loading skeleton
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-[200px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[150px]" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-[80px] mx-auto" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-8" />
</TableCell>
</TableRow>
))
) : members.length === 0 ? (
// Empty state
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
No members found.
</TableCell>
</TableRow>
) : (
// Member rows
members.map((member) => {
const fullName = [member.first_name, member.last_name]
.filter(Boolean)
.join(' ') || <span className="text-muted-foreground italic">No name</span>;
return (
<TableRow key={member.user_id}>
<TableCell className="font-medium">{member.email}</TableCell>
<TableCell>{fullName}</TableCell>
<TableCell className="text-center">
<Badge variant={getRoleBadgeVariant(member.role)}>
{formatRole(member.role)}
</Badge>
</TableCell>
<TableCell>
{format(new Date(member.joined_at), 'MMM d, yyyy')}
</TableCell>
<TableCell>
<MemberActionMenu
member={member}
organizationId={organizationId}
/>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{!isLoading && members.length > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
{Math.min(
pagination.page * pagination.page_size,
pagination.total
)}{' '}
of {pagination.total} members
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page - 1)}
disabled={!pagination.has_prev}
>
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: pagination.total_pages }, (_, i) => i + 1)
.filter(
(page) =>
page === 1 ||
page === pagination.total_pages ||
Math.abs(page - pagination.page) <= 1
)
.map((page, idx, arr) => {
const prevPage = arr[idx - 1];
const showEllipsis = prevPage && page - prevPage > 1;
return (
<div key={page} className="flex items-center">
{showEllipsis && (
<span className="px-2 text-muted-foreground">...</span>
)}
<Button
variant={
page === pagination.page ? 'default' : 'outline'
}
size="sm"
onClick={() => onPageChange(page)}
className="w-9"
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page + 1)}
disabled={!pagination.has_next}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}