This commit is contained in:
m3tam3re
2026-07-16 15:05:58 +02:00
commit 514ee9a2e3
38 changed files with 9020 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const baserowToken = process.env.BASEROW_API_TOKEN?.trim();
const baserowTableUrl = process.env.BASEROW_TABLE_URL?.trim();
const campaignsUrl = process.env.BASEROW_CAMPAIGNS_TABLE_URL?.trim();
if (baserowToken) {
let apiUrl = '';
if (campaignsUrl && campaignsUrl.includes('/database/')) {
const match = campaignsUrl.match(/\/table\/(\d+)/);
if (match) {
const tableId = match[1];
const baseUrl = new URL(campaignsUrl).origin;
apiUrl = `${baseUrl}/api/database/rows/table/${tableId}/?user_field_names=true&size=200`;
}
} else if (baserowTableUrl && baserowTableUrl.includes('/database/')) {
const baseUrl = new URL(baserowTableUrl).origin;
// Fallback: Table 1103 is "Simulationen"
apiUrl = `${baseUrl}/api/database/rows/table/1103/?user_field_names=true&size=200`;
}
if (!apiUrl) {
return NextResponse.json([]);
}
let allResults: any[] = [];
let nextUrl: string | null = apiUrl;
while (nextUrl) {
nextUrl = nextUrl.replace(/^http:\/\//i, 'https://');
const response: Response = await fetch(nextUrl, {
headers: {
Authorization: `Token ${baserowToken}`,
},
});
if (!response.ok) {
throw new Error('Fehler beim Abrufen der Baserow-Daten (Campaigns): ' + response.status);
}
const data: any = await response.json();
if (data.results) {
allResults = allResults.concat(data.results);
}
nextUrl = data.next;
}
const transformedData = allResults.map((row: any) => ({
Simulation: row['Simulation'] || '',
Quelle: row['Quelle']?.value || '',
Simulationsname: row['Simulationsname']?.value || '',
Simulationstyp: row['Simulationstyp']?.value || '',
Vorschau: row['Vorschau'] || [],
Absendername: row['Absendername'] || '',
Von: row['Von'] || '',
Betreff: row['Betreff'] || '',
'Phishing-Link/Anlage': row['Phishing-Link/Anlage'] || ''
}));
return NextResponse.json(transformedData);
}
return NextResponse.json([]);
} catch (error: any) {
console.error('API Error (Campaigns):', error);
return NextResponse.json({ error: error.message || 'Interner Serverfehler' }, { status: 500 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const body = await request.json();
if (body.password === process.env.DASHBOARD_PASSWORD) {
const response = NextResponse.json({ success: true });
// Set cookie for 1 day
response.cookies.set({
name: 'auth_session',
value: 'authenticated',
httpOnly: true,
path: '/',
maxAge: 60 * 60 * 24,
});
return response;
}
return NextResponse.json({ success: false }, { status: 401 });
}
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
// Das ist die Schnittstelle, die Live-Daten von Baserow holt.
export async function GET() {
try {
const baserowToken = process.env.BASEROW_API_TOKEN?.trim();
const baserowTableUrl = (process.env.BASEROW_PHISHING_TABLE_URL || process.env.BASEROW_TABLE_URL)?.trim();
// 1. Wenn Baserow-Zugangsdaten vorhanden sind, Live-Daten abrufen:
if (baserowToken && baserowTableUrl) {
let apiUrl = baserowTableUrl;
// Konvertiere UI URL (z.B. https://br.az-gruppe.com/database/423/table/1091/4099) in API URL
if (baserowTableUrl.includes('/database/')) {
const match = baserowTableUrl.match(/\/table\/(\d+)/);
if (match) {
const tableId = match[1];
const baseUrl = new URL(baserowTableUrl).origin;
apiUrl = `${baseUrl}/api/database/rows/table/${tableId}/?user_field_names=true&size=200`;
}
}
let allResults: any[] = [];
let nextUrl: string | null = apiUrl;
while (nextUrl) {
// Fix: Falls Baserow HTTP statt HTTPS für next_url zurückgibt (z.B. wegen Reverse Proxy),
// zwingen wir es auf HTTPS, damit fetch den Authorization-Header beim Redirect nicht droppt!
nextUrl = nextUrl.replace(/^http:\/\//i, 'https://');
const response: Response = await fetch(nextUrl, {
headers: {
Authorization: `Token ${baserowToken}`,
},
});
if (!response.ok) {
const errText = await response.text();
throw new Error('Fehler beim Abrufen der Baserow-Daten: ' + response.status + ' ' + errText);
}
const data: any = await response.json();
if (data.results) {
allResults = allResults.concat(data.results);
}
nextUrl = data.next; // Pagination von Baserow
}
// Transformiere Baserow-Daten in das Format, das das Dashboard erwartet (wie CSV)
const transformedData = allResults.map((row: any) => ({
Simulationstyp: row.Simulationstyp?.[0]?.value?.value || '',
Name: row.Name?.value || row.Name || '',
NameColor: row.Name?.color || '',
Gelesen: row.Gelesen ? 'True' : 'False',
'Link Geklickt': row['Link Geklickt']?.value || row['Link Geklickt'] || '',
'Anmeldeinfos eingegeben': row['Anmeldeinfos eingegeben']?.value || row['Anmeldeinfos eingegeben'] || '',
'IT Kontakt': row['IT Kontakt']?.value || row['IT Kontakt'] || '',
'Gelöscht': row['Gelöscht'] ? 'True' : 'False',
'Weitergeleitet': row['Weitergeleitet'] ? 'True' : 'False',
'Anlage geöffnet': row['Anlage geöffnet']?.value || row['Anlage geöffnet'] || '',
'Gesendet': row['Gesendet'] || '',
'Abgeschlossen': row['Abgeschlossen'] || '',
'Simulation': row['Simulation']?.[0]?.value || '',
}));
return NextResponse.json(transformedData);
}
return NextResponse.json([]);
} catch (error: any) {
console.error('API Error:', error);
return NextResponse.json({ error: error.message || 'Interner Serverfehler' }, { status: 500 });
}
}
+252
View File
@@ -0,0 +1,252 @@
:root {
--background: #fdfdfd;
--foreground: #444444;
--card-bg: #ffffff;
--card-border: #e0e0e0;
--primary: #e3000f; /* AZ INTEC Red */
--primary-hover: #c2000d;
--danger: #d32f2f;
--danger-light: #ffebee;
--success: #2e7d32;
--success-light: #e8f5e9;
--text-muted: #777777;
--font-sans: 'Montserrat', 'Corbel', system-ui, -apple-system, sans-serif;
--radius: 8px; /* Softer edges for a more modern premium feel */
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0f172a;
--foreground: #f1f5f9;
--card-bg: #1e293b;
--card-border: #334155;
--primary: #3b82f6;
--primary-hover: #60a5fa;
--danger: #ef4444;
--danger-light: rgba(239, 68, 68, 0.1);
--success: #22c55e;
--success-light: rgba(34, 197, 94, 0.1);
--text-muted: #94a3b8;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
line-height: 1.5;
min-height: 100vh;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--primary-hover);
}
.container {
max-width: 1600px; /* Made slightly wider to fit filters and side-by-side charts better */
margin: 0 auto;
padding: 2rem;
}
/* Premium Card Design - Flat & Crisp */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); /* Enhanced shadow for premium feel */
}
.card-title {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
color: var(--text-muted);
}
.card-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--foreground);
margin-bottom: 0.25rem;
line-height: 1.2;
}
/* Form Elements */
.input {
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--card-border);
border-radius: var(--radius);
background: var(--card-bg);
color: var(--foreground);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(227, 0, 15, 0.1);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--primary);
color: #ffffff;
padding: 0.6rem 1.25rem;
border: none;
border-radius: var(--radius);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.btn:hover {
background: var(--primary-hover);
}
.btn:active {
transform: scale(0.98);
}
/* Header & Logo */
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--card-border);
}
.logo-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-text {
font-weight: 800;
font-size: 1.5rem;
color: var(--foreground);
letter-spacing: -0.02em;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
color: var(--foreground);
margin-bottom: 0.25rem;
}
/* Filter Bar Styling */
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
margin-bottom: 2rem;
background: var(--card-bg);
padding: 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--card-border);
align-items: flex-end;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
}
/* Trend Indicator Styling */
.trend-indicator {
display: inline-flex;
align-items: center;
font-size: 0.875rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 4px;
margin-top: 0.5rem;
}
.trend-positive {
color: var(--success);
background-color: var(--success-light);
}
.trend-negative {
color: var(--danger);
background-color: var(--danger-light);
}
.trend-neutral {
color: var(--text-muted);
background-color: rgba(0,0,0,0.05);
}
/* Utilities */
.flex-center {
display: flex;
align-items: center;
}
.gap-2 {
gap: 0.5rem;
}
.w-full {
width: 100%;
}
/* Tabs Styling */
.tabs {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
border-bottom: 2px solid var(--card-border);
}
.tab {
background: none;
border: none;
font-size: 1.05rem;
font-weight: 600;
color: var(--text-muted);
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px; /* Overlap border */
transition: all 0.2s;
}
.tab:hover {
color: var(--foreground);
}
.tab-active {
color: var(--primary);
border-bottom-color: var(--primary);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AZ-Phish-Board",
description: "Internes Auswertungstool für Phishing Simulationen",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body>{children}</body>
</html>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
router.push('/');
} else {
setError('Falsches Passwort');
}
};
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', backgroundColor: 'var(--background)' }}>
<div className="card" style={{ width: '100%', maxWidth: '400px', padding: '2.5rem 2rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '2rem' }}>
<img src="https://www.azintec.com/images/logo.png" alt="AZ INTEC" style={{ height: '56px', width: 'auto', marginBottom: '1.5rem' }} />
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>AZ-Phish-Board</p>
</div>
{error && (
<div style={{ color: 'var(--danger)', marginBottom: '1.5rem', textAlign: 'center', fontSize: '0.875rem', fontWeight: 600 }}>
{error}
</div>
)}
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text-muted)', fontSize: '0.875rem', fontWeight: 600 }}>Passwort</label>
<input
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Bitte Passwort eingeben"
required
/>
</div>
<button type="submit" className="btn" style={{ width: '100%', padding: '0.75rem' }}>
Anmelden
</button>
</form>
</div>
</div>
);
}
+544
View File
@@ -0,0 +1,544 @@
"use client";
import { useEffect, useState } from 'react';
import { FileText } from 'lucide-react';
import { parseDateString, isDateInRange } from '../lib/utils';
import { PhishingRow, CampaignData } from '../types';
import OverviewTab from '../components/OverviewTab';
import MatrixTab from '../components/MatrixTab';
import CampaignTab from '../components/CampaignTab';
import TrendTab from '../components/TrendTab';
import InteractionTab from '../components/InteractionTab';
import UserTab from '../components/UserTab';
export default function Home() {
const [data, setData] = useState<any[]>([]);
const [campaigns, setCampaigns] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Tabs
const [activeTab, setActiveTab] = useState<'übersicht' | 'matrix' | 'diagramme' | 'trend' | 'interaktion' | 'nutzer'>('übersicht');
const [campaignSortOrder, setCampaignSortOrder] = useState('date-desc');
// Filters
const [selectedSimulation, setSelectedSimulation] = useState('Alle');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// Reference Filters
const [refStartDate, setRefStartDate] = useState('');
const [refEndDate, setRefEndDate] = useState('');
useEffect(() => {
Promise.all([
fetch('/api/phishing').then(async res => {
if (!res.ok) throw new Error(`API Error Phishing (${res.status}): ${await res.text()}`);
return res.json();
}),
fetch('/api/campaigns').then(async res => {
if (!res.ok) throw new Error(`API Error Campaigns (${res.status}): ${await res.text()}`);
return res.json();
})
])
.then(([phishingData, campaignsData]) => {
// Globaler Filter: Ignoriere Daten, deren "Abgeschlossen"-Datum in der Zukunft liegt
const now = new Date();
now.setHours(23, 59, 59, 999); // Ende des heutigen Tages
const validPhishingData = phishingData.filter((row: any) => {
if (!row['Abgeschlossen']) return true;
const abgDate = parseDateString(row['Abgeschlossen']);
if (!abgDate) return true;
return abgDate <= now;
});
setData(validPhishingData);
setCampaigns(campaignsData);
setLoading(false);
})
.catch(err => {
console.error(err);
setErrorMsg(err.message);
setLoading(false);
});
}, []);
if (loading) {
return <div className="container" style={{ textAlign: 'center', marginTop: '20vh' }}>Lade Daten...</div>;
}
if (errorMsg) {
return (
<div className="container" style={{ textAlign: 'center', marginTop: '20vh', color: 'red' }}>
<h2>Fehler beim Laden der Daten</h2>
<pre style={{ whiteSpace: 'pre-wrap', textAlign: 'left', background: '#333', padding: '1rem', color: '#fff' }}>{errorMsg}</pre>
</div>
);
}
const allSimulations = Array.from(new Set(data.map(row => row.Simulation).filter(Boolean)));
const simulationOptions = ['Alle', ...allSimulations];
// Extract unique valid dates from the data and sort descending
const availableDates = Array.from(new Set(data.map(row => {
const parsed = parseDateString(row.Gesendet);
// Use ISO string (YYYY-MM-DD) as the internal value
if (!parsed) return '';
const tzOffset = parsed.getTimezoneOffset() * 60000; // offset in milliseconds
const localISOTime = (new Date(parsed.getTime() - tzOffset)).toISOString().split('T')[0];
return localISOTime;
}).filter(Boolean))).sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
const formatDateForDisplay = (isoStr: string) => {
const d = new Date(isoStr);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
};
// 1. Filter Current Period Data
const filteredData = data.filter(row => {
if (selectedSimulation !== 'Alle' && row.Simulation !== selectedSimulation) return false;
return isDateInRange(row.Gesendet, startDate, endDate);
});
// 2. Filter Reference Period Data
const refFilteredData = data.filter(row => {
if (selectedSimulation !== 'Alle' && row.Simulation !== selectedSimulation) return false;
// If no reference date is set, we don't calculate a reference
if (!refStartDate && !refEndDate) return false;
return isDateInRange(row.Gesendet, refStartDate, refEndDate);
});
// Preview Data (if specific simulation selected)
const selectedCampaign = selectedSimulation !== 'Alle'
? campaigns.find(c => c.Simulation === selectedSimulation)
: null;
// Calculate Metrics Function
const calculateMetrics = (dataset: any[]) => {
const total = dataset.length;
const uniqueUsers = new Set(dataset.map(d => d.Name)).size;
const uniqueSimulations = new Set(dataset.map(d => d.Simulation)).size;
const read = dataset.filter(d => d['Gelesen'] === 'True').length;
const click = dataset.filter(d => d['Link Geklickt'] === '✓').length;
const credentials = dataset.filter(d => d['Anmeldeinfos eingegeben'] === '✓').length;
// Only checkmarks, ignoring 'IT' (Tests egal)
const reported = dataset.filter(d => d['IT Kontakt'] === '✓').length;
// Checkmarks + Compromised
const reportedCompromised = dataset.filter(d => {
if (d['IT Kontakt'] !== '✓') return false;
const isClicked = d['Link Geklickt'] === '✓';
const isCreds = d['Anmeldeinfos eingegeben'] === '✓';
const isAttachment = d['Anlage geöffnet'] === '✓';
return isClicked || isCreds || isAttachment;
}).length;
const deleted = dataset.filter(d => d['Gelöscht'] === 'True').length;
const forwarded = dataset.filter(d => d['Weitergeleitet'] === 'True').length;
const attachment = dataset.filter(d => d['Anlage geöffnet'] === '✓').length;
const compromised = dataset.filter(d => {
const isClicked = d['Link Geklickt'] === '✓';
const isCreds = d['Anmeldeinfos eingegeben'] === '✓';
const isAttachment = d['Anlage geöffnet'] === '✓';
const isTest = d['IT Kontakt'] === 'IT';
return (isClicked || isCreds || isAttachment) && !isTest;
}).length;
return { total, uniqueUsers, uniqueSimulations, read, click, credentials, reported, reportedCompromised, deleted, forwarded, attachment, compromised };
};
const currentMetrics = calculateMetrics(filteredData);
const refMetrics = calculateMetrics(refFilteredData);
const hasRef = Boolean(refStartDate || refEndDate);
// --- Neue Diagrammdaten berechnen ---
// 1. Kampagnen-Vergleich
const campaignsMap = new Map();
filteredData.forEach(row => {
const sim = row['Simulation'] || 'Unbekannt';
if (!campaignsMap.has(sim)) {
campaignsMap.set(sim, { name: sim, total: 0, reported: 0, compromised: 0, date: null });
}
const stats = campaignsMap.get(sim);
stats.total += 1;
if (row['IT Kontakt'] === '✓') stats.reported += 1;
// Parse Date for sorting
const dateStr = row['Gesendet'];
if (dateStr) {
const d = parseDateString(dateStr);
if (d && (!stats.date || d > stats.date)) {
stats.date = d;
}
}
const isClicked = row['Link Geklickt'] === '✓';
const isCreds = row['Anmeldeinfos eingegeben'] === '✓';
const isAttachment = row['Anlage geöffnet'] === '✓';
const isTest = row['IT Kontakt'] === 'IT';
if ((isClicked || isCreds || isAttachment) && !isTest) {
stats.compromised += 1;
}
});
const campaignComparisonData = Array.from(campaignsMap.values())
.map(c => {
const meld = c.total > 0 ? Math.round((c.reported / c.total) * 100) : 0;
const komp = c.total > 0 ? Math.round((c.compromised / c.total) * 100) : 0;
// Berechne Farbe basierend auf Risiko
let color = '#9ca3af'; // Default grau
const totalRisk = komp + meld;
if (totalRisk > 0) {
const riskRatio = komp / totalRisk; // 0 bis 1
const hue = (1 - riskRatio) * 120; // 0=rot, 120=grün
color = `hsl(${hue}, 80%, 45%)`;
}
return {
name: c.name.length > 30 ? c.name.substring(0, 30) + '...' : c.name,
fullName: c.name,
total: c.total,
Melderate: meld,
Kompromittierungsrate: komp,
riskColor: color,
dateValue: c.date ? c.date.getTime() : 0
};
})
.sort((a, b) => {
switch (campaignSortOrder) {
case 'komp-desc': return b.Kompromittierungsrate - a.Kompromittierungsrate;
case 'komp-asc': return a.Kompromittierungsrate - b.Kompromittierungsrate;
case 'meld-desc': return b.Melderate - a.Melderate;
case 'meld-asc': return a.Melderate - b.Melderate;
case 'date-desc': return b.dateValue - a.dateValue;
case 'date-asc': return a.dateValue - b.dateValue;
default: return b.Kompromittierungsrate - a.Kompromittierungsrate;
}
});
// 2. Zeitlicher Verlauf
const timeMap = new Map();
filteredData.forEach(row => {
const dateStr = row['Gesendet'];
const d = parseDateString(dateStr);
if (!d) return;
const monthYear = d.toLocaleString('de-DE', { month: 'short', year: 'numeric' });
const sortKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!timeMap.has(sortKey)) {
timeMap.set(sortKey, { name: monthYear, sortKey, total: 0, reported: 0, compromised: 0, reportedCompromised: 0 });
}
const stats = timeMap.get(sortKey);
stats.total += 1;
const isReported = row['IT Kontakt'] === '✓';
if (isReported) stats.reported += 1;
const isClicked = row['Link Geklickt'] === '✓';
const isCreds = row['Anmeldeinfos eingegeben'] === '✓';
const isAttachment = row['Anlage geöffnet'] === '✓';
const isTest = row['IT Kontakt'] === 'IT';
if ((isClicked || isCreds || isAttachment) && !isTest) {
stats.compromised += 1;
if (isReported) {
stats.reportedCompromised += 1;
}
}
});
const timeSeriesData = Array.from(timeMap.values())
.sort((a, b) => a.sortKey.localeCompare(b.sortKey))
.map(t => ({
name: t.name,
Melderate: t.total > 0 ? Math.round((t.reported / t.total) * 100) : 0,
Kompromittierungsrate: t.total > 0 ? Math.round((t.compromised / t.total) * 100) : 0,
'Komp. aber gemeldet': t.total > 0 ? Math.round((t.reportedCompromised / t.total) * 100) : 0
}));
// 3. Detail-Interaktion (Multi-Ring Donut = 100% der Mails)
let validSent = 0;
let cntGelesen = 0;
let cntKompNur = 0;
let cntMeldNur = 0;
let cntKompUndMeld = 0;
let cntGeloescht = 0;
filteredData.forEach(row => {
const isTest = row['IT Kontakt'] === 'IT';
if (isTest) return; // Test-Nutzer komplett ausklammern
validSent++;
const isClicked = row['Link Geklickt'] === '✓';
const isCreds = row['Anmeldeinfos eingegeben'] === '✓';
const isAttachment = row['Anlage geöffnet'] === '✓';
const isCompromised = isClicked || isCreds || isAttachment;
const isReported = row['IT Kontakt'] === '✓';
const isDeleted = row['Gelöscht'] === 'True' || row['Mail gelöscht'] === '✓' || row['Gelöscht'] === '✓';
const isRead = row['Gelesen'] === 'True';
// Alles was interagiert wurde, gilt als geöffnet
if (isRead || isReported || isDeleted || isCompromised) {
cntGelesen++;
}
if (isCompromised && isReported) {
cntKompUndMeld++;
} else if (isCompromised) {
cntKompNur++;
} else if (isReported) {
cntMeldNur++;
}
if (isDeleted) {
cntGeloescht++;
}
});
const pieReadData = [
{ name: 'Geöffnet / Gelesen', value: cntGelesen, color: '#f59e0b' },
{ name: 'Ignoriert', value: validSent - cntGelesen, color: 'var(--card-border)' }
];
const pieKompData = [
{ name: 'Kompromittiert (ohne Meldung)', value: cntKompNur, color: 'var(--danger)' },
{ name: 'Rest', value: validSent - cntKompNur, color: 'var(--card-border)' }
];
const pieMeldData = [
{ name: 'Gemeldet (ohne Komprom.)', value: cntMeldNur, color: 'var(--success)' },
{ name: 'Rest', value: validSent - cntMeldNur, color: 'var(--card-border)' }
];
const pieKompUndMeldData = [
{ name: 'Kompromittiert und Gemeldet', value: cntKompUndMeld, color: '#8b5cf6' },
{ name: 'Rest', value: validSent - cntKompUndMeld, color: 'var(--card-border)' }
];
const pieDelData = [
{ name: 'Gelöscht', value: cntGeloescht, color: '#3b82f6' },
{ name: 'Rest', value: validSent - cntGeloescht, color: 'var(--card-border)' }
];
const customLegend = [
{ value: 'Geöffnet / Gelesen', type: 'circle', color: '#f59e0b' },
{ value: 'Gelöscht', type: 'circle', color: '#3b82f6' },
{ value: 'Kompromittiert (ohne Meldung)', type: 'circle', color: 'var(--danger)' },
{ value: 'Kompromittiert und Gemeldet', type: 'circle', color: '#8b5cf6' },
{ value: 'Gemeldet (ohne Komprom.)', type: 'circle', color: 'var(--success)' }
];
// Dynamically sort rings: largest value outside, smallest inside
const sortedRings = [
{ id: 'read', data: pieReadData, value: cntGelesen },
{ id: 'del', data: pieDelData, value: cntGeloescht },
{ id: 'kompnur', data: pieKompData, value: cntKompNur },
{ id: 'kompmeld', data: pieKompUndMeldData, value: cntKompUndMeld },
{ id: 'meldnur', data: pieMeldData, value: cntMeldNur }
].sort((a, b) => b.value - a.value);
const COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#9ca3af'];
return (
<div className="container">
<header className="header-top">
<div className="logo-container">
<img src="https://www.azintec.com/images/logo.png" alt="AZ INTEC" style={{ height: '48px', width: 'auto' }} />
<div style={{ borderLeft: '2px solid var(--card-border)', paddingLeft: '1rem', marginLeft: '0.5rem' }}>
<p style={{ color: "var(--text-muted)", fontSize: "0.875rem", fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
AZ-Phish-Board Phishing Simulation Dashboard (Angriffssimulationstraining)
</p>
</div>
</div>
</header>
{/* FILTER BAR */}
<div className="filter-bar">
<div className="filter-group" style={{ flex: '1 1 250px' }}>
<label className="filter-label">Simulation auswählen</label>
<select
className="input"
value={selectedSimulation}
onChange={(e) => setSelectedSimulation(e.target.value)}
>
{simulationOptions.map((sim, idx) => (
<option key={idx} value={sim}>{sim}</option>
))}
</select>
</div>
<div className="filter-group" style={{ flex: '1 1 150px' }}>
<label className="filter-label">Zeitraum Von</label>
<select className="input" value={startDate} onChange={e => setStartDate(e.target.value)}>
<option value="">Alle</option>
{availableDates.map(date => <option key={date} value={date}>{formatDateForDisplay(date)}</option>)}
</select>
</div>
<div className="filter-group" style={{ flex: '1 1 150px' }}>
<label className="filter-label">Zeitraum Bis</label>
<select className="input" value={endDate} onChange={e => setEndDate(e.target.value)}>
<option value="">Alle</option>
{availableDates.map(date => <option key={date} value={date}>{formatDateForDisplay(date)}</option>)}
</select>
</div>
<div style={{ width: '1px', backgroundColor: 'var(--card-border)', height: '40px', margin: '0 1rem' }}></div>
<div className="filter-group" style={{ flex: '1 1 150px' }}>
<label className="filter-label" style={{ color: 'var(--primary)' }}>Vergleich Von</label>
<select className="input" value={refStartDate} onChange={e => setRefStartDate(e.target.value)}>
<option value="">Keiner</option>
{availableDates.map(date => <option key={date} value={date}>{formatDateForDisplay(date)}</option>)}
</select>
</div>
<div className="filter-group" style={{ flex: '1 1 150px' }}>
<label className="filter-label" style={{ color: 'var(--primary)' }}>Vergleich Bis</label>
<select className="input" value={refEndDate} onChange={e => setRefEndDate(e.target.value)}>
<option value="">Keiner</option>
{availableDates.map(date => <option key={date} value={date}>{formatDateForDisplay(date)}</option>)}
</select>
</div>
{(startDate || endDate || refStartDate || refEndDate || selectedSimulation !== 'Alle') && (
<button
className="btn"
style={{ height: '42px', padding: '0 1rem', background: 'transparent', color: 'var(--text-muted)', border: '1px solid var(--card-border)' }}
onClick={() => {
setStartDate(''); setEndDate(''); setRefStartDate(''); setRefEndDate(''); setSelectedSimulation('Alle');
}}
>
Reset
</button>
)}
</div>
{/* PREVIEW SECTION (only visible if specific simulation is selected) */}
{selectedCampaign && (
<div className="card" style={{ marginBottom: '2rem', display: 'flex', gap: '2rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 300px' }}>
<h2 className="card-title" style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FileText size={18} /> Simulations-Details
</h2>
<div style={{ display: 'grid', gap: '1rem' }}>
<div>
<span className="filter-label">Betreff</span>
<p style={{ fontWeight: 600, fontSize: '1.1rem' }}>{selectedCampaign.Betreff || '-'}</p>
</div>
<div>
<span className="filter-label">Absender</span>
<p>{selectedCampaign.Absendername ? `${selectedCampaign.Absendername} ` : ''}
{selectedCampaign.Von ? <span style={{ color: 'var(--text-muted)' }}>&lt;{selectedCampaign.Von}&gt;</span> : ''}
</p>
</div>
<div>
<span className="filter-label">Typ</span>
<p style={{ display: 'inline-block', background: 'var(--card-border)', padding: '0.2rem 0.6rem', borderRadius: '1rem', fontSize: '0.85rem' }}>
{selectedCampaign.Simulationstyp || '-'}
</p>
</div>
</div>
</div>
<div style={{ flex: '1 1 400px', textAlign: 'center', background: '#f8fafc', padding: '1rem', borderRadius: '8px', border: '1px dashed var(--card-border)' }}>
<span className="filter-label" style={{ display: 'block', marginBottom: '1rem' }}>Vorschau</span>
{selectedCampaign.Vorschau && selectedCampaign.Vorschau.length > 0 ? (
<a href={selectedCampaign.Vorschau[0].url} target="_blank" rel="noopener noreferrer">
<img
src={selectedCampaign.Vorschau[0].url}
alt="Vorschau"
style={{ maxWidth: '100%', maxHeight: '200px', objectFit: 'contain', borderRadius: '4px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}
/>
</a>
) : (
<div style={{ padding: '2rem', color: 'var(--text-muted)' }}>Kein Bild verfügbar</div>
)}
</div>
</div>
)}
{/* TABS NAVIGATION */}
<div className="tabs">
<button
className={`tab ${activeTab === 'übersicht' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('übersicht')}
>
Übersicht
</button>
<button
className={`tab ${activeTab === 'matrix' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('matrix')}
>
Risiko-Matrix
</button>
<button
className={`tab ${activeTab === 'diagramme' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('diagramme')}
>
Kampagnen-Vergleich
</button>
<button
className={`tab ${activeTab === 'trend' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('trend')}
>
Trend
</button>
<button
className={`tab ${activeTab === 'interaktion' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('interaktion')}
>
Nutzer-Verhalten
</button>
<button
className={`tab ${activeTab === 'nutzer' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('nutzer')}
>
Nutzer-Auswertung
</button>
</div>
{activeTab === 'übersicht' && (
<OverviewTab currentMetrics={currentMetrics} refMetrics={refMetrics} hasRef={hasRef} />
)}
{activeTab === 'matrix' && (
<MatrixTab campaignComparisonData={campaignComparisonData} />
)}
{activeTab === 'diagramme' && (
<CampaignTab
campaignComparisonData={campaignComparisonData}
campaignSortOrder={campaignSortOrder}
setCampaignSortOrder={setCampaignSortOrder}
/>
)}
{activeTab === 'trend' && (
<TrendTab timeSeriesData={timeSeriesData} />
)}
{activeTab === 'interaktion' && (
<InteractionTab
sortedRings={sortedRings}
validSent={validSent}
customLegend={customLegend}
/>
)}
{activeTab === 'nutzer' && (
<UserTab data={filteredData} campaignsData={campaigns} />
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import React, { useEffect, useState } from 'react';
interface AnimatedNumberProps {
value: number;
duration?: number;
}
export const AnimatedNumber: React.FC<AnimatedNumberProps> = ({ value, duration = 1000 }) => {
const [displayValue, setDisplayValue] = useState(0);
useEffect(() => {
let startTimestamp: number | null = null;
const endValue = value;
const step = (timestamp: number) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
// easeOutQuart for smooth deceleration
const easeOut = 1 - Math.pow(1 - progress, 4);
setDisplayValue(Math.floor(easeOut * endValue));
if (progress < 1) {
window.requestAnimationFrame(step);
} else {
setDisplayValue(endValue);
}
};
window.requestAnimationFrame(step);
}, [value, duration]);
return <span>{displayValue}</span>;
};
+64
View File
@@ -0,0 +1,64 @@
import React from 'react';
import { TrendingUp } from 'lucide-react';
import { ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Tooltip as RechartsTooltip, Legend, Bar } from 'recharts';
interface CampaignTabProps {
campaignComparisonData: any[];
campaignSortOrder: string;
setCampaignSortOrder: (order: string) => void;
}
export default function CampaignTab({ campaignComparisonData, campaignSortOrder, setCampaignSortOrder }: CampaignTabProps) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: "1.5rem", marginBottom: "2rem" }}>
<div className="card" style={{ height: '600px', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem', marginBottom: '1rem' }}>
<div>
<h2 className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<TrendingUp size={16} /> Kampagnen-Vergleich
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
<b style={{ color: 'var(--danger)' }}>Roter Balken:</b> Anteil der Nutzer, die kompromittiert wurden.<br />
<b style={{ color: 'var(--success)' }}>Grüner Balken:</b> Anteil der Nutzer, die es an die IT gemeldet haben.
</p>
</div>
<select
value={campaignSortOrder}
onChange={e => setCampaignSortOrder(e.target.value)}
style={{ padding: '0.5rem', borderRadius: '4px', border: '1px solid var(--card-border)', backgroundColor: 'var(--card-bg)', color: 'var(--foreground)', fontSize: '0.9rem', outline: 'none', cursor: 'pointer' }}
>
<optgroup label="Risiko (Kompromittierungsrate)">
<option value="komp-desc">Höchste zuerst (Gefährlichste)</option>
<option value="komp-asc">Niedrigste zuerst (Sicherste)</option>
</optgroup>
<optgroup label="Awareness (Melderate)">
<option value="meld-desc">Höchste zuerst</option>
<option value="meld-asc">Niedrigste zuerst</option>
</optgroup>
<optgroup label="Zeitpunkt (Gesendet)">
<option value="date-desc">Neueste zuerst</option>
<option value="date-asc">Älteste zuerst</option>
</optgroup>
</select>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden', paddingRight: '10px' }}>
<div style={{ height: `${Math.max(400, campaignComparisonData.length * 60)}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={campaignComparisonData} layout="vertical" margin={{ top: 10, right: 30, left: 20, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" opacity={0.2} horizontal={true} vertical={false} />
<XAxis type="number" stroke="var(--text-muted)" tick={{ fontSize: 12 }} unit="%" />
<YAxis dataKey="name" type="category" stroke="var(--text-muted)" tick={{ fontSize: 12 }} width={180} />
<RechartsTooltip cursor={{ fill: 'var(--card-border)', opacity: 0.4 }} contentStyle={{ backgroundColor: 'var(--card-bg)', borderColor: 'var(--card-border)', borderRadius: '8px', color: 'var(--foreground)', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }} />
<Legend verticalAlign="top" height={36} iconType="circle" wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="Kompromittierungsrate" fill="var(--danger)" name="Kompromittierungsrate (%)" radius={[0, 4, 4, 0]} maxBarSize={20} />
<Bar dataKey="Melderate" fill="var(--success)" name="Melderate (%)" radius={[0, 4, 4, 0]} maxBarSize={20} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { Eye } from 'lucide-react';
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip as RechartsTooltip } from 'recharts';
interface InteractionTabProps {
sortedRings: any[];
validSent: number;
customLegend: any[];
}
export default function InteractionTab({ sortedRings, validSent, customLegend }: InteractionTabProps) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: "1.5rem", marginBottom: "2rem" }}>
<div className="card" style={{ height: '650px', display: 'flex', flexDirection: 'column' }}>
<h2 className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Eye size={16} /> Nutzer-Verhalten
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '1rem' }}>
Jeder Ring repräsentiert 100% aller versendeten E-Mails. Die Farbigen Segmente zeigen den exakten Anteil der Nutzer an, die die jeweilige Aktion durchgeführt haben. Die Bereiche können sich überlappen (z.B. ein Nutzer kann gelesen, geklickt und gemeldet haben).
</p>
<div style={{ flex: 1, minHeight: 0 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
{sortedRings.map((ring, index) => {
const outer = 200 - (index * 30);
const inner = outer - 30;
return (
<Pie key={ring.id} data={ring.data} cx="50%" cy="50%" startAngle={90} endAngle={-270} innerRadius={inner} outerRadius={outer} dataKey="value" stroke="none">
{ring.data.map((entry: any, i: number) => <Cell key={`cell-${ring.id}-${i}`} fill={entry.color} />)}
</Pie>
);
})}
<RechartsTooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
if (data.name.startsWith('Nicht ') || data.name === 'Ignoriert') {
return null; // Zeige nichts bei grauem "Rest"
}
const perc = validSent > 0 ? Math.round((data.value / validSent) * 100) : 0;
return (
<div style={{ backgroundColor: 'var(--card-bg)', border: '1px solid var(--card-border)', padding: '10px', borderRadius: '8px', color: 'var(--foreground)', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}>
<p style={{ margin: 0, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%', backgroundColor: data.color }}></span>
{data.name}
</p>
<p style={{ margin: 0, marginTop: '4px' }}>{`${data.value} Nutzer (${perc}%)`}</p>
</div>
);
}
return null;
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '20px', marginTop: '10px', fontSize: '14px', color: 'var(--foreground)' }}>
{customLegend.map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ display: 'inline-block', width: '12px', height: '12px', borderRadius: '50%', backgroundColor: item.color }}></span>
{item.value}
</div>
))}
</div>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import { ResponsiveContainer, ScatterChart, CartesianGrid, XAxis, YAxis, ZAxis, Tooltip as RechartsTooltip, Scatter, Cell } from 'recharts';
interface MatrixTabProps {
campaignComparisonData: any[];
}
export default function MatrixTab({ campaignComparisonData }: MatrixTabProps) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: "1.5rem", marginBottom: "2rem" }}>
<div className="card" style={{ height: '600px', display: 'flex', flexDirection: 'column' }}>
<h2 className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<AlertTriangle size={16} /> Risiko-Matrix (Melderate vs. Kompromittierungsrate)
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '1rem' }}>
Je weiter <b>links oben</b> ein Punkt ist, desto gefährlicher war die Simulation (hohe Klickrate, wenig Meldungen).
Je weiter <b>rechts unten</b>, desto sicherer haben die Nutzer reagiert.<br />
Die <b>Größe des Punktes</b> spiegelt die Anzahl der Empfänger wider (großer Punkt = viele Empfänger).
</p>
<div style={{ flex: 1, minHeight: 0 }}>
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 20, right: 30, left: 10, bottom: 20 }}>
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
<XAxis
dataKey="Melderate"
type="number"
stroke="var(--text-muted)"
tick={{ fontSize: 12 }}
domain={[0, 'dataMax + 10']}
label={{ value: 'Melderate (%)', position: 'insideBottom', offset: -10, fill: 'var(--text-muted)', fontSize: 12 }}
/>
<YAxis
dataKey="Kompromittierungsrate"
type="number"
stroke="var(--text-muted)"
tick={{ fontSize: 12 }}
domain={[0, 'dataMax + 10']}
label={{ value: 'Kompromittierungsrate (%)', angle: -90, position: 'insideLeft', fill: 'var(--text-muted)', fontSize: 12 }}
/>
<ZAxis dataKey="total" range={[150, 400]} name="Empfänger" />
<RechartsTooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div style={{ backgroundColor: 'var(--card-bg)', border: '1px solid var(--card-border)', padding: '12px', borderRadius: '8px', color: 'var(--foreground)', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}>
<p style={{ fontWeight: 'bold', marginBottom: '8px', borderBottom: '1px solid var(--card-border)', paddingBottom: '4px' }}>
{data.fullName}
</p>
<p style={{ fontSize: '0.9rem', marginBottom: '4px' }}>Gesendet an: <b>{data.total}</b></p>
<p style={{ fontSize: '0.9rem', marginBottom: '4px', color: 'var(--danger)' }}>Komp.-Rate: <b>{data.Kompromittierungsrate}%</b></p>
<p style={{ fontSize: '0.9rem', color: 'var(--success)' }}>Melderate: <b>{data.Melderate}%</b></p>
</div>
);
}
return null;
}}
/>
<Scatter name="Kampagnen" data={campaignComparisonData}>
{campaignComparisonData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.riskColor} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import React from 'react';
import { Send, Users, Eye, Trash2, ShieldAlert, CheckCircle, TrendingUp, TrendingDown, Minus, ShieldCheck } from 'lucide-react';
import { Metrics } from '../types';
import { AnimatedNumber } from './AnimatedNumber';
interface TrendProps {
current: number;
refValue: number;
hasRef: boolean;
totalCurrent?: number;
totalRef?: number;
invertGoodBad?: boolean;
absolute?: boolean;
neutralOnly?: boolean;
}
export const Trend: React.FC<TrendProps> = ({ current, refValue, hasRef, totalCurrent, totalRef, invertGoodBad = false, absolute = false, neutralOnly = false }) => {
if (!hasRef) return null;
if (!absolute && (totalRef === 0 || totalRef === undefined || totalCurrent === undefined)) return null;
const valCurrent = absolute ? current : (totalCurrent! > 0 ? (current / totalCurrent!) * 100 : 0);
const valRef = absolute ? refValue : (totalRef! > 0 ? (refValue / totalRef!) * 100 : 0);
const diff = valCurrent - valRef;
const displayRef = absolute ? refValue : `${Math.round(valRef)}%`;
if (neutralOnly || Math.abs(diff) < 0.1) {
return (
<span className="trend-indicator trend-neutral" style={{ fontSize: '0.8rem' }}>
{!neutralOnly && <Minus size={14} style={{ marginRight: '4px' }} />}
Vergleich: {displayRef}
</span>
);
}
const isIncrease = diff > 0;
const isGoodTrend = invertGoodBad ? !isIncrease : isIncrease;
const Icon = isIncrease ? TrendingUp : TrendingDown;
const trendClass = isGoodTrend ? "trend-positive" : "trend-negative";
return (
<span className={`trend-indicator ${trendClass}`} style={{ fontSize: '0.8rem' }}>
<Icon size={14} style={{ marginRight: '4px' }} />
Vergleich: {displayRef}
</span>
);
};
interface OverviewTabProps {
currentMetrics: Metrics;
refMetrics: Metrics;
hasRef: boolean;
}
export default function OverviewTab({ currentMetrics, refMetrics, hasRef }: OverviewTabProps) {
return (
<>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: "1.5rem", marginBottom: "2rem" }}>
<div className="card">
<div className="card-title flex-center gap-2"><Send size={16} /> Gesamte Mails</div>
<div className="card-value"><AnimatedNumber value={currentMetrics.total} /></div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p style={{ color: "var(--text-muted)", fontSize: "0.85rem" }}>von {currentMetrics.uniqueSimulations} verschiedenen Simulationen</p>
<Trend current={currentMetrics.total} refValue={refMetrics.total} hasRef={hasRef} absolute={true} neutralOnly={true} />
</div>
</div>
<div className="card">
<div className="card-title flex-center gap-2"><Users size={16} /> Anzahl Benutzer</div>
<div className="card-value"><AnimatedNumber value={currentMetrics.uniqueUsers} /></div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p style={{ color: "var(--text-muted)", fontSize: "0.85rem" }}>Teilnehmer im Filter</p>
<Trend current={currentMetrics.uniqueUsers} refValue={refMetrics.uniqueUsers} hasRef={hasRef} absolute={true} neutralOnly={true} />
</div>
</div>
<div className="card">
<div className="card-title flex-center gap-2" style={{ color: "var(--foreground)" }}>
<Eye size={16} /> Mail geöffnet
</div>
<div className="card-value"><AnimatedNumber value={currentMetrics.read} /></div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
Rate: {currentMetrics.total ? Math.round((currentMetrics.read / currentMetrics.total) * 100) : 0}%
</p>
<Trend current={currentMetrics.read} refValue={refMetrics.read} hasRef={hasRef} totalCurrent={currentMetrics.total} totalRef={refMetrics.total} invertGoodBad={true} />
</div>
</div>
<div className="card">
<div className="card-title flex-center gap-2" style={{ color: "var(--success)" }}>
<Trash2 size={16} /> Mail gelöscht
</div>
<div className="card-value" style={{ color: "var(--success)" }}><AnimatedNumber value={currentMetrics.deleted} /></div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
Rate: {currentMetrics.total ? Math.round((currentMetrics.deleted / currentMetrics.total) * 100) : 0}%
</p>
<Trend current={currentMetrics.deleted} refValue={refMetrics.deleted} hasRef={hasRef} totalCurrent={currentMetrics.total} totalRef={refMetrics.total} invertGoodBad={false} />
</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: "1.5rem", marginBottom: "2rem" }}>
<div className="card">
<div className="card-title flex-center gap-2" style={{ color: "var(--danger)" }}>
<ShieldAlert size={16} /> Kompromittiert (Klick/Anhang)
</div>
<div className="card-value" style={{ color: "var(--danger)" }}><AnimatedNumber value={currentMetrics.compromised} /></div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
Rate: {currentMetrics.total ? Math.round((currentMetrics.compromised / currentMetrics.total) * 100) : 0}%
</p>
<Trend current={currentMetrics.compromised} refValue={refMetrics.compromised} hasRef={hasRef} totalCurrent={currentMetrics.total} totalRef={refMetrics.total} invertGoodBad={true} />
</div>
</div>
<div className="card">
<div className="card-title flex-center gap-2" style={{ color: "var(--success)" }}>
<CheckCircle size={16} /> IT Kontaktiert
</div>
<div className="card-value" style={{ color: "var(--success)" }}><AnimatedNumber value={currentMetrics.reported} /></div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
Rate: {currentMetrics.total ? Math.round((currentMetrics.reported / currentMetrics.total) * 100) : 0}%
</p>
<Trend current={currentMetrics.reported} refValue={refMetrics.reported} hasRef={hasRef} totalCurrent={currentMetrics.total} totalRef={refMetrics.total} invertGoodBad={false} />
</div>
</div>
<div className="card">
<div className="card-title flex-center gap-2" style={{ color: "#8b5cf6" }}>
<ShieldCheck size={16} /> Kompromittiert, aber Gemeldet
</div>
<div className="card-value" style={{ color: "#8b5cf6" }}><AnimatedNumber value={currentMetrics.reportedCompromised} /></div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
Rate: {currentMetrics.total ? Math.round((currentMetrics.reportedCompromised / currentMetrics.total) * 100) : 0}%
</p>
<Trend current={currentMetrics.reportedCompromised} refValue={refMetrics.reportedCompromised} hasRef={hasRef} totalCurrent={currentMetrics.total} totalRef={refMetrics.total} invertGoodBad={false} />
</div>
</div>
</div>
</>
);
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { FileText } from 'lucide-react';
import { ResponsiveContainer, LineChart, CartesianGrid, XAxis, YAxis, Tooltip as RechartsTooltip, Legend, Line } from 'recharts';
interface TrendTabProps {
timeSeriesData: any[];
}
export default function TrendTab({ timeSeriesData }: TrendTabProps) {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(600px, 1fr))", gap: "1.5rem", marginBottom: "2rem" }}>
<div className="card" style={{ height: '500px', display: 'flex', flexDirection: 'column' }}>
<h2 className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FileText size={16} /> Zeitlicher Verlauf
</h2>
<div style={{ flex: 1, minHeight: 0 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={timeSeriesData} margin={{ top: 20, right: 30, left: 0, bottom: 20 }}>
<CartesianGrid strokeDasharray="3 3" opacity={0.2} vertical={false} />
<XAxis dataKey="name" stroke="var(--text-muted)" tick={{ fontSize: 12 }} />
<YAxis stroke="var(--text-muted)" tick={{ fontSize: 12 }} unit="%" />
<RechartsTooltip contentStyle={{ backgroundColor: 'var(--card-bg)', borderColor: 'var(--card-border)', borderRadius: '8px', color: 'var(--foreground)', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }} />
<Legend verticalAlign="bottom" height={36} iconType="circle" wrapperStyle={{ fontSize: '12px' }} />
<Line type="monotone" dataKey="Melderate" stroke="var(--success)" strokeWidth={3} name="Melderate (%)" activeDot={{ r: 6 }} />
<Line type="monotone" dataKey="Kompromittierungsrate" stroke="var(--danger)" strokeWidth={3} name="Kompromittierungsrate (%)" activeDot={{ r: 6 }} />
<Line type="monotone" dataKey="Komp. aber gemeldet" stroke="#f59e0b" strokeWidth={3} name="Komp. aber gemeldet (%)" activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
+497
View File
@@ -0,0 +1,497 @@
import React, { useState, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Users, Search, CheckCircle, ShieldAlert, ArrowUpDown, FileText } from 'lucide-react';
import { parseDateString } from '../lib/utils';
import { PhishingRow, CampaignData } from '../types';
interface UserTabProps {
data: PhishingRow[];
campaignsData?: CampaignData[];
}
const Sparkline = ({ simulations }: { simulations: any[] }) => {
if (!simulations || simulations.length < 2) {
return <span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Zu wenig Daten</span>;
}
const width = 80;
const height = 24;
const padding = 4;
const getPoints = () => {
return simulations.map((sim, i) => {
const x = padding + (i / (simulations.length - 1)) * (width - 2 * padding);
let y = height / 2; // middle (ignored)
if (sim.isCompromised) y = padding; // top (bad)
if (sim.isReported && !sim.isCompromised) y = height - padding; // bottom (good)
return { x, y, sim };
});
};
const points = getPoints();
const pathData = `M ${points.map(p => `${p.x},${p.y}`).join(' L ')}`;
return (
<svg width={width} height={height} style={{ overflow: 'visible' }}>
<path
d={pathData}
fill="none"
stroke="var(--text-muted)"
strokeWidth="1.5"
strokeOpacity="0.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
{points.map((p, i) => {
let color = "var(--text-muted)";
if (p.sim.isCompromised) color = "var(--danger)";
else if (p.sim.isReported) color = "var(--success)";
return (
<circle
key={i}
cx={p.x}
cy={p.y}
r={p.sim.isCompromised || p.sim.isReported ? 3 : 2}
fill={color}
/>
);
})}
</svg>
);
};
const HoverTooltip = ({ children, sim, campaign }: { children: React.ReactNode, sim: any, campaign: CampaignData | undefined }) => {
const [isHovered, setIsHovered] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0, position: 'top' });
const ref = useRef<HTMLDivElement>(null);
if (!sim) return <>{children}</>;
const handleMouseEnter = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
const spaceAbove = rect.top;
if (spaceAbove < 350) {
// Not enough space above, position below
setCoords({ x: rect.left + rect.width / 2, y: rect.bottom + 12, position: 'bottom' });
} else {
// Position above
setCoords({ x: rect.left + rect.width / 2, y: rect.top - 12, position: 'top' });
}
}
setIsHovered(true);
};
return (
<div
ref={ref}
style={{ display: 'inline-block' }}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)}
>
{children}
{isHovered && campaign && typeof document !== 'undefined' && createPortal(
<div style={{
position: 'fixed',
top: coords.y,
left: coords.x,
transform: coords.position === 'top' ? 'translate(-50%, -100%)' : 'translate(-50%, 0)',
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
padding: '24px',
borderRadius: '12px',
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
zIndex: 9999,
width: 'max-content',
minWidth: '650px',
maxWidth: '900px',
fontSize: '0.9rem',
color: 'var(--text-color)',
pointerEvents: 'none',
display: 'flex',
flexDirection: 'column',
gap: '20px',
textAlign: 'left'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#64748b', fontSize: '0.8rem', fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' }}>
<FileText size={16} /> Simulations-Details
</div>
<div style={{ display: 'flex', gap: '32px' }}>
{/* Left Column: Metadata */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', minWidth: '300px', flex: 1 }}>
<div>
<div style={{ color: '#64748b', marginBottom: '6px', fontSize: '0.85rem' }}>Betreff</div>
<div style={{ fontSize: '1.2rem', fontWeight: 600, color: '#0f172a' }}>{campaign.Betreff || '-'}</div>
</div>
<div>
<div style={{ color: '#64748b', marginBottom: '6px', fontSize: '0.85rem' }}>Absender</div>
<div style={{ fontSize: '1rem', color: '#334155' }}>
{campaign.Absendername && campaign.Von
? <>{campaign.Absendername} <span style={{ color: '#64748b' }}>&lt;{campaign.Von}&gt;</span></>
: (campaign.Absendername || campaign.Von || '-')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ color: '#64748b', fontSize: '0.85rem' }}>Typ</div>
<div style={{ background: '#e2e8f0', padding: '6px 12px', borderRadius: '20px', fontSize: '0.85rem', color: '#334155', fontWeight: 500 }}>
{campaign.Simulationstyp || '-'}
</div>
</div>
</div>
{/* Right Column: Vorschau */}
<div style={{ flex: 1.5, background: '#f8fafc', border: '1px dashed #cbd5e1', borderRadius: '8px', padding: '16px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ color: '#475569', fontWeight: 600, marginBottom: '16px' }}>Vorschau</div>
{campaign.Vorschau && campaign.Vorschau.length > 0 ? (
<img src={campaign.Vorschau[0].url} alt="Vorschau" style={{ maxWidth: '100%', maxHeight: '250px', objectFit: 'contain', background: 'white', padding: '12px', border: '1px solid #e2e8f0', borderRadius: '6px', boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }} />
) : (
<div style={{ color: '#94a3b8', fontStyle: 'italic', padding: '40px 20px', textAlign: 'center' }}>Keine Vorschau verfügbar</div>
)}
</div>
</div>
</div>,
document.body
)}
</div>
);
};
const SortIcon = ({ columnKey, activeKey }: { columnKey: string, activeKey: string }) => {
if (activeKey !== columnKey) return <ArrowUpDown size={14} style={{ opacity: 0.3, marginLeft: '4px' }} />;
return <ArrowUpDown size={14} style={{ marginLeft: '4px', color: 'var(--primary)' }} />;
};
export default function UserTab({ data, campaignsData }: UserTabProps) {
const [filterStatus, setFilterStatus] = useState<'Alle' | 'Aktiv' | 'Ausgeschieden'>('Aktiv');
const [searchQuery, setSearchQuery] = useState('');
const [sortConfig, setSortConfig] = useState<{ key: string, direction: 'asc' | 'desc' }>({ key: 'name', direction: 'asc' });
const campaignMap = useMemo(() => {
const map = new Map();
if (!campaignsData) return map;
campaignsData.forEach(c => map.set(c.Simulation, c));
return map;
}, [campaignsData]);
const userStats = useMemo(() => {
const map = new Map();
data.forEach(row => {
const name = row['Name'];
if (!name) return;
const isTest = row['IT Kontakt'] === 'IT';
if (isTest) return;
if (!map.has(name)) {
map.set(name, {
name,
color: row.NameColor || '',
total: 0,
compromised: 0,
reported: 0,
simulations: []
});
}
const user = map.get(name);
user.total += 1;
const isClicked = row['Link Geklickt'] === '✓';
const isCreds = row['Anmeldeinfos eingegeben'] === '✓';
const isAttachment = row['Anlage geöffnet'] === '✓';
const isRead = row['Gelesen'] === 'True' || isClicked || isCreds || isAttachment; // Wenn interagiert, wurde sie auch gelesen
const isCompromised = isClicked || isCreds || isAttachment;
const isReported = row['IT Kontakt'] === '✓';
if (isCompromised) user.compromised += 1;
if (isReported) user.reported += 1;
const dateStr = row['Gesendet'];
const date = dateStr ? parseDateString(dateStr) : null;
user.simulations.push({
simName: row['Simulation'],
date,
isCompromised,
isReported,
isRead,
isClicked,
isCreds,
isAttachment
});
});
const usersArray = Array.from(map.values())
.filter(user => user.total > 0)
.map(user => {
user.simulations.sort((a: any, b: any) => {
if (!a.date) return 1;
if (!b.date) return -1;
return a.date.getTime() - b.date.getTime();
});
const firstSim = user.simulations[0] || null;
const lastSim = user.simulations[user.simulations.length - 1] || null;
const lastCompromisedSim = [...user.simulations].reverse().find((s: any) => s.isCompromised) || null;
let trend = "Gleichbleibend";
if (user.simulations.length === 0) {
trend = "Keine Daten";
} else if (user.simulations.length >= 2) {
const firstHalf = user.simulations.slice(0, Math.ceil(user.simulations.length / 2));
const secondHalf = user.simulations.slice(Math.ceil(user.simulations.length / 2));
const firstKompRate = firstHalf.filter((s: any) => s.isCompromised).length / firstHalf.length;
const secondKompRate = secondHalf.filter((s: any) => s.isCompromised).length / secondHalf.length;
if (secondKompRate < firstKompRate) trend = "Verbessert";
else if (secondKompRate > firstKompRate) trend = "Verschlechtert";
else if (user.compromised > 0) trend = "Konstant Anfällig";
else trend = "Sicher";
} else {
if (user.compromised > 0) trend = "Gefährdet";
else trend = "Sicher";
}
const isActive = user.color !== 'darker-gray';
return {
...user,
firstSim,
lastSim,
lastCompromisedSim,
trend,
isActive
};
});
return usersArray;
}, [data]);
const filteredUsers = useMemo(() => {
return userStats.filter(user => {
if (filterStatus === 'Aktiv' && !user.isActive) return false;
if (filterStatus === 'Ausgeschieden' && user.isActive) return false;
if (searchQuery && !user.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
return true;
});
}, [userStats, filterStatus, searchQuery]);
const sortedUsers = useMemo(() => {
const sortable = [...filteredUsers];
sortable.sort((a, b) => {
let aVal = a[sortConfig.key];
let bVal = b[sortConfig.key];
if (sortConfig.key === 'compRate') {
aVal = a.total > 0 ? a.compromised / a.total : 0;
bVal = b.total > 0 ? b.compromised / b.total : 0;
} else if (sortConfig.key === 'repRate') {
aVal = a.total > 0 ? a.reported / a.total : 0;
bVal = b.total > 0 ? b.reported / b.total : 0;
}
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
return sortable;
}, [filteredUsers, sortConfig]);
const requestSort = (key: string) => {
let direction: 'asc' | 'desc' = 'desc';
if (sortConfig.key === key && sortConfig.direction === 'desc') {
direction = 'asc';
}
setSortConfig({ key, direction });
};
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: "1.5rem", marginBottom: "2rem" }}>
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem', marginBottom: '1.5rem' }}>
<div>
<h2 className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<Users size={16} /> Nutzer-Auswertung
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
Detaillierte Übersicht aller Mitarbeiter über alle Simulationen hinweg.
</p>
</div>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ position: 'relative' }}>
<Search size={16} style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input
type="text"
placeholder="Name suchen..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="input"
style={{ paddingLeft: '32px', width: '200px' }}
/>
</div>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value as any)}
className="input"
style={{ minWidth: '150px' }}
>
<option value="Alle">Alle Mitarbeiter</option>
<option value="Aktiv">Nur Aktive</option>
<option value="Ausgeschieden">Nur Ausgeschiedene</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<ShieldAlert size={14} color="var(--danger)" /> <span>Kompromittiert</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<CheckCircle size={14} color="var(--success)" /> <span>Gemeldet</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontWeight: 'bold' }}>-</span> <span>Ignoriert / Sicher</span>
</div>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)', textAlign: 'left', color: 'var(--text-muted)' }}>
<th style={{ padding: '12px 8px', cursor: 'pointer' }} onClick={() => requestSort('name')}>
<div style={{ display: 'flex', alignItems: 'center' }}>Mitarbeiter <SortIcon columnKey="name" activeKey={sortConfig.key} /></div>
</th>
<th style={{ padding: '12px 8px', cursor: 'pointer' }} onClick={() => requestSort('total')}>
<div style={{ display: 'flex', alignItems: 'center' }}>Teilnahmen <SortIcon columnKey="total" activeKey={sortConfig.key} /></div>
</th>
<th style={{ padding: '12px 8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
Kompromittiert
<div style={{ display: 'flex', gap: '4px', fontSize: '0.75rem', fontWeight: 'normal' }}>
<span style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', background: 'var(--card-border)', padding: '2px 4px', borderRadius: '4px' }} onClick={() => requestSort('compromised')} title="Nach Anzahl sortieren">
# <SortIcon columnKey="compromised" activeKey={sortConfig.key} />
</span>
<span style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', background: 'var(--card-border)', padding: '2px 4px', borderRadius: '4px' }} onClick={() => requestSort('compRate')} title="Nach Prozent sortieren">
% <SortIcon columnKey="compRate" activeKey={sortConfig.key} />
</span>
</div>
</div>
</th>
<th style={{ padding: '12px 8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
Gemeldet
<div style={{ display: 'flex', gap: '4px', fontSize: '0.75rem', fontWeight: 'normal' }}>
<span style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', background: 'var(--card-border)', padding: '2px 4px', borderRadius: '4px' }} onClick={() => requestSort('reported')} title="Nach Anzahl sortieren">
# <SortIcon columnKey="reported" activeKey={sortConfig.key} />
</span>
<span style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', background: 'var(--card-border)', padding: '2px 4px', borderRadius: '4px' }} onClick={() => requestSort('repRate')} title="Nach Prozent sortieren">
% <SortIcon columnKey="repRate" activeKey={sortConfig.key} />
</span>
</div>
</div>
</th>
<th style={{ padding: '12px 8px' }}>Erste Simulation</th>
<th style={{ padding: '12px 8px' }}>Letzte Kompromittierung</th>
<th style={{ padding: '12px 8px' }}>Letzte Simulation</th>
<th style={{ padding: '12px 8px', cursor: 'pointer' }} onClick={() => requestSort('trend')}>
<div style={{ display: 'flex', alignItems: 'center' }}>Trend <SortIcon columnKey="trend" activeKey={sortConfig.key} /></div>
</th>
</tr>
</thead>
<tbody>
{sortedUsers.map((user, i) => {
const compRate = user.total > 0 ? Math.round((user.compromised / user.total) * 100) : 0;
const repRate = user.total > 0 ? Math.round((user.reported / user.total) * 100) : 0;
return (
<tr key={i} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '12px 8px', fontWeight: 500 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{user.name}
</div>
</td>
<td style={{ padding: '12px 8px' }}>{user.total}</td>
<td style={{ padding: '12px 8px', color: user.compromised > 0 ? 'var(--danger)' : 'inherit' }}>
{user.compromised} ({compRate}%)
</td>
<td style={{ padding: '12px 8px', color: user.reported > 0 ? 'var(--success)' : 'inherit' }}>
{user.reported} ({repRate}%)
</td>
<td style={{ padding: '12px 8px', fontSize: '0.85rem' }}>
{user.firstSim ? (
<HoverTooltip sim={user.firstSim} campaign={campaignMap.get(user.firstSim.simName)}>
<div style={{ fontWeight: 500 }}>{user.firstSim.simName}</div>
{user.firstSim.date && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{user.firstSim.date.toLocaleDateString('de-DE')}
</div>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
{user.firstSim.isCompromised && <ShieldAlert size={14} color="var(--danger)" />}
{user.firstSim.isReported && <CheckCircle size={14} color="var(--success)" />}
{!user.firstSim.isCompromised && !user.firstSim.isReported && <span style={{ color: 'var(--text-muted)' }}>-</span>}
</div>
</HoverTooltip>
) : '-'}
</td>
<td style={{ padding: '12px 8px', fontSize: '0.85rem' }}>
{user.lastCompromisedSim ? (
<HoverTooltip sim={user.lastCompromisedSim} campaign={campaignMap.get(user.lastCompromisedSim.simName)}>
<div style={{ fontWeight: 500 }}>{user.lastCompromisedSim.simName}</div>
{user.lastCompromisedSim.date && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{user.lastCompromisedSim.date.toLocaleDateString('de-DE')}
</div>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
<ShieldAlert size={14} color="var(--danger)" />
{user.lastCompromisedSim.isReported && <CheckCircle size={14} color="var(--success)" />}
</div>
</HoverTooltip>
) : <span style={{ color: 'var(--text-muted)' }}>-</span>}
</td>
<td style={{ padding: '12px 8px', fontSize: '0.85rem' }}>
{user.lastSim ? (
<HoverTooltip sim={user.lastSim} campaign={campaignMap.get(user.lastSim.simName)}>
<div style={{ fontWeight: 500 }}>{user.lastSim.simName}</div>
{user.lastSim.date && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{user.lastSim.date.toLocaleDateString('de-DE')}
</div>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
{user.lastSim.isCompromised && <ShieldAlert size={14} color="var(--danger)" />}
{user.lastSim.isReported && <CheckCircle size={14} color="var(--success)" />}
{!user.lastSim.isCompromised && !user.lastSim.isReported && <span style={{ color: 'var(--text-muted)' }}>-</span>}
</div>
</HoverTooltip>
) : '-'}
</td>
<td style={{ padding: '12px 8px' }}>
<Sparkline simulations={user.simulations} />
</td>
</tr>
);
})}
{sortedUsers.length === 0 && (
<tr>
<td colSpan={7} style={{ padding: '24px', textAlign: 'center', color: 'var(--text-muted)' }}>
Keine Benutzer gefunden.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
export const parseDateString = (dateStr: string) => {
if (!dateStr) return null;
// If format is DD.MM.YYYY
if (dateStr.includes('.')) {
const parts = dateStr.split(' ')[0].split('.');
if (parts.length === 3) {
return new Date(`${parts[2]}-${parts[1]}-${parts[0]}`);
}
}
const date = new Date(dateStr);
return isNaN(date.getTime()) ? null : date;
};
export const isDateInRange = (dateStr: string, start: string, end: string) => {
if (!start && !end) return true;
const d = parseDateString(dateStr);
if (!d) return false;
d.setHours(0, 0, 0, 0);
if (start) {
const s = new Date(start);
s.setHours(0, 0, 0, 0);
if (d < s) return false;
}
if (end) {
const e = new Date(end);
e.setHours(23, 59, 59, 999);
if (d > e) return false;
}
return true;
};
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
// Protect all API routes and the root dashboard (except the login API)
if (path === '/' || (path.startsWith('/api/') && path !== '/api/login')) {
const session = request.cookies.get('auth_session');
if (!session || session.value !== 'authenticated') {
if (path.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.redirect(new URL('/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/', '/api/:path*'],
};
+38
View File
@@ -0,0 +1,38 @@
export interface PhishingRow {
Simulationstyp?: string;
Name?: string;
NameColor?: string;
Gelesen?: string;
'Link Geklickt'?: string;
'Anmeldeinfos eingegeben'?: string;
'IT Kontakt'?: string;
Gelöscht?: string;
'Mail gelöscht'?: string;
Weitergeleitet?: string;
'Anlage geöffnet'?: string;
Gesendet?: string;
Abgeschlossen?: string;
Simulation?: string;
[key: string]: any;
}
export interface CampaignData {
id: string;
value: string;
[key: string]: any;
}
export interface Metrics {
total: number;
uniqueUsers: number;
uniqueSimulations: number;
read: number;
click: number;
credentials: number;
reported: number;
reportedCompromised: number;
deleted: number;
forwarded: number;
attachment: number;
compromised: number;
}