RBAC, Permissions, and Export/Import

This commit is contained in:
Koncept Kit
2025-12-16 20:04:00 +07:00
parent 02e38e1050
commit 9ed778db1c
30 changed files with 4579 additions and 487 deletions

View 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;