first
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
Binary file not shown.
Generated
+27
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Binary file not shown.
Generated
+6555
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)' }}><{selectedCampaign.Von}></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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }}><{campaign.Von}></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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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*'],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user