236 lines
8.6 KiB
JavaScript
236 lines
8.6 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
|
import api from '../utils/api';
|
|
import { Card } from './ui/card';
|
|
import { Button } from './ui/button';
|
|
import { Badge } from './ui/badge';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from './ui/table';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from './ui/alert-dialog';
|
|
import { toast } from 'sonner';
|
|
import { Mail, Trash2, MailCheck, Clock } from 'lucide-react';
|
|
|
|
const PendingInvitationsTable = () => {
|
|
const [invitations, setInvitations] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [revokeDialog, setRevokeDialog] = useState({ open: false, invitation: null });
|
|
const [resending, setResending] = useState(null);
|
|
|
|
useEffect(() => {
|
|
fetchInvitations();
|
|
}, []);
|
|
|
|
const fetchInvitations = async () => {
|
|
try {
|
|
const response = await api.get('/admin/users/invitations?status=pending');
|
|
setInvitations(response.data);
|
|
} catch (error) {
|
|
toast.error('Failed to fetch invitations');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResend = async (invitationId) => {
|
|
setResending(invitationId);
|
|
try {
|
|
await api.post(`/admin/users/invitations/${invitationId}/resend`);
|
|
toast.success('Invitation resent successfully');
|
|
fetchInvitations(); // Refresh list to show new expiry date
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail || 'Failed to resend invitation');
|
|
} finally {
|
|
setResending(null);
|
|
}
|
|
};
|
|
|
|
const handleRevoke = async () => {
|
|
if (!revokeDialog.invitation) return;
|
|
|
|
try {
|
|
await api.delete(`/admin/users/invitations/${revokeDialog.invitation.id}`);
|
|
toast.success('Invitation revoked');
|
|
setRevokeDialog({ open: false, invitation: null });
|
|
fetchInvitations(); // Refresh list
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail || 'Failed to revoke invitation');
|
|
}
|
|
};
|
|
|
|
const getRoleBadge = (role) => {
|
|
const config = {
|
|
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
|
|
admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
|
|
member: { label: 'Member', className: 'bg-[#DDD8EB] text-[#422268]' }
|
|
};
|
|
|
|
const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
|
|
return (
|
|
<Badge className={`${roleConfig.className} px-3 py-1 rounded-full text-sm`}>
|
|
{roleConfig.label}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
const isExpiringSoon = (expiresAt) => {
|
|
const expiry = new Date(expiresAt);
|
|
const now = new Date();
|
|
const hoursDiff = (expiry - now) / (1000 * 60 * 60);
|
|
return hoursDiff < 24 && hoursDiff > 0;
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const hoursDiff = (date - now) / (1000 * 60 * 60);
|
|
|
|
if (hoursDiff < 0) {
|
|
return 'Expired';
|
|
} else if (hoursDiff < 24) {
|
|
return `Expires in ${Math.round(hoursDiff)} hours`;
|
|
} else {
|
|
const daysDiff = Math.round(hoursDiff / 24);
|
|
return `Expires in ${daysDiff} day${daysDiff > 1 ? 's' : ''}`;
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="text-center py-8">
|
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Loading invitations...
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (invitations.length === 0) {
|
|
return (
|
|
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
|
<Mail className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
|
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
No Pending Invitations
|
|
</h3>
|
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
All invitations have been accepted or expired
|
|
</p>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Card className="bg-white rounded-2xl border border-[#ddd8eb] overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-[#DDD8EB] hover:bg-[#DDD8EB]">
|
|
<TableHead className="text-[#422268] font-semibold">Email</TableHead>
|
|
<TableHead className="text-[#422268] font-semibold">Name</TableHead>
|
|
<TableHead className="text-[#422268] font-semibold">Role</TableHead>
|
|
<TableHead className="text-[#422268] font-semibold">Invited</TableHead>
|
|
<TableHead className="text-[#422268] font-semibold">Expires</TableHead>
|
|
<TableHead className="text-[#422268] font-semibold text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{invitations.map((invitation) => (
|
|
<TableRow key={invitation.id} className="hover:bg-[#F9F8FB]">
|
|
<TableCell className="font-medium text-[#422268]">
|
|
{invitation.email}
|
|
</TableCell>
|
|
<TableCell className="text-[#664fa3]">
|
|
{invitation.first_name && invitation.last_name
|
|
? `${invitation.first_name} ${invitation.last_name}`
|
|
: '-'}
|
|
</TableCell>
|
|
<TableCell>{getRoleBadge(invitation.role)}</TableCell>
|
|
<TableCell className="text-[#664fa3]">
|
|
{new Date(invitation.invited_at).toLocaleDateString()}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Clock className={`h-4 w-4 ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500' : 'text-[#664fa3]'}`} />
|
|
<span className={`text-sm ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500 font-semibold' : 'text-[#664fa3]'}`}>
|
|
{formatDate(invitation.expires_at)}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleResend(invitation.id)}
|
|
disabled={resending === invitation.id}
|
|
className="rounded-xl border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
|
>
|
|
{resending === invitation.id ? (
|
|
'Resending...'
|
|
) : (
|
|
<>
|
|
<MailCheck className="h-4 w-4 mr-1" />
|
|
Resend
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setRevokeDialog({ open: true, invitation })}
|
|
className="rounded-xl border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" />
|
|
Revoke
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Card>
|
|
|
|
{/* Revoke Confirmation Dialog */}
|
|
<AlertDialog open={revokeDialog.open} onOpenChange={(open) => setRevokeDialog({ open, invitation: null })}>
|
|
<AlertDialogContent className="rounded-2xl">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Revoke Invitation
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Are you sure you want to revoke the invitation for{' '}
|
|
<span className="font-semibold">{revokeDialog.invitation?.email}</span>?
|
|
This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel className="rounded-xl">Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleRevoke}
|
|
className="rounded-xl bg-red-500 hover:bg-red-600 text-white"
|
|
>
|
|
Revoke
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default PendingInvitationsTable;
|