mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 12:28:21 +00:00
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { AlertTriangle, CheckCircle, Ban, Eye } from 'lucide-react';
|
|
import DataTable from '@/components/ui/DataTable';
|
|
import Badge from '@/components/ui/Badge';
|
|
import ActionButton from '@/components/ui/ActionButton';
|
|
import Modal from '@/components/ui/Modal';
|
|
import { fraudApi } from '@/lib/api';
|
|
import { formatDateTime } from '@/lib/utils';
|
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
|
import { PERMS } from '@/lib/permissions';
|
|
import { useWritePermission } from '@/lib/use-permission';
|
|
|
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
|
<div className="bg-muted/40 rounded-lg p-3">
|
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
|
</div>
|
|
);
|
|
|
|
const SectionHeader = ({ title }: { title: string }) => (
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
|
</h3>
|
|
);
|
|
|
|
const SEVERITY_GRAD: Record<string, string> = {
|
|
CRITICAL: 'from-red-700 to-red-800',
|
|
HIGH: 'from-red-600 to-red-700',
|
|
MEDIUM: 'from-amber-500 to-amber-600',
|
|
LOW: 'from-blue-500 to-blue-600',
|
|
};
|
|
|
|
function FraudDetectionPageContent() {
|
|
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
|
|
const [selected, setSelected] = useState<any>(null);
|
|
const queryClient = useQueryClient();
|
|
|
|
const canManageFraud = useWritePermission(PERMS.fraud.edit, PERMS.fraud.manage);
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['fraud-alerts', filters],
|
|
queryFn: () => fraudApi.getAlerts(filters),
|
|
});
|
|
|
|
const acknowledgeMutation = useMutation({
|
|
mutationFn: fraudApi.acknowledgeAlert,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['fraud-alerts'] });
|
|
alert('Alert acknowledged');
|
|
},
|
|
});
|
|
|
|
const blockUserMutation = useMutation({
|
|
mutationFn: ({ userId, reason }: any) => fraudApi.blockUser(userId, { reason }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['fraud-alerts'] });
|
|
alert('User blocked successfully');
|
|
},
|
|
});
|
|
|
|
const handleAcknowledge = async (alert: any) => {
|
|
await acknowledgeMutation.mutateAsync(alert.id);
|
|
};
|
|
|
|
const handleBlockUser = async (alert: any) => {
|
|
if (confirm(`Block user ${alert.user?.email}?`)) {
|
|
await blockUserMutation.mutateAsync({ userId: alert.userId, reason: `Fraud alert: ${alert.ruleType}` });
|
|
}
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
key: 'severity',
|
|
label: 'Severity',
|
|
render: (alert: any) => (
|
|
<Badge variant="status" status={alert.severity === 'HIGH' || alert.severity === 'CRITICAL' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
|
|
{alert.severity}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'ruleType',
|
|
label: 'Rule Type',
|
|
render: (alert: any) => (
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
|
<span>{alert.ruleType}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'user',
|
|
label: 'User',
|
|
render: (alert: any) => (
|
|
<div>
|
|
<div className="font-medium">{alert.user?.fullName || 'N/A'}</div>
|
|
<div className="text-sm text-muted-foreground">{alert.user?.email || 'N/A'}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'description',
|
|
label: 'Description',
|
|
render: (alert: any) => <span className="text-sm">{alert.description || alert.details}</span>,
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
render: (alert: any) => (
|
|
<Badge variant="status" status={alert.acknowledged ? 'CONFIRMED' : 'PENDING'}>
|
|
{alert.acknowledged ? 'Acknowledged' : 'Pending'}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
label: 'Detected',
|
|
sortable: true,
|
|
render: (alert: any) => formatDateTime(alert.createdAt),
|
|
},
|
|
];
|
|
|
|
const actions = [
|
|
{
|
|
label: 'View Details',
|
|
onClick: (alert: any) => setSelected(alert),
|
|
variant: 'secondary' as const,
|
|
icon: Eye,
|
|
},
|
|
{
|
|
label: 'Acknowledge',
|
|
onClick: handleAcknowledge,
|
|
variant: 'primary' as const,
|
|
icon: CheckCircle,
|
|
show: (alert: any) => canManageFraud && !alert.acknowledged,
|
|
},
|
|
{
|
|
label: 'Block User',
|
|
show: () => canManageFraud,
|
|
onClick: handleBlockUser,
|
|
variant: 'danger' as const,
|
|
icon: Ban,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground">Fraud Detection</h1>
|
|
<p className="text-muted-foreground">Monitor and manage fraud alerts</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="label">Search</label>
|
|
<input type="text" placeholder="Search alerts..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<label className="label">Severity</label>
|
|
<select className="input" value={filters.severity} onChange={(e) => setFilters({ ...filters, severity: e.target.value })}>
|
|
<option value="">All Severities</option>
|
|
<option value="LOW">Low</option>
|
|
<option value="MEDIUM">Medium</option>
|
|
<option value="HIGH">High</option>
|
|
<option value="CRITICAL">Critical</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Status</label>
|
|
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
|
|
<option value="">All Status</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="acknowledged">Acknowledged</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable
|
|
data={data?.items || []}
|
|
columns={columns}
|
|
actions={actions}
|
|
loading={isLoading}
|
|
emptyMessage="No fraud alerts found"
|
|
/>
|
|
|
|
{/* Fraud Alert Details Modal */}
|
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Fraud Alert Details" size="xl">
|
|
{selected && (() => {
|
|
const al = selected;
|
|
const grad = SEVERITY_GRAD[al.severity] || 'from-gray-600 to-gray-700';
|
|
return (
|
|
<div>
|
|
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Fraud Alert</p>
|
|
<p className="text-white text-xl font-bold">{al.ruleType}</p>
|
|
</div>
|
|
<div className="text-right shrink-0 space-y-1">
|
|
<Badge variant="status" status={al.severity === 'HIGH' || al.severity === 'CRITICAL' ? 'CANCELLED' : al.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
|
|
{al.severity}
|
|
</Badge>
|
|
<div>
|
|
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
|
|
{al.acknowledged ? 'Acknowledged' : 'Pending'}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-white/70 text-xs">{formatDateTime(al.createdAt)}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
|
{[
|
|
{ label: 'Severity', value: al.severity || '—' },
|
|
{ label: 'Rule Type', value: al.ruleType || '—' },
|
|
{ label: 'User', value: al.user?.fullName || al.user?.email || '—' },
|
|
].map(({ label, value }) => (
|
|
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
|
<p className="text-white/70 text-xs">{label}</p>
|
|
<p className="text-white text-sm font-bold truncate">{value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<section>
|
|
<SectionHeader title="Alert Details" />
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Field label="Rule Type" value={al.ruleType} />
|
|
<Field label="Severity" value={al.severity} />
|
|
<div className="bg-muted/40 rounded-lg p-3">
|
|
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
|
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
|
|
{al.acknowledged ? 'Acknowledged' : 'Pending'}
|
|
</Badge>
|
|
</div>
|
|
<Field label="Detected At" value={formatDateTime(al.createdAt)} />
|
|
<div className="col-span-2 md:col-span-4 bg-muted/40 rounded-lg p-3">
|
|
<p className="text-xs text-muted-foreground mb-1">Description</p>
|
|
<p className="text-sm font-medium">{al.description || al.details || '—'}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionHeader title="Flagged User" />
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
<Field label="Full Name" value={al.user?.fullName} />
|
|
<Field label="Email" value={al.user?.email} truncate />
|
|
<Field label="Phone" value={al.user?.phone} />
|
|
<Field label="User ID" value={al.userId || al.user?.id} mono truncate />
|
|
<div className="bg-muted/40 rounded-lg p-3">
|
|
<p className="text-xs text-muted-foreground mb-2">Blocked</p>
|
|
<Badge variant="status" status={al.user?.isBlocked ? 'CANCELLED' : 'CONFIRMED'}>
|
|
{al.user?.isBlocked ? 'Blocked' : 'Not Blocked'}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{al.bookingId && (
|
|
<section>
|
|
<SectionHeader title="Related Booking" />
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
<Field label="Booking ID" value={al.bookingId} mono truncate />
|
|
<Field label="Booking Ref" value={al.booking?.bookingRef} mono />
|
|
<Field label="Amount" value={al.booking?.totalMinor ? `ETB ${(al.booking.totalMinor / 100).toFixed(2)}` : 'N/A'} />
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{al.acknowledged && (
|
|
<section>
|
|
<SectionHeader title="Resolution" />
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
<Field label="Acknowledged At" value={al.acknowledgedAt ? formatDateTime(al.acknowledgedAt) : '—'} />
|
|
<Field label="Acknowledged By" value={al.acknowledgedBy?.fullName || al.acknowledgedBy?.email || '—'} />
|
|
<Field label="Notes" value={al.resolutionNotes || '—'} truncate />
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<section>
|
|
<SectionHeader title="System" />
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
<Field label="Alert ID" value={al.id} mono truncate />
|
|
<Field label="Created" value={formatDateTime(al.createdAt)} />
|
|
<Field label="Last Updated" value={formatDateTime(al.updatedAt)} />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
|
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function FraudDetectionPage() {
|
|
return (
|
|
<PermissionGuard permission={PERMS.fraud.view}>
|
|
<FraudDetectionPageContent />
|
|
</PermissionGuard>
|
|
);
|
|
}
|