mirror of
https://github.com/domfelipe/fraudshield.git
synced 2026-08-07 10:56:54 +00:00
feat: implement FraudShield MVP — backend API + frontend alerts
Backend (Python 3.12/FastAPI): - 5 models: Transaction, DetectionRule, FraudAlert, AuditLog, RuleSnapshot - Rule engine: 4 rule types (amount, location, time, pattern) - 11 API routes: health, auth, transactions, alerts - Sync ingestion pipeline with explanation generation - JWT auth with 3 roles (analyst, admin, senior) - Seed script with 5 default rules + 50 synthetic transactions Frontend (React 19/TypeScript/Tailwind): - Auth: login page, JWT token management, role-based routing - Alerts: queue with filters, detail panel, decision workflow - Layout: responsive sidebar, top bar, protected routes - Full TypeScript, zero ts-ignore, Tailwind CSS Build: Backend imports clean, Frontend tsc --noEmit passes, vite build produces 296KB production bundle.
This commit is contained in:
parent
14f2616037
commit
989202648d
60 changed files with 6943 additions and 0 deletions
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>FraudShield</title>
|
||||
</head>
|
||||
<body class="bg-gray-50 font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3258
frontend/package-lock.json
generated
Normal file
3258
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "fraudshield-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"clsx": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
38
frontend/src/App.tsx
Normal file
38
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import Shell from './components/layout/Shell';
|
||||
import { ProtectedRoute, AdminRoute } from './components/layout/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import AlertsPage from './pages/AlertsPage';
|
||||
|
||||
function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center p-8 bg-white rounded-xl shadow-sm border">
|
||||
<div className="text-4xl mb-3">🚧</div>
|
||||
<h2 className="text-lg font-semibold text-gray-700">{title}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Em desenvolvimento</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Shell />}>
|
||||
<Route path="/" element={<Navigate to="/alerts" replace />} />
|
||||
<Route path="/alerts" element={<AlertsPage />} />
|
||||
<Route element={<AdminRoute />}>
|
||||
<Route path="/rules" element={<Placeholder title="Gestão de Regras" />} />
|
||||
</Route>
|
||||
<Route path="/dashboard" element={<Placeholder title="Dashboard" />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
113
frontend/src/api/client.ts
Normal file
113
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
const API_BASE = '/api/v1';
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('fraudshield_token');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: { ...getAuthHeaders(), ...((options.headers as Record<string, string>) || {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface AlertFilters {
|
||||
status?: string;
|
||||
min_score?: number;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
triggered_rule?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface TriggeredRule {
|
||||
rule_id: string;
|
||||
rule_name: string;
|
||||
score: number;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export interface AlertSummary {
|
||||
id: string;
|
||||
score: number;
|
||||
status: string;
|
||||
amount: number;
|
||||
merchant_name: string;
|
||||
top_rule: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AlertDetail {
|
||||
id: string;
|
||||
transaction_id: string;
|
||||
score: number;
|
||||
triggered_rules: TriggeredRule[];
|
||||
explanation: string;
|
||||
status: string;
|
||||
assigned_to?: string | null;
|
||||
decision?: string | null;
|
||||
decided_by?: string | null;
|
||||
decided_at?: string | null;
|
||||
justification?: string | null;
|
||||
created_at: string;
|
||||
amount: number;
|
||||
merchant_name: string;
|
||||
merchant_category: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export interface AlertListResponse {
|
||||
items: AlertSummary[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface AlertDecisionRequest {
|
||||
decision: string;
|
||||
justification?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface TransactionAccepted {
|
||||
transaction_id: string;
|
||||
status: string;
|
||||
trace_id: string;
|
||||
}
|
||||
|
||||
export interface DashboardMetrics {
|
||||
total_transactions: number;
|
||||
total_alerts: number;
|
||||
fraud_rate_pct: number;
|
||||
false_positive_rate_pct: number;
|
||||
alerts_by_status: { pending: number; confirmed: number; false_positive: number; escalated: number };
|
||||
}
|
||||
|
||||
export function listAlerts(params: AlertFilters = {}): Promise<AlertListResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => { if (v !== undefined) qs.set(k, String(v)); });
|
||||
return apiFetch<AlertListResponse>(`/alerts?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function getAlert(id: string): Promise<AlertDetail> {
|
||||
return apiFetch<AlertDetail>(`/alerts/${id}`);
|
||||
}
|
||||
|
||||
export function decideAlert(id: string, data: AlertDecisionRequest): Promise<AlertDetail> {
|
||||
return apiFetch<AlertDetail>(`/alerts/${id}/decide`, { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function getDashboardMetrics(period = '24h'): Promise<DashboardMetrics> {
|
||||
return apiFetch<DashboardMetrics>(`/dashboard/metrics?period=${period}`);
|
||||
}
|
||||
|
||||
export { getAuthHeaders };
|
||||
38
frontend/src/components/alerts/AlertCard.tsx
Normal file
38
frontend/src/components/alerts/AlertCard.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { AlertSummary } from '../../api/client';
|
||||
import { formatCurrency, formatRelative, getScoreColor, getScoreBg, getStatusBadge, cn } from '../../lib/utils';
|
||||
|
||||
interface Props {
|
||||
alert: AlertSummary;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function AlertCard({ alert, isSelected, onClick }: Props) {
|
||||
const badge = getStatusBadge(alert.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 cursor-pointer transition-all duration-150 border-l-4',
|
||||
isSelected ? 'bg-blue-50 border-blue-500' : 'bg-white border-transparent hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold text-white shrink-0', getScoreColor(alert.score))}>
|
||||
{alert.score}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-sm text-gray-900">{formatCurrency(alert.amount)}</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">{formatRelative(alert.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 truncate">{alert.merchant_name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs bg-gray-100 text-gray-600 rounded px-1.5 py-0.5">{alert.top_rule}</span>
|
||||
<span className={cn('text-xs px-1.5 py-0.5 rounded font-medium', badge.cls)}>{badge.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
frontend/src/components/alerts/AlertDetail.tsx
Normal file
200
frontend/src/components/alerts/AlertDetail.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { useState } from 'react';
|
||||
import type { AlertDetail } from '../../api/client';
|
||||
import { useAlertDetail, useDecideAlert } from '../../hooks/useAlerts';
|
||||
import { formatCurrency, formatDate, getScoreColor, getScoreBg, getStatusBadge, cn } from '../../lib/utils';
|
||||
import { ShieldOff, CheckCircle, ArrowUp, Loader2, FileText } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
alertId: string | null;
|
||||
}
|
||||
|
||||
export default function AlertDetailPanel({ alertId }: Props) {
|
||||
const { data: alert, isLoading } = useAlertDetail(alertId);
|
||||
const { mutate: doDecide } = useDecideAlert();
|
||||
const [decision, setDecision] = useState<string | null>(null);
|
||||
const [justification, setJustification] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
if (!alertId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
<div className="text-center">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">Selecione um alerta para ver os detalhes</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!alert) {
|
||||
return <div className="p-4 text-center text-red-500 text-sm">Alerta não encontrado</div>;
|
||||
}
|
||||
|
||||
const badge = getStatusBadge(alert.status);
|
||||
const isPending = alert.status === 'pending';
|
||||
|
||||
const handleDecide = async (d: string) => {
|
||||
if (d === 'confirmed') {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await doDecide(alert.id, { decision: d });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setDecision(d);
|
||||
};
|
||||
|
||||
const submitWithJustification = async () => {
|
||||
if (!decision) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await doDecide(alert.id, { decision, justification });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setDecision(null);
|
||||
setJustification('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5 overflow-y-auto h-full">
|
||||
|
||||
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl">
|
||||
<div className={cn('w-16 h-16 rounded-full flex items-center justify-center text-xl font-bold text-white', getScoreColor(alert.score))}>
|
||||
{alert.score}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Score de Risco</p>
|
||||
<p className="text-xs text-gray-500">de 100 pontos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-white border rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Transação</h3>
|
||||
<Row label="Valor" value={formatCurrency(alert.amount || 0)} />
|
||||
<Row label="Estabelecimento" value={alert.merchant_name || '-'} />
|
||||
<Row label="Categoria" value={alert.merchant_category || '-'} />
|
||||
<Row label="Canal" value={alert.channel || '-'} />
|
||||
</div>
|
||||
|
||||
|
||||
{alert.triggered_rules && alert.triggered_rules.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Regras Acionadas</h3>
|
||||
{alert.triggered_rules.map((rule, i) => (
|
||||
<div key={i} className="bg-white border rounded-xl p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">{rule.rule_name}</span>
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', getScoreBg(rule.score))}>
|
||||
+{rule.score}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">{rule.explanation}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<p className="text-sm text-blue-900 whitespace-pre-line">{alert.explanation}</p>
|
||||
</div>
|
||||
|
||||
|
||||
{isPending && !decision && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => handleDecide('confirmed')}
|
||||
disabled={submitting}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ShieldOff className="w-4 h-4" />}
|
||||
Confirmar Fraude
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDecide('false_positive')}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Falso Positivo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDecide('escalated')}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-yellow-500 hover:bg-yellow-600 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
Escalar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{decision && decision !== 'confirmed' && (
|
||||
<div className="border rounded-xl p-4 space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
Justificativa para "{decision === 'false_positive' ? 'Falso Positivo' : 'Escalar'}"
|
||||
</h4>
|
||||
<textarea
|
||||
value={justification}
|
||||
onChange={(e) => setJustification(e.target.value)}
|
||||
className="w-full border rounded-lg p-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="Descreva o motivo da decisão..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={submitWithJustification}
|
||||
disabled={submitting || !justification.trim()}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white text-sm rounded-lg"
|
||||
>
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Confirmar'}
|
||||
</button>
|
||||
<button onClick={() => setDecision(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{!isPending && alert.decision && (
|
||||
<div className="border rounded-xl p-4 space-y-2 bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-sm px-3 py-1 rounded-full font-medium', getStatusBadge(alert.status).cls)}>
|
||||
{getStatusBadge(alert.status).label}
|
||||
</span>
|
||||
</div>
|
||||
{alert.decided_by && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Decidido por <span className="font-medium">{alert.decided_by}</span>
|
||||
{alert.decided_at && ` em ${formatDate(alert.decided_at)}`}
|
||||
</p>
|
||||
)}
|
||||
{alert.justification && (
|
||||
<p className="text-sm text-gray-600 italic mt-1">{alert.justification}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="text-gray-900 font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/alerts/AlertQueue.tsx
Normal file
86
frontend/src/components/alerts/AlertQueue.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useState } from 'react';
|
||||
import type { AlertFilters, AlertSummary } from '../../api/client';
|
||||
import { useAlertQueue } from '../../hooks/useAlerts';
|
||||
import AlertCard from './AlertCard';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function AlertQueue({ selectedId, onSelect }: Props) {
|
||||
const [filters, setFilters] = useState<AlertFilters>({ status: 'pending' });
|
||||
const { data, isLoading, isError } = useAlertQueue(filters);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b bg-white shrink-0 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => setFilters(f => ({ ...f, status: e.target.value || undefined }))}
|
||||
className="text-xs border rounded-lg px-2 py-1.5 bg-white"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="pending">Pendentes</option>
|
||||
<option value="confirmed">Confirmados</option>
|
||||
<option value="false_positive">Falsos Positivos</option>
|
||||
<option value="escalated">Escalados</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Score mín."
|
||||
className="text-xs border rounded-lg px-2 py-1.5 w-20"
|
||||
onChange={(e) => setFilters(f => ({ ...f, min_score: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-gray-100">
|
||||
{isLoading && (
|
||||
<div className="p-3 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="flex items-center gap-3 animate-pulse">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-4 bg-gray-200 rounded w-24" />
|
||||
<div className="h-3 bg-gray-200 rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="p-4 text-center text-sm text-red-600">
|
||||
Erro ao carregar alertas
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.items.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<Search className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500">Nenhum alerta encontrado</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.items.map(alert => (
|
||||
<AlertCard
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
isSelected={alert.id === selectedId}
|
||||
onClick={() => onSelect(alert.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="p-2 border-t bg-white text-xs text-gray-500 text-center shrink-0">
|
||||
{data.total} alertas
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/layout/ProtectedRoute.tsx
Normal file
16
frontend/src/components/layout/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) return <div className="flex items-center justify-center h-screen"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" /></div>;
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function AdminRoute() {
|
||||
const { isAdmin, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
if (!isAdmin) return <Navigate to="/alerts" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
89
frontend/src/components/layout/Shell.tsx
Normal file
89
frontend/src/components/layout/Shell.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Outlet, NavLink, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { Shield, AlertTriangle, ShieldCheck, BarChart3, Menu, X, LogOut } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Shell() {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/alerts': 'Alertas',
|
||||
'/rules': 'Regras',
|
||||
'/dashboard': 'Dashboard',
|
||||
};
|
||||
|
||||
const title = pageTitles[location.pathname] || 'FraudShield';
|
||||
|
||||
const links = [
|
||||
{ to: '/alerts', icon: AlertTriangle, label: 'Alertas' },
|
||||
...(isAdmin ? [{ to: '/rules', icon: ShieldCheck, label: 'Regras' }] : []),
|
||||
{ to: '/dashboard', icon: BarChart3, label: 'Dashboard' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<aside className={`
|
||||
fixed inset-y-0 left-0 z-50 bg-[#1a1a2e] text-white transition-all duration-200
|
||||
${sidebarOpen ? 'w-60' : 'w-16'}
|
||||
md:relative md:translate-x-0
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
|
||||
`}>
|
||||
<div className="flex items-center gap-2 p-4 border-b border-gray-700">
|
||||
<Shield className="w-6 h-6 text-blue-400 shrink-0" />
|
||||
{sidebarOpen && <span className="font-semibold text-sm">FraudShield</span>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="md:hidden absolute top-4 right-[-40px] bg-[#1a1a2e] p-2 rounded-r-lg text-white"
|
||||
>
|
||||
{sidebarOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<nav className="mt-4 flex flex-col gap-1 px-2">
|
||||
{links.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||
isActive ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5 shrink-0" />
|
||||
{sidebarOpen && <span>{label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-16 bg-white border-b flex items-center justify-between px-4 md:px-6 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="md:hidden p-1">
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold text-gray-900">{title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500 hidden sm:inline">{user?.name}</span>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700 font-medium">
|
||||
{user?.role}
|
||||
</span>
|
||||
<button onClick={logout} className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/contexts/AuthContext.tsx
Normal file
87
frontend/src/contexts/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isAdmin: boolean;
|
||||
isAnalyst: boolean;
|
||||
isSenior: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthState>(null!);
|
||||
|
||||
function parseJWT(token: string): User | null {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return {
|
||||
id: payload.sub || '',
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'analyst',
|
||||
name: (payload.email || '').split('@')[0],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('fraudshield_token');
|
||||
if (stored) {
|
||||
const u = parseJWT(stored);
|
||||
if (u) { setUser(u); setToken(stored); }
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const form = new URLSearchParams();
|
||||
form.set('username', email);
|
||||
form.set('password', password);
|
||||
const res = await fetch('/api/v1/auth/token', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
|
||||
throw new Error(err.detail || 'Login failed');
|
||||
}
|
||||
const data = await res.json();
|
||||
localStorage.setItem('fraudshield_token', data.access_token);
|
||||
const u = parseJWT(data.access_token);
|
||||
if (u) { setUser(u); setToken(data.access_token); }
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('fraudshield_token');
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
}, []);
|
||||
|
||||
const role = user?.role || 'analyst';
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user, token, loading,
|
||||
isAuthenticated: !!token,
|
||||
isAdmin: role === 'admin',
|
||||
isAnalyst: role === 'analyst',
|
||||
isSenior: role === 'senior',
|
||||
login, logout,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
30
frontend/src/hooks/useAlerts.ts
Normal file
30
frontend/src/hooks/useAlerts.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { listAlerts, getAlert, decideAlert, type AlertFilters, type AlertDetail, type AlertDecisionRequest } from '../api/client';
|
||||
|
||||
export function useAlertQueue(filters: AlertFilters = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['alerts', filters],
|
||||
queryFn: () => listAlerts({ limit: 50, ...filters }),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertDetail(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['alert', id],
|
||||
queryFn: () => getAlert(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDecideAlert() {
|
||||
const qc = useQueryClient();
|
||||
return {
|
||||
mutate: async (id: string, data: AlertDecisionRequest) => {
|
||||
const result = await decideAlert(id, data);
|
||||
qc.invalidateQueries({ queryKey: ['alerts'] });
|
||||
qc.invalidateQueries({ queryKey: ['alert', id] });
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
6
frontend/src/hooks/useAuth.ts
Normal file
6
frontend/src/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { useContext } from 'react';
|
||||
import { AuthContext } from '../contexts/AuthContext';
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
45
frontend/src/lib/utils.ts
Normal file
45
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { clsx } from 'clsx';
|
||||
export const cn = (...args: Parameters<typeof clsx>) => clsx(args);
|
||||
|
||||
export function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return 'agora';
|
||||
if (min < 60) return `há ${min} min`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `há ${h}h`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `há ${d}d`;
|
||||
}
|
||||
|
||||
export function getScoreColor(score: number): string {
|
||||
if (score <= 30) return 'bg-green-500';
|
||||
if (score <= 60) return 'bg-yellow-500';
|
||||
if (score <= 80) return 'bg-orange-500';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
export function getScoreBg(score: number): string {
|
||||
if (score <= 30) return 'bg-green-100 text-green-800';
|
||||
if (score <= 60) return 'bg-yellow-100 text-yellow-800';
|
||||
if (score <= 80) return 'bg-orange-100 text-orange-800';
|
||||
return 'bg-red-100 text-red-800';
|
||||
}
|
||||
|
||||
export function getStatusBadge(status: string): { label: string; cls: string } {
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: 'Pendente', cls: 'bg-blue-100 text-blue-800' },
|
||||
confirmed: { label: 'Fraude', cls: 'bg-red-100 text-red-800' },
|
||||
false_positive: { label: 'Falso Positivo', cls: 'bg-green-100 text-green-800' },
|
||||
escalated: { label: 'Escalado', cls: 'bg-yellow-100 text-yellow-800' },
|
||||
};
|
||||
return map[status] || { label: status, cls: 'bg-gray-100 text-gray-800' };
|
||||
}
|
||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
18
frontend/src/pages/AlertsPage.tsx
Normal file
18
frontend/src/pages/AlertsPage.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { useState } from 'react';
|
||||
import AlertQueue from '../components/alerts/AlertQueue';
|
||||
import AlertDetailPanel from '../components/alerts/AlertDetail';
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="w-2/5 border-r bg-white overflow-hidden">
|
||||
<AlertQueue selectedId={selectedId} onSelect={setSelectedId} />
|
||||
</div>
|
||||
<div className="w-3/5 overflow-hidden">
|
||||
<AlertDetailPanel alertId={selectedId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
frontend/src/pages/LoginPage.tsx
Normal file
89
frontend/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { Shield, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/alerts');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Falha no login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-blue-900 to-gray-800 p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-blue-600 mb-4">
|
||||
<Shield className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">FraudShield</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Motor de Detecção de Fraudes</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm"
|
||||
placeholder="analista@fraudshield.local"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Senha</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Entrar
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 p-3 bg-gray-50 rounded-lg text-xs text-gray-500">
|
||||
<p className="font-medium mb-1">Credenciais de teste:</p>
|
||||
<p>analista@fraudshield.local / qualquer senha</p>
|
||||
<p>admin@fraudshield.local / qualquer senha</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/tailwind.config.ts
Normal file
13
frontend/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
sidebar: '#1a1a2e',
|
||||
accent: '#3b82f6',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
frontend/vite.config.ts
Normal file
11
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue