RBAC, Permissions, and Export/Import
This commit is contained in:
235
src/components/PendingInvitationsTable.js
Normal file
235
src/components/PendingInvitationsTable.js
Normal file
@@ -0,0 +1,235 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user