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
+8
View File
@@ -0,0 +1,8 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.env
+4
View File
@@ -0,0 +1,4 @@
BASEROW_API_TOKEN=your_baserow_api_token_here
BASEROW_PHISHING_TABLE_URL=https://your-baserow-instance.com/database/YOUR_DB_ID/table/YOUR_TABLE_ID_1
BASEROW_CAMPAIGNS_TABLE_URL=https://your-baserow-instance.com/database/YOUR_DB_ID/table/YOUR_TABLE_ID_2
DASHBOARD_PASSWORD=your_secure_password
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# agent stugg
.pi*
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
result
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+52
View File
@@ -0,0 +1,52 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js telemetry is disabled
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
+15
View File
@@ -0,0 +1,15 @@
version: '3.8'
services:
dashboard:
build:
context: .
dockerfile: Dockerfile
container_name: phishing_dashboard
ports:
- "3000:3000"
environment:
- BASEROW_API_TOKEN=${BASEROW_API_TOKEN}
- BASEROW_TABLE_URL=${BASEROW_TABLE_URL}
- DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD}
restart: always
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
BIN
View File
Binary file not shown.
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1784011430,
"narHash": "sha256-lDebytrYdd47IBLwvNOD+6AGeoqZ78CIKlp70hzW280=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "8eeec934ae0dbeca3d7868c059568a65c08b2fc3",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+75
View File
@@ -0,0 +1,75 @@
{
description = "AZ Phishboard - Next.js phishing simulation dashboard";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
};
outputs = {
self,
nixpkgs,
}: let
systems = [
"aarch64-linux"
"x86_64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in {
nixosModules.default = import ./nixos/module.nix;
packages = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.buildNpmPackage {
pname = "phishboard";
version = "0.1.0";
src = self;
nodejs = pkgs.nodejs_22;
npmDepsHash = "sha256-4BheNdfPgXQFjAiaRyxMpmhCk/H6puSPxjxh+PY2ljA=";
NEXT_TELEMETRY_DISABLED = "1";
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r .next/standalone/. $out/
mkdir -p $out/.next
cp -r .next/static $out/.next/static
if [ -d public ]; then
cp -r public $out/public
fi
runHook postInstall
'';
meta = with pkgs.lib; {
description = "AZ Phishboard Next.js dashboard";
homepage = "https://pb.l.az-gruppe.com";
platforms = platforms.linux;
};
};
});
devShells = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.mkShell {
packages = with pkgs; [
nodejs_22
typescript
];
shellHook = ''
echo "Phishboard development environment"
echo "Node: $(node --version)"
echo "npm: $(npm --version)"
'';
};
});
};
}
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;
+94
View File
@@ -0,0 +1,94 @@
{
config,
lib,
pkgs,
...
}: let
cfg = config.services.phishboard;
in {
options.services.phishboard = {
enable = lib.mkEnableOption "AZ Phishboard Next.js service";
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = "Phishboard package to run.";
};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Host address the Phishboard service should bind to.";
};
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port the Phishboard service should listen on.";
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
File containing runtime environment variables, for example
BASEROW_API_TOKEN, BASEROW_PHISHING_TABLE_URL,
BASEROW_CAMPAIGNS_TABLE_URL, and DASHBOARD_PASSWORD.
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.package != null;
message = "services.phishboard.package must be set.";
}
];
systemd.services.phishboard = {
description = "AZ Phishboard";
wantedBy = ["multi-user.target"];
after = ["network-online.target"];
wants = ["network-online.target"];
environment = {
HOSTNAME = cfg.host;
PORT = toString cfg.port;
NODE_ENV = "production";
NEXT_TELEMETRY_DISABLED = "1";
};
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.nodejs_22}/bin/node ${cfg.package}/server.js";
WorkingDirectory = cfg.package;
DynamicUser = true;
Restart = "on-failure";
RestartSec = "10s";
CapabilityBoundingSet = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RestrictAddressFamilies = ["AF_UNIX" "AF_INET" "AF_INET6"];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = ["@system-service" "~@privileged"];
} // lib.optionalAttrs (cfg.environmentFile != null) {
EnvironmentFile = cfg.environmentFile;
};
};
};
}
BIN
View File
Binary file not shown.
+6555
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"lucide-react": "^1.24.0",
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.9.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"typescript": "^5"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+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;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}