From ea52d39e6cb6111be0f2cb0897390250988f4d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Tue, 4 Nov 2025 21:35:45 +0100 Subject: [PATCH] logintrainer --- docker-compose/logintrainer/.env | 28 + docker-compose/logintrainer/Dockerfile | 60 ++ docker-compose/logintrainer/README.md | 48 ++ .../logintrainer/docker-compose.yml | 16 + .../logintrainer/docker/entrypoint.sh | 19 + docker-compose/logintrainer/eslint.config.mjs | 23 + docker-compose/logintrainer/next-env.d.ts | 6 + docker-compose/logintrainer/next.config.mjs | 9 + docker-compose/logintrainer/nginx-config.conf | 0 docker-compose/logintrainer/package.json | 44 ++ docker-compose/logintrainer/prisma/dev.db | Bin 0 -> 40960 bytes .../20251101111709_reset/migration.sql | 38 ++ .../prisma/migrations/migration_lock.toml | 3 + .../logintrainer/prisma/schema.prisma | 42 ++ .../scripts/generate-tailwind.cjs | 21 + .../app/api/attempts/purge-failed/route.ts | 42 ++ .../logintrainer/src/app/api/cleanup/route.ts | 50 ++ .../api/leaderboard/top-performers/route.ts | 90 +++ .../logintrainer/src/app/api/login/route.ts | 137 ++++ .../src/app/api/register/route.ts | 42 ++ .../src/app/api/teacher/auth/route.ts | 22 + .../logintrainer/src/app/layout.tsx | 18 + .../leaderboard/LeaderboardIndex.module.css | 159 +++++ .../[klasse]/Leaderboard.module.css | 546 +++++++++++++++ .../src/app/leaderboard/[klasse]/page.tsx | 448 +++++++++++++ .../logintrainer/src/app/leaderboard/page.tsx | 69 ++ .../logintrainer/src/app/login/page.tsx | 356 ++++++++++ .../src/app/login/success/page.tsx | 166 +++++ .../src/app/login/success/success-stats.tsx | 436 ++++++++++++ .../src/app/login/uppercase/page.tsx | 67 ++ docker-compose/logintrainer/src/app/page.tsx | 5 + .../logintrainer/src/app/public/.keep | 0 .../logintrainer/src/app/register/page.tsx | 36 + .../src/app/teacher/cleanup/CleanupForm.tsx | 110 ++++ .../src/app/teacher/cleanup/page.tsx | 24 + .../src/app/teacher/dbdump/DumpViewer.tsx | 143 ++++ .../src/app/teacher/dbdump/page.tsx | 76 +++ .../src/app/teacher/logs/AuthGate.tsx | 75 +++ .../src/app/teacher/logs/LogsTable.tsx | 183 ++++++ .../src/app/teacher/logs/page.tsx | 54 ++ .../logintrainer/src/app/teacher/page.tsx | 88 +++ .../app/teacher/stats/StatsTable.module.css | 13 + .../src/app/teacher/stats/StatsTable.tsx | 166 +++++ .../src/app/teacher/stats/page.tsx | 139 ++++ .../app/teacher/violaters/ViolatersTable.tsx | 179 +++++ .../src/app/teacher/violaters/page.tsx | 125 ++++ .../src/components/AutoRefresh.tsx | 24 + .../src/components/NetworkBackground.tsx | 167 +++++ docker-compose/logintrainer/src/lib/auth.ts | 146 ++++ docker-compose/logintrainer/src/lib/logger.ts | 19 + docker-compose/logintrainer/src/lib/prisma.ts | 11 + .../logintrainer/src/styles/globals.css | 442 +++++++++++++ .../src/styles/tailwind.generated.css | 621 ++++++++++++++++++ .../logintrainer/tailwind.config.cjs | 8 + docker-compose/logintrainer/tsconfig.json | 26 + 55 files changed, 5885 insertions(+) create mode 100644 docker-compose/logintrainer/.env create mode 100644 docker-compose/logintrainer/Dockerfile create mode 100644 docker-compose/logintrainer/README.md create mode 100644 docker-compose/logintrainer/docker-compose.yml create mode 100644 docker-compose/logintrainer/docker/entrypoint.sh create mode 100644 docker-compose/logintrainer/eslint.config.mjs create mode 100644 docker-compose/logintrainer/next-env.d.ts create mode 100644 docker-compose/logintrainer/next.config.mjs create mode 100644 docker-compose/logintrainer/nginx-config.conf create mode 100644 docker-compose/logintrainer/package.json create mode 100644 docker-compose/logintrainer/prisma/dev.db create mode 100644 docker-compose/logintrainer/prisma/migrations/20251101111709_reset/migration.sql create mode 100644 docker-compose/logintrainer/prisma/migrations/migration_lock.toml create mode 100644 docker-compose/logintrainer/prisma/schema.prisma create mode 100644 docker-compose/logintrainer/scripts/generate-tailwind.cjs create mode 100644 docker-compose/logintrainer/src/app/api/attempts/purge-failed/route.ts create mode 100644 docker-compose/logintrainer/src/app/api/cleanup/route.ts create mode 100644 docker-compose/logintrainer/src/app/api/leaderboard/top-performers/route.ts create mode 100644 docker-compose/logintrainer/src/app/api/login/route.ts create mode 100644 docker-compose/logintrainer/src/app/api/register/route.ts create mode 100644 docker-compose/logintrainer/src/app/api/teacher/auth/route.ts create mode 100644 docker-compose/logintrainer/src/app/layout.tsx create mode 100644 docker-compose/logintrainer/src/app/leaderboard/LeaderboardIndex.module.css create mode 100644 docker-compose/logintrainer/src/app/leaderboard/[klasse]/Leaderboard.module.css create mode 100644 docker-compose/logintrainer/src/app/leaderboard/[klasse]/page.tsx create mode 100644 docker-compose/logintrainer/src/app/leaderboard/page.tsx create mode 100644 docker-compose/logintrainer/src/app/login/page.tsx create mode 100644 docker-compose/logintrainer/src/app/login/success/page.tsx create mode 100644 docker-compose/logintrainer/src/app/login/success/success-stats.tsx create mode 100644 docker-compose/logintrainer/src/app/login/uppercase/page.tsx create mode 100644 docker-compose/logintrainer/src/app/page.tsx create mode 100644 docker-compose/logintrainer/src/app/public/.keep create mode 100644 docker-compose/logintrainer/src/app/register/page.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/cleanup/CleanupForm.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/cleanup/page.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/dbdump/DumpViewer.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/dbdump/page.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/logs/AuthGate.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/logs/LogsTable.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/logs/page.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/page.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/stats/StatsTable.module.css create mode 100644 docker-compose/logintrainer/src/app/teacher/stats/StatsTable.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/stats/page.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/violaters/ViolatersTable.tsx create mode 100644 docker-compose/logintrainer/src/app/teacher/violaters/page.tsx create mode 100644 docker-compose/logintrainer/src/components/AutoRefresh.tsx create mode 100644 docker-compose/logintrainer/src/components/NetworkBackground.tsx create mode 100644 docker-compose/logintrainer/src/lib/auth.ts create mode 100644 docker-compose/logintrainer/src/lib/logger.ts create mode 100644 docker-compose/logintrainer/src/lib/prisma.ts create mode 100644 docker-compose/logintrainer/src/styles/globals.css create mode 100644 docker-compose/logintrainer/src/styles/tailwind.generated.css create mode 100644 docker-compose/logintrainer/tailwind.config.cjs create mode 100644 docker-compose/logintrainer/tsconfig.json diff --git a/docker-compose/logintrainer/.env b/docker-compose/logintrainer/.env new file mode 100644 index 0000000..4280113 --- /dev/null +++ b/docker-compose/logintrainer/.env @@ -0,0 +1,28 @@ +ADMIN_PURGE_TOKEN=sehrgeheim +DATABASE_URL="file:./dev.db" +TEACHER_LOGS_PASSWORD="strenggeheim" +# Schalter: local (bcrypt/Prisma) ODER ad (Active Directory über LDAP) +AUTH_MODE=ad + + +DOPING_THRESHOLD_MS_PER_CHAR=201 +RANKING_METHOD="time_per_char" +PASSWORD_POLICY_MIN_LENGTH=13 +NEXT_PUBLIC_SCHOOLNAME="*Deine Schule*" + + +## paedML-Novell (eDirectory) +# AUTH_MODE="ldap" +# LDAP_URL="ldaps://10.1.1.32" # GServer mit edirectory +# LDAP_URL="ldaps://gserver03.oes.ml-bw.de" # GServer mit edirectory per Name im Schulnetz +# LDAP_BASE_DN="OU=SCHULEN,O=ML3" +# LDAP_BIND_DN="cn=ldapuserTT,ou=server,ou=dienste,o=ml3" +# LDAP_BIND_PW="changeMe" + +# AD/LDAP Konfiguration für linuxmuster.net +LDAP_URL="ldaps://server.deineschule.tld:636" # am besten LDAPS:636 +LDAP_BIND_DN="CN=login-trainer,OU=Management,OU=GLOBAL,DC=deineschule,DC=tld" +LDAP_BIND_PW="PasswortVonLogin-Trainer-LDAP-USER" +LDAP_BASE_DN="DC=deineschule,DC=tld" +LDAP_DOMAIN_UPN_SUFFIX="@deineschule.tld" +LDAP_ALLOWED_GROUP_DN="" # optional: nur Mitglieder dieser Gruppe zulassen diff --git a/docker-compose/logintrainer/Dockerfile b/docker-compose/logintrainer/Dockerfile new file mode 100644 index 0000000..8679deb --- /dev/null +++ b/docker-compose/logintrainer/Dockerfile @@ -0,0 +1,60 @@ +# --- Base image -------------------------------------------------------------- +FROM node:24-alpine AS base +WORKDIR /app +# (hilft manchen native Deps; Prisma nutzt linux-musl Binaries) +RUN apk add --no-cache libc6-compat + +# --- Dependencies ------------------------------------------------------------ +FROM base AS deps +COPY package.json package-lock.json ./ +COPY prisma ./prisma +RUN npm ci + + +# --- Builder ----------------------------------------------------------------- +FROM base AS builder +ENV NEXT_TELEMETRY_DISABLED=1 +# Node-Module aus deps +COPY --from=deps /app/node_modules ./node_modules +# Projektquellen +COPY . . +# Build (empfohlen: Next standalone output, siehe Hinweis unten) +RUN mkdir -p public +RUN npm run build + +# --- Runner ------------------------------------------------------------------ +FROM node:24-alpine AS runner +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +WORKDIR /app + +# optional: curl für Healthchecks +RUN apk add --no-cache libc6-compat curl + +# App-User anlegen +RUN addgroup -S app && adduser -S app -G app + +# Standalone + Assets +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +# COPY --from=builder /app/public ./public # nur falls du public/ nutzt + +# Prisma: Schema + Engines +COPY --from=builder /app/prisma ./prisma +COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma + +# separat persistentes DB-Verzeichnis (per Compose gemountet) +RUN mkdir -p /data + +# ⬅︎ WICHTIG: Alles dem App-User übergeben, **nach** allen COPYs +RUN chown -R app:app /data + +# Entrypoint +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +USER app +EXPOSE 3000 +ENV PORT=3000 +ENTRYPOINT ["/entrypoint.sh"] +CMD ["node", "server.js"] \ No newline at end of file diff --git a/docker-compose/logintrainer/README.md b/docker-compose/logintrainer/README.md new file mode 100644 index 0000000..c675739 --- /dev/null +++ b/docker-compose/logintrainer/README.md @@ -0,0 +1,48 @@ +# Login Trainer + +Trainingsplattform zum Üben sicherer Logins, Messung von Tippgeschwindigkeit und Monitoring durch Lehrkräfte. + +## Routenübersicht + +### Öffentliche Seiten + +- `/` – Startseite mit CTA zum Login/Registrieren. +- `/login` – Login-Formular mit Tippgeschwindigkeits-Tracking. +- `/login/uppercase` – Hinweisseite, falls Benutzername Großbuchstaben enthält. +- `/login/success` – Auswertung nach erfolgreichem Login. +- `/register` – Registrierung neuer Konten. + +### Teacher-Bereich (hinter AuthGate) + +- `/teacher` – Übersichts-Dashboard mit Kacheln zu allen Modulen. (*CSS-Problem, ist noch eine List*) +- `/teacher/logs` – Tabellenansicht sämtlicher Login-Versuche. +- `/teacher/stats` – Aggregierte Statistiken. +- `/leaderboard` – Übersicht der Klassen-Highscores. +- `/teacher/violaters` – Liste der Accounts mit zu kurzen Passwörtern (inkl. Sort/Filter). +- `/teacher/dbdump` – Datenbank-Dump als Download. + +### API-Endpunkte + +- `/api/login` – Verarbeitung von Login-Versuchen. +- `/api/register` – Anlage neuer Nutzer. +- `/api/teacher/auth` – Prüfung des Teacher-Passworts für das AuthGate. +- `/api/attempts/purge-failed` – Löschen fehlgeschlagener Versuche (geschützt via `ADMIN_PURGE_TOKEN`). +- `/api/leaderboard/top-performers` - Top 3 jeder Klasse als JSON... falls mal irgendwo diese Information erscheinen soll :) + +## Umgebungsvariablen + +| Variable | Beschreibung | +| --- | --- | +| `AUTH_MODE` | Authentifizierungsmodus (`local` oder `ad`). | +| `NEXT_PUBLIC_AUTH_MODE` | Frontend-Flag zum Anzeigen des Modus. | +| `DATABASE_URL` | Pfad zur SQLite-DB. | +| `PASSWORD_POLICY_MIN_LENGTH` | Mindestlänge für Passwörter (wird für Statistiken & Policy-Checks verwendet). | +| `NEXT_PUBLIC_SCHOOLNAME` | Anzeige des Schulnamens auf der Login-Seite. | +| `DOPING_THRESHOLD_MS_PER_CHAR` | Grenzwert für „Doping“-Hinweise pro Zeichen. | +| `NEXT_PUBLIC_MIN_PASSWORD_ALERT_MESSAGE` | Text für Hinweis zu kurzen Passwörtern. | +| `LDAP_URL` / `LDAP_BASE_DN` / `LDAP_BIND_DN` / `LDAP_BIND_PW` / `LDAP_DOMAIN_PREFIX` | LDAP/AD-Konfiguration (nur bei `AUTH_MODE=ad`). | +| `TEACHER_LOGS_PASSWORD` | Passwort für das Teacher-AuthGate. | +| `ADMIN_PURGE_TOKEN` | Schutz-Token für Purge-Endpunkt. | +| `RUN_MIGRATIONS` | Steuert automatisches Ausführen von Prisma-Migrationen im Docker-Setup. | + +Weitere Beispielwerte siehe `.env.example`. Nach Änderungen an Prisma-Schema immer `npx prisma migrate deploy` und `npx prisma generate` ausführen, damit der Client aktuell ist. diff --git a/docker-compose/logintrainer/docker-compose.yml b/docker-compose/logintrainer/docker-compose.yml new file mode 100644 index 0000000..8deaee6 --- /dev/null +++ b/docker-compose/logintrainer/docker-compose.yml @@ -0,0 +1,16 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + environment: + DATABASE_URL: file:/data/logintrainer.db + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: "1" + ports: + - "3000:3000" + volumes: + - ./app-data:/data + restart: unless-stopped diff --git a/docker-compose/logintrainer/docker/entrypoint.sh b/docker-compose/logintrainer/docker/entrypoint.sh new file mode 100644 index 0000000..19747b8 --- /dev/null +++ b/docker-compose/logintrainer/docker/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -e + +SCHEMA_PATH="/app/prisma/schema.prisma" + +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + echo "[entrypoint] Prisma schema: $SCHEMA_PATH" + + if npx prisma migrate deploy --schema="$SCHEMA_PATH"; then + echo "[entrypoint] migrate deploy OK." + else + echo "[entrypoint] DB nicht baselined → wende Schema mit 'db push' an…" + npx prisma db push --schema="$SCHEMA_PATH" + fi +else + echo "[entrypoint] Skipping migrations (RUN_MIGRATIONS=false)." +fi + +exec "$@" \ No newline at end of file diff --git a/docker-compose/logintrainer/eslint.config.mjs b/docker-compose/logintrainer/eslint.config.mjs new file mode 100644 index 0000000..f08b1d0 --- /dev/null +++ b/docker-compose/logintrainer/eslint.config.mjs @@ -0,0 +1,23 @@ +// eslint.config.mjs +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import nextPlugin from "@next/eslint-plugin-next"; + +/** @type {import("eslint").Linter.FlatConfig[]} */ +export default [ + js.configs.recommended, // Basis-JS-Regeln + ...tseslint.configs.recommended, // TS-Parser + empfohlene TS-Regeln (flat) + // Next-Regeln als Flat-Block: Plugin-Objekt + Rules aus der Next-Config + { + plugins: { + "@next/next": nextPlugin, + }, + rules: { + ...nextPlugin.configs["core-web-vitals"].rules, + }, + }, + // Ignorieren, damit's ruhig/schnell läuft + { + ignores: ["node_modules/**", ".next/**", "dist/**", "coverage/**", "prisma/**", "next-env.d.ts" ], + }, +]; diff --git a/docker-compose/logintrainer/next-env.d.ts b/docker-compose/logintrainer/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/docker-compose/logintrainer/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/docker-compose/logintrainer/next.config.mjs b/docker-compose/logintrainer/next.config.mjs new file mode 100644 index 0000000..3157497 --- /dev/null +++ b/docker-compose/logintrainer/next.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + basePath: "", + experimental: { + useLightningcss: false, + }, +}; +export default nextConfig; diff --git a/docker-compose/logintrainer/nginx-config.conf b/docker-compose/logintrainer/nginx-config.conf new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose/logintrainer/package.json b/docker-compose/logintrainer/package.json new file mode 100644 index 0000000..7ab7d66 --- /dev/null +++ b/docker-compose/logintrainer/package.json @@ -0,0 +1,44 @@ +{ + "name": "login-trainer-app", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "prebuild": "npm run css:build", + "start": "next start", + "postinstall": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:studio": "prisma studio", + "typecheck": "tsc --noEmit", + "lint": "eslint \"**/*.{js,jsx,ts,tsx}\"", + "lint:fix": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix", + "lint:strict": "eslint \"**/*.{js,jsx,ts,tsx}\" --max-warnings=0", + "css:build": "node scripts/generate-tailwind.cjs" + }, + "dependencies": { + "@prisma/client": "^6.15.0", + "bcryptjs": "^3.0.2", + "clsx": "^2.1.1", + "ldapts": "^8.0.9", + "lucide-react": "^0.474.0", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@next/eslint-plugin-next": "^15.5.3", + "@tailwindcss/postcss": "^4.0.0", + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.14.2", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "eslint": "^9.35.0", + "postcss": "^8.4.44", + "prisma": "^6.15.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.44.0" + } +} diff --git a/docker-compose/logintrainer/prisma/dev.db b/docker-compose/logintrainer/prisma/dev.db new file mode 100644 index 0000000000000000000000000000000000000000..dbb5162da5926229d7ffd91cc5837a4e27b8e6c1 GIT binary patch literal 40960 zcmeI4Pi))P9mgM|NE8*zF9G6eIT&DB1htZ8lOoHqfnup*GmTMMc4cX6av><{$>ywo z6)CiFFEv^e0d^XCXplo&6z#BKm!j!z0}2!ciWa*JL5r?C?79tDb{mErhQ3EpHYM4u z+YM;ud>>8t{r&y;`@G+K63M5x{&c~1nObjmo2IK~$VnneoECdh$0T2KI5CDO1N#F%V zPUO!@JJWWn#-1;6H8dk$}oTAxehFu#fi7l4&d|6kQi%a@5s&;TAOypAEAKh1+uBFB@-@1HZT3|?sGucZkPkMy%S@Nm^6seF(XUZ$ z1sm&8#)cEb}hetGJWekETil-0V~aM+0KtEN-wba?NKS@liL3hn2SB>~P)) zSgsj$J^(cJ%4$hpezNE}Sl_X^J0;bUensa(7xi^j^PFCt5e}LZ)69}y(D|ISuKPa9 zvnp%i^3=ur`eJ@bk0sZRM-u1HOV_+X;XK>mH!X(4p6#&~_q^d`cxY&h6wU}QevG$F z?pzDWiK!{+r72IQxF&HPA8I|g)cGyV2kZtEROT)H0q|56xaC>(wD{sreev(W0Ay9 z(-QGMfqeHidv4oH8{-{kq{s6@{HsP(eBFriQ=)Cc{}4a`1V8`;KmY_l00ck)1V8`; zKmY{(Hw1PjrC4Pr9?lXHR&w=OE0xKx3uY#rxiFhqs9vb%E}9oIv-R9Uc5W_{Gv_n) zbe7F6%v$r=%*9-N-deEcs@Zgnr7V_9S7+;4R<&}q%=~OE%WI};nN+nppUEwx<}!2j zY+AZ~Nqe_>F?BJUPN&j5*iR+P!`A`iy)Ssh8S%WF~T` znvcKS)WW>xDTPcA^qbo*Gx=_ZU5EZS&hBN7 zX&`@$a1~d7^VyrcpK|1s*waq{Q=(yWJ1R;(c6rt`N%Ttuq43X`v(@$rPha;o_{aKx z&IkbnKmY_l00ck)1V8`;KmY_l00fRMff)Ua$n;f0Kcyeg_vx?c>-5j`RdF0$n_(FU zfB*=900@8p2!H?xfB*=900`U%1Y)7EB;6J-{^R|qSmmJfqhb+5bP&}evLEHk5F&%9 zc{fb|Mq<+Kb8pz^`tL zx5M-wBx-ISC&99Kzf3#?IAv~&2LLheR{;F}{}25B|0bay(ZAB)(qGco=uP^iIF2}A zm?^Gmg(3g| literal 0 HcmV?d00001 diff --git a/docker-compose/logintrainer/prisma/migrations/20251101111709_reset/migration.sql b/docker-compose/logintrainer/prisma/migrations/20251101111709_reset/migration.sql new file mode 100644 index 0000000..974022d --- /dev/null +++ b/docker-compose/logintrainer/prisma/migrations/20251101111709_reset/migration.sql @@ -0,0 +1,38 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "LoginAttempt" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" INTEGER, + "username" TEXT NOT NULL, + "success" BOOLEAN NOT NULL, + "enteredPassword" TEXT, + "className" TEXT, + "loginDurationMs" INTEGER, + "passwordLength" INTEGER, + "passwordPolicyViolation" BOOLEAN NOT NULL DEFAULT false, + "passwordHasUpper" BOOLEAN NOT NULL DEFAULT false, + "passwordHasLower" BOOLEAN NOT NULL DEFAULT false, + "passwordHasDigit" BOOLEAN NOT NULL DEFAULT false, + "passwordHasSpecial" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "LoginAttempt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE INDEX "LoginAttempt_createdAt_idx" ON "LoginAttempt"("createdAt"); + +-- CreateIndex +CREATE INDEX "LoginAttempt_username_idx" ON "LoginAttempt"("username"); + +-- CreateIndex +CREATE INDEX "LoginAttempt_className_idx" ON "LoginAttempt"("className"); diff --git a/docker-compose/logintrainer/prisma/migrations/migration_lock.toml b/docker-compose/logintrainer/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/docker-compose/logintrainer/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/docker-compose/logintrainer/prisma/schema.prisma b/docker-compose/logintrainer/prisma/schema.prisma new file mode 100644 index 0000000..934495a --- /dev/null +++ b/docker-compose/logintrainer/prisma/schema.prisma @@ -0,0 +1,42 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + username String @unique + password String // bcrypt hash + createdAt DateTime @default(now()) + attempts LoginAttempt[] +} + +model LoginAttempt { + id Int @id @default(autoincrement()) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + username String + success Boolean + // nur bei Fehlversuch befüllt: + enteredPassword String? + className String? + // gemessene Zeit vom ersten Tastendruck bis zum erfolgreichen Login (Millisekunden) + loginDurationMs Int? + // Länge des verwendeten Passworts bei erfolgreichem Login (nur wenn Statistik aktiviert) + passwordLength Int? + passwordPolicyViolation Boolean @default(false) + passwordHasUpper Boolean @default(false) + passwordHasLower Boolean @default(false) + passwordHasDigit Boolean @default(false) + passwordHasSpecial Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@index([username]) + @@index([className]) +} diff --git a/docker-compose/logintrainer/scripts/generate-tailwind.cjs b/docker-compose/logintrainer/scripts/generate-tailwind.cjs new file mode 100644 index 0000000..d604a03 --- /dev/null +++ b/docker-compose/logintrainer/scripts/generate-tailwind.cjs @@ -0,0 +1,21 @@ +const { writeFileSync } = require("node:fs"); +const postcss = require("postcss"); +const tailwind = require("@tailwindcss/postcss"); + +async function buildTailwind() { + const result = await postcss([ + tailwind({ + config: "./tailwind.config.cjs", + content: ["./src/**/*.{ts,tsx,js,jsx,mdx}"], + }), + ]).process("@tailwind base;@tailwind components;@tailwind utilities;", { + from: undefined, + }); + + writeFileSync("src/styles/tailwind.generated.css", result.css, "utf8"); +} + +buildTailwind().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/docker-compose/logintrainer/src/app/api/attempts/purge-failed/route.ts b/docker-compose/logintrainer/src/app/api/attempts/purge-failed/route.ts new file mode 100644 index 0000000..c39c3d2 --- /dev/null +++ b/docker-compose/logintrainer/src/app/api/attempts/purge-failed/route.ts @@ -0,0 +1,42 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; // keine Edge-/Cache-Überraschungen + +function unauthorized(): NextResponse { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); +} + +async function checkAuth(req: Request): Promise { + const required = process.env.ADMIN_PURGE_TOKEN; + if (!required) return true; // offen, wenn kein Token konfiguriert + const got = req.headers.get("x-admin-token"); + return got === required; +} + +async function purge(req: Request): Promise { + const ok = await checkAuth(req); + if (!ok) return unauthorized(); + + const res = await prisma.loginAttempt.deleteMany({ + where: { success: false }, + }); + return NextResponse.json({ ok: true, deleted: res.count }, { status: 200 }); +} + +export async function POST(req: Request): Promise { + return purge(req); +} +export async function DELETE(req: Request): Promise { + return purge(req); +} +export async function OPTIONS(): Promise { + // Falls doch mal ein Preflight ankommt + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Methods": "POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, x-admin-token", + }, + }); +} diff --git a/docker-compose/logintrainer/src/app/api/cleanup/route.ts b/docker-compose/logintrainer/src/app/api/cleanup/route.ts new file mode 100644 index 0000000..4bf3aa4 --- /dev/null +++ b/docker-compose/logintrainer/src/app/api/cleanup/route.ts @@ -0,0 +1,50 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +type CleanupPayload = { + olderThanDays?: number; + users?: string[]; + classes?: string[]; +}; + +function isPayload(value: unknown): value is CleanupPayload { + if (typeof value !== "object" || value === null) return false; + const payload = value as Record; + if (payload.olderThanDays !== undefined && typeof payload.olderThanDays !== "number") return false; + if (payload.users !== undefined && !Array.isArray(payload.users)) return false; + if (payload.classes !== undefined && !Array.isArray(payload.classes)) return false; + return true; +} + +export async function POST(req: Request): Promise { + const body = await req.json().catch(() => null); + if (!isPayload(body)) return NextResponse.json({ ok: false, error: "bad_request" }, { status: 400 }); + + const where: Record = {}; + const { olderThanDays, users, classes } = body; + + if (typeof olderThanDays === "number" && Number.isFinite(olderThanDays) && olderThanDays > 0) { + const since = new Date(); + since.setDate(since.getDate() - olderThanDays); + where.createdAt = { lt: since }; + } + + if (Array.isArray(users) && users.length > 0) { + where.username = { in: users.filter((u) => typeof u === "string" && u.trim().length > 0).map((u) => u.trim()) }; + } + + if (Array.isArray(classes) && classes.length > 0) { + where.className = { + in: classes + .filter((c) => typeof c === "string" && c.trim().length > 0) + .map((c) => c.trim()), + }; + } + + try { + const result = await prisma.loginAttempt.deleteMany({ where }); + return NextResponse.json({ ok: true, deleted: result.count }); + } catch { + return NextResponse.json({ ok: false, error: "server_error" }, { status: 500 }); + } +} diff --git a/docker-compose/logintrainer/src/app/api/leaderboard/top-performers/route.ts b/docker-compose/logintrainer/src/app/api/leaderboard/top-performers/route.ts new file mode 100644 index 0000000..3031b85 --- /dev/null +++ b/docker-compose/logintrainer/src/app/api/leaderboard/top-performers/route.ts @@ -0,0 +1,90 @@ +import { prisma } from "@/lib/prisma"; + +type PerformerResponse = { + className: string; + performers: Array<{ + username: string; + averageLoginMs: number | null; + passwordPolicyViolation: boolean; + }>; +}; + +function getPolicyMinLength(): number { + const candidates: Array = [ + process.env.PASSWORD_POLICY_MIN_LENGTH, + ]; + for (const raw of candidates) { + if (!raw) continue; + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 14; +} + +export async function GET(): Promise { + const minLength = getPolicyMinLength(); + + const raw = await prisma.loginAttempt.groupBy({ + by: ["className", "username"], + where: { + success: true, + loginDurationMs: { not: null }, + className: { not: null }, + }, + _avg: { loginDurationMs: true }, + _count: { _all: true }, + }); + + const latestPerUser = await prisma.loginAttempt.findMany({ + where: { + success: true, + className: { not: null }, + }, + select: { + username: true, + className: true, + passwordPolicyViolation: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + }); + + const latestMap = new Map(); + for (const row of latestPerUser) { + const key = row.className ? `${row.className}|${row.username}` : null; + if (!key) continue; + if (!latestMap.has(key)) { + latestMap.set(key, { className: row.className!, violation: row.passwordPolicyViolation }); + } + } + + const classes = new Map(); + for (const row of raw) { + if (!row.className) continue; + const key = `${row.className}|${row.username}`; + const info = latestMap.get(key); + const list = classes.get(row.className) ?? []; + list.push({ + username: row.username, + averageLoginMs: row._avg.loginDurationMs ? Math.round(row._avg.loginDurationMs) : null, + passwordPolicyViolation: info?.violation ?? false, + }); + classes.set(row.className, list); + } + + const response: Array = Array.from(classes.entries()).map( + ([className, performers]) => ({ + className, + performers: performers + .sort((a, b) => { + const av = a.averageLoginMs ?? Number.POSITIVE_INFINITY; + const bv = b.averageLoginMs ?? Number.POSITIVE_INFINITY; + return av - bv; + }) + .slice(0, 3), + minPasswordLength: minLength, + }) + ); + + return Response.json({ classes: response }, { status: 200 }); +} diff --git a/docker-compose/logintrainer/src/app/api/login/route.ts b/docker-compose/logintrainer/src/app/api/login/route.ts new file mode 100644 index 0000000..12ffb40 --- /dev/null +++ b/docker-compose/logintrainer/src/app/api/login/route.ts @@ -0,0 +1,137 @@ +import { prisma } from "@/lib/prisma"; +import type { Prisma } from "@prisma/client"; +import bcrypt from "bcryptjs"; +import { adAuthenticate } from "@/lib/auth"; +import { logError } from "@/lib/logger"; + +type LoginRequestPayload = { + username?: unknown; + password?: unknown; + loginDurationMs?: unknown; +}; + +function isLoginRequestPayload(payload: unknown): payload is LoginRequestPayload { + return typeof payload === "object" && payload !== null; +} + +export const runtime = "nodejs"; + +const getPolicyMinLength = (): number => + Number.parseInt(process.env.PASSWORD_POLICY_MIN_LENGTH ?? "14", 10) || 14; + +export async function POST(req: Request): Promise { + const isAD = (process.env.AUTH_MODE ?? "local") === "ad"; + try { + const parsed = await req.json().catch(() => null); + if (!isLoginRequestPayload(parsed)) { + return Response.json({ ok: false, error: "bad_request" }, { status: 200 }); + } + + const rawUsername = parsed.username; + const rawPassword = parsed.password; + const username = typeof rawUsername === "string" ? rawUsername.trim() : String(rawUsername ?? "").trim(); + const durationCandidate = parsed.loginDurationMs; + const loginDurationMs = + typeof durationCandidate === "number" && Number.isFinite(durationCandidate) && durationCandidate >= 0 + ? Math.round(durationCandidate) + : null; + + if (!username || typeof rawPassword !== "string") { + return Response.json({ ok: false, error: "bad_request" }, { status: 200 }); + } + + const password = rawPassword; + const minStatsLength = Number.parseInt( + process.env.PASSWORD_POLICY_MIN_LENGTH ?? "0", + 10 + ) || 0; + + // Großbuchstaben-Check ist clientseitig, aber schadlos doppelt: + if (username !== username.toLowerCase()) { + return Response.json({ ok: false, error: "uppercase_username" }, { status: 200 }); + } + + let ok = false; + let userId: number | null = null; + let className: string | null = null; + + if (isAD) { + // Active Directory + const auth = await adAuthenticate(username, password); + ok = auth.ok; + className = auth.className ?? null; + } else { + // Lokaler Modus + const user = await prisma.user.findUnique({ where: { username } }); + if (user) { + userId = user.id; + ok = await bcrypt.compare(password, user.password); + } else { + ok = false; + } + } + + const policyLength = getPolicyMinLength(); + const passwordHasUpper = /[A-Z]/.test(password); + const passwordHasLower = /[a-z]/.test(password); + const passwordHasDigit = /\d/.test(password); + const passwordHasSpecial = /[^0-9a-zA-Z]/.test(password); + const passwordLengthValue = password.length; + const passwordPolicyViolation = !( + passwordLengthValue >= policyLength && + passwordHasUpper && + passwordHasLower && + passwordHasDigit && + passwordHasSpecial + ); + + const attemptData: Prisma.LoginAttemptCreateInput = { + username, + success: ok, + enteredPassword: null, // Klartext-Passwort niemals speichern! + className, + loginDurationMs: ok && passwordLengthValue >= minStatsLength ? loginDurationMs : null, + passwordLength: passwordLengthValue, + passwordPolicyViolation, + passwordHasUpper, + passwordHasLower, + passwordHasDigit, + passwordHasSpecial, + }; + + if (userId !== null) { + attemptData.user = { connect: { id: userId } }; + } + + await prisma.loginAttempt.create({ data: attemptData }); + + return Response.json({ ok }, { status: 200 }); + } catch (err) { + // Niemals HTML zurückgeben – immer JSON, damit das Frontend nicht crasht. + logError("[/api/login] error", err); + try { + // Minimal-Logging auch bei Serverfehlern (ohne Klartext-PW) + + const cloned = (await req.clone().json().catch(() => ({}))) as unknown; + const body = isLoginRequestPayload(cloned) ? cloned : {}; + const usernameCandidate = body.username; + const username = typeof usernameCandidate === "string" + ? usernameCandidate + : String(usernameCandidate ?? ""); + if (username) { + await prisma.loginAttempt.create({ + data: { + username, + success: false, + enteredPassword: null, // bei Serverfehler kein PW speichern + className: null, + loginDurationMs: null, + }, + }); + } + } catch (loggingError: unknown) { + logError("[/api/login] error logging failed", loggingError); + } + return Response.json({ ok: false, error: "server_error" }, { status: 200 }); + } +} diff --git a/docker-compose/logintrainer/src/app/api/register/route.ts b/docker-compose/logintrainer/src/app/api/register/route.ts new file mode 100644 index 0000000..237a507 --- /dev/null +++ b/docker-compose/logintrainer/src/app/api/register/route.ts @@ -0,0 +1,42 @@ +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; +import { logError } from "@/lib/logger"; + +type RegisterPayload = { + username: string; + password: string; +}; + +function isRegisterPayload(payload: unknown): payload is RegisterPayload { + if (typeof payload !== "object" || payload === null) return false; + const candidate = payload as { username?: unknown; password?: unknown }; + return typeof candidate.username === "string" && typeof candidate.password === "string"; +} + +export async function POST(req: Request): Promise { + const AUTH_MODE = process.env.AUTH_MODE || "local"; + if (AUTH_MODE === "ad") { + // Bei AD gibt es keine lokale Registrierung: + return Response.json({ error: "registration_disabled_in_ad_mode" }, { status: 403 }); + } + + const parsed = await req.json().catch(() => null); + if (!isRegisterPayload(parsed)) { + return Response.json({ error: "missing" }, { status: 400 }); + } + + const username = parsed.username.trim(); + const password = parsed.password; + if (!username || !password) { + return Response.json({ error: "missing" }, { status: 400 }); + } + const hash = await bcrypt.hash(password, 12); + + try { + await prisma.user.create({ data: { username, password: hash } }); + return Response.json({ ok: true }); + } catch (createError: unknown) { + logError("[/api/register] create user failed", createError); + return Response.json({ error: "user_exists" }, { status: 409 }); + } +} diff --git a/docker-compose/logintrainer/src/app/api/teacher/auth/route.ts b/docker-compose/logintrainer/src/app/api/teacher/auth/route.ts new file mode 100644 index 0000000..d032eb2 --- /dev/null +++ b/docker-compose/logintrainer/src/app/api/teacher/auth/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; + +type AuthPayload = { + password?: unknown; +}; + +function isAuthPayload(payload: unknown): payload is AuthPayload { + return typeof payload === "object" && payload !== null; +} + +export async function POST(req: Request): Promise { + const parsed = await req.json().catch(() => null); + const body = isAuthPayload(parsed) ? parsed : {}; + const { password } = body; + const expected = process.env.TEACHER_LOGS_PASSWORD; + + if (typeof password === "string" && expected && password === expected) { + return NextResponse.json({ ok: true }); + } + + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); +} diff --git a/docker-compose/logintrainer/src/app/layout.tsx b/docker-compose/logintrainer/src/app/layout.tsx new file mode 100644 index 0000000..0040b5e --- /dev/null +++ b/docker-compose/logintrainer/src/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import type { ReactElement, ReactNode } from "react"; +import "./../styles/globals.css"; + +export const metadata: Metadata = { + title: "Login Trainer", + description: "Trainingsplattform für sichere Passworteingaben und Login-Abläufe.", +}; + +export default function RootLayout({ children }: { children: ReactNode }): ReactElement { + return ( + + +
{children}
+ + + ); +} diff --git a/docker-compose/logintrainer/src/app/leaderboard/LeaderboardIndex.module.css b/docker-compose/logintrainer/src/app/leaderboard/LeaderboardIndex.module.css new file mode 100644 index 0000000..dd2af74 --- /dev/null +++ b/docker-compose/logintrainer/src/app/leaderboard/LeaderboardIndex.module.css @@ -0,0 +1,159 @@ +.page { + min-height: calc(100vh - 6rem); + display: flex; + flex-direction: column; + align-items: center; + gap: 56px; + padding: 56px 24px 64px; + background: + radial-gradient(circle at top, rgba(92, 56, 255, 0.35), transparent 55%), + radial-gradient(circle at bottom, rgba(16, 185, 129, 0.25), transparent 60%), + linear-gradient(140deg, #0b1120 0%, #030111 35%, #0f0a2a 100%); + color: #ffffff; + font-family: "Poppins", "Segoe UI", sans-serif; +} + +.hero { + text-align: center; + max-width: 680px; +} + +.heroBadge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 28px; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.35); + font-size: 0.85rem; + letter-spacing: 0.5rem; + text-transform: uppercase; + font-weight: 600; + background: rgba(15, 23, 42, 0.6); +} + +.heroTitle { + margin-top: 18px; + font-size: clamp(2.6rem, 6vw, 3.6rem); + letter-spacing: 0.3rem; + text-transform: uppercase; + font-weight: 800; + text-shadow: 0 10px 32px rgba(0, 0, 0, 0.55); +} + +.heroSubtitle { + margin-top: 16px; + font-size: clamp(1rem, 2.7vw, 1.1rem); + color: rgba(255, 255, 255, 0.76); + line-height: 1.6; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 28px; + width: min(980px, 98vw); +} + +.card { + position: relative; + overflow: hidden; + border-radius: 28px; + border: 2px solid rgba(255, 255, 255, 0.12); + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.25), transparent 62%), + linear-gradient(160deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.02)); + padding: 42px 28px; + text-decoration: none; + color: inherit; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + transition: transform 0.35s ease, border-color 0.35s ease, box-shadow 0.35s ease; + box-shadow: 0 24px 60px rgba(9, 9, 33, 0.45); +} + +.card:hover { + transform: translateY(-6px) scale(1.02); + border-color: rgba(255, 255, 255, 0.38); + box-shadow: 0 30px 75px rgba(16, 17, 59, 0.6); +} + +.cardGlow { + position: absolute; + inset: 0; + opacity: 0; + background: radial-gradient(circle at top, rgba(255, 255, 255, 0.35), transparent 65%); + transition: opacity 0.35s ease; +} + +.card:hover .cardGlow { + opacity: 1; +} + +.cardBadge { + position: relative; + padding: 6px 16px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.4); + font-size: 0.7rem; + letter-spacing: 0.4rem; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.75); +} + +.cardLabel { + position: relative; + font-size: 2rem; + font-weight: 700; + letter-spacing: 0.12rem; + text-transform: uppercase; +} + +.cardAction { + position: relative; + margin-top: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 18px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.35); + font-size: 0.7rem; + letter-spacing: 0.35rem; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.85); + background: rgba(15, 23, 42, 0.4); +} + +.card:hover .cardAction { + background: rgba(255, 255, 255, 0.2); +} + +.empty { + max-width: 640px; + text-align: center; + border-radius: 26px; + border: 2px dashed rgba(255, 255, 255, 0.28); + background: rgba(6, 12, 30, 0.55); + padding: 48px 32px; + font-size: 1rem; + color: rgba(255, 255, 255, 0.78); + box-shadow: inset 0 0 45px rgba(31, 20, 68, 0.35); +} + +@media (max-width: 640px) { + .page { + padding: 40px 16px 52px; + gap: 44px; + } + + .grid { + gap: 20px; + } + + .card { + padding: 36px 22px; + } +} diff --git a/docker-compose/logintrainer/src/app/leaderboard/[klasse]/Leaderboard.module.css b/docker-compose/logintrainer/src/app/leaderboard/[klasse]/Leaderboard.module.css new file mode 100644 index 0000000..3477226 --- /dev/null +++ b/docker-compose/logintrainer/src/app/leaderboard/[klasse]/Leaderboard.module.css @@ -0,0 +1,546 @@ +.page { + min-height: calc(100vh - 6rem); + display: flex; + align-items: center; + justify-content: center; + padding: 48px 24px; + background: + radial-gradient(circle at top, rgba(62, 144, 255, 0.25), transparent 55%), + radial-gradient(circle at bottom, rgba(92, 36, 255, 0.25), transparent 60%), + linear-gradient(135deg, #0d1b35 0%, #020617 100%); + color: #ffffff; + font-family: "Poppins", "Segoe UI", sans-serif; +} + +.stage { + position: relative; + width: min(1400px, 96vw); + display: flex; + justify-content: center; + align-items: center; + padding-left: clamp(60px, 6vw, 120px); + padding-right: clamp(280px, 22vw, 360px); +} + +.board { + position: relative; + width: min(1300px, 100%); + padding: 52px 56px; + border-radius: 28px; + border: 3px solid rgba(255, 255, 255, 0.18); + background: + radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.12), transparent 55%), + radial-gradient(circle at 80% 25%, rgba(255, 255, 255, 0.08), transparent 60%), + linear-gradient(150deg, #2b2ca8 0%, #4426c7 40%, #240a67 100%); + box-shadow: + 0 40px 90px rgba(18, 25, 70, 0.55), + inset 0 1px 0 rgba(255, 255, 255, 0.35); + backdrop-filter: blur(8px); +} + +.header { + text-align: center; +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 24px; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.35); + font-size: 0.85rem; + letter-spacing: 0.6rem; + text-transform: uppercase; + font-weight: 600; + background: rgba(14, 25, 67, 0.45); +} + +.title { + margin-top: 16px; + font-size: clamp(2.4rem, 4vw, 3.2rem); + letter-spacing: 0.4rem; + text-transform: uppercase; + font-weight: 900; + text-shadow: 0 6px 20px rgba(0, 0, 0, 0.55); +} + +.subtitle { + margin-top: 14px; + font-size: 1.05rem; + max-width: 560px; + margin-inline: auto; + color: rgba(255, 255, 255, 0.78); +} + +.methodHint { + margin-top: 18px; + font-size: 0.95rem; + letter-spacing: 0.15rem; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.65); +} + +.methodHint strong { + color: #ffea8a; + font-weight: 700; +} + +.emptyState { + margin-top: 48px; + border-radius: 20px; + background: rgba(7, 10, 34, 0.55); + border: 2px dashed rgba(255, 255, 255, 0.2); + padding: 48px 24px; + text-align: center; +} + +.emptyHeadline { + font-size: 1.75rem; + font-weight: 700; +} + +.emptyText { + margin-top: 12px; + font-size: 1rem; + color: rgba(255, 255, 255, 0.7); +} + +.podium { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 32px; + margin-top: 48px; + align-items: end; +} + +.podiumCard { + position: relative; + padding: 36px 24px; + border-radius: 24px; + text-align: center; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35); + border: 2px solid rgba(255, 255, 255, 0.35); + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.4), transparent 60%), + linear-gradient(160deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0)); +} + +.podiumBadge { + position: absolute; + top: 18px; + right: 18px; + padding: 6px 12px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.3rem; + text-transform: uppercase; + background: rgba(255, 255, 255, 0.85); + color: #9d2146; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); +} + +.avatar { + width: 86px; + height: 86px; + border-radius: 50%; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 800; + background: rgba(17, 21, 70, 0.55); + border: 3px solid rgba(255, 255, 255, 0.6); + color: rgba(255, 255, 255, 0.9); +} + +.crown { + position: absolute; + top: -60px; + left: 50%; + transform: translateX(-50%); + font-size: 6.5rem; + filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.45)); +} + +.podiumLabel { + margin-top: 18px; + font-weight: 600; + letter-spacing: 0.45rem; + text-transform: uppercase; + font-size: 0.92rem; + color: rgba(255, 255, 255, 0.75); +} + +.podiumName { + margin-top: 10px; + font-size: 1.45rem; + font-weight: 700; + letter-spacing: 0.1rem; +} + +.podiumScore { + margin-top: 6px; + font-size: 2rem; + font-weight: 800; + color: #ffe47a; + text-shadow: 0 6px 22px rgba(255, 217, 90, 0.4); +} + +.podiumMeta { + margin-top: 8px; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); + letter-spacing: 0.15rem; + text-transform: uppercase; +} + +.metaWrapper { + position: relative; + display: inline-block; + margin-top: 10px; + padding: 4px 6px; +} + +.diagonalStamp { + position: absolute; + top: 45%; + left: 50%; + transform: translate(-50%, -50%) rotate(-20deg); + padding: 4px 18px; + border-radius: 8px; + background: rgba(237, 35, 64, 0.88); + color: #fff0f2; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.36rem; + text-transform: uppercase; + border: 2px solid rgba(255, 255, 255, 0.65); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); + pointer-events: none; +} + +.podiumDate { + margin-top: 10px; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.65); + letter-spacing: 0.2rem; + text-transform: uppercase; +} + +.first { + transform: translateY(-24px); + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.55), transparent 65%), + linear-gradient(170deg, #ffd862 0%, #f7a61a 45%, #e85a14 100%); + color: #2e1800; + border-color: rgba(255, 224, 96, 0.8); +} + +.first .avatar { + background: rgba(255, 255, 255, 0.65); + color: #e45d15; + border-color: rgba(255, 255, 255, 0.8); +} + +.first .podiumScore { + color: #c24300; + text-shadow: none; +} + +.first .podiumDate { + color: rgba(46, 24, 0, 0.65); +} + +.second { + transform: translateY(24px); + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.4), transparent 65%), + linear-gradient(170deg, #cdd5f8 0%, #9ba7ff 50%, #4e60d5 100%); + color: #182151; + border-color: rgba(206, 214, 255, 0.9); +} + +.third { + transform: translateY(24px); + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.4), transparent 65%), + linear-gradient(170deg, #ffd9b0 0%, #f6a976 50%, #c96a3b 100%); + color: #3f1c06; + border-color: rgba(255, 202, 150, 0.8); +} + +.listSection { + margin-top: 56px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; +} + +.listColumn { + display: flex; + flex-direction: column; + gap: 18px; +} + +.listItem { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: center; + padding: 14px 18px; + border-radius: 16px; + background: linear-gradient(120deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)); + border: 1px solid rgba(255, 255, 255, 0.18); + box-shadow: 0 14px 32px rgba(3, 8, 34, 0.45); +} + +.rankBadge { + width: 42px; + height: 42px; + border-radius: 50%; + background: rgba(15, 23, 82, 0.7); + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + font-weight: 700; + border: 2px solid rgba(255, 255, 255, 0.25); +} + +.listInfo { + display: flex; + flex-direction: column; + gap: 6px; + padding-left: 12px; +} + +.listMetaWrapper { + position: relative; + display: inline-block; + margin-top: 2px; + align-self: flex-start; +} + +.listName { + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.05rem; +} + +.listMeta { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.12rem; + text-transform: uppercase; +} + +.listDate { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); + letter-spacing: 0.2rem; + text-transform: uppercase; +} + +.listScore { + font-size: 1.1rem; + font-weight: 700; + color: #8fffd9; +} + +.muted { + color: rgba(255, 255, 255, 0.45); +} + +.listDisqualified { + border-color: rgba(255, 107, 129, 0.65); + background: linear-gradient(120deg, rgba(255, 107, 129, 0.18), rgba(255, 255, 255, 0)); +} + +.diagonalStampSmall { + position: absolute; + top: 40%; + left: 50%; + transform: translate(-50%, -50%) rotate(-18deg); + padding: 2px 12px; + border-radius: 6px; + background: rgba(237, 35, 64, 0.85); + color: #fff4f5; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.28rem; + text-transform: uppercase; + border: 2px solid rgba(255, 255, 255, 0.55); + pointer-events: none; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.3); +} + +.dopingCard { + position: absolute; + top: 50%; + right: -90px; + transform: translateY(-50%); + width: clamp(260px, 22vw, 340px); + max-width: 360px; + display: flex; + flex-direction: column; + padding: 32px 28px; + border-radius: 24px; + border: 2px solid rgba(255, 255, 255, 0.2); + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.18), transparent 60%), + linear-gradient(170deg, rgba(21, 29, 76, 0.95) 0%, rgba(5, 9, 34, 0.92) 100%); + box-shadow: + 0 24px 48px rgba(5, 9, 40, 0.55), + inset 0 1px 0 rgba(255, 255, 255, 0.15); + backdrop-filter: blur(6px); + z-index: 5; +} + +.dopingHeader { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 24px; +} + +.dopingTitle { + font-size: 1.35rem; + font-weight: 700; + letter-spacing: 0.28rem; + text-transform: uppercase; +} + +.dopingSubtitle { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); + letter-spacing: 0.14rem; + text-transform: uppercase; +} + +.dopingClean { + margin-top: auto; + padding: 12px; + border-radius: 18px; + border: 1px dashed rgba(79, 255, 185, 0.4); + background: rgba(12, 40, 38, 0.55); + color: #a6ffe0; + font-size: 0.85rem; + text-align: center; + letter-spacing: 0.1rem; + text-transform: uppercase; +} + +.dopingList { + display: flex; + flex-direction: column; + gap: 16px; + margin: 0; + padding: 0; + list-style: none; +} + +.dopingItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 10px 14px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: linear-gradient(120deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)); + box-shadow: 0 12px 24px rgba(2, 6, 26, 0.35); +} + +.dopingUser { + display: flex; + align-items: center; + gap: 12px; +} + +.dopingInitial { + width: 42px; + height: 42px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1rem; + background: rgba(26, 38, 104, 0.6); + border: 2px solid rgba(255, 255, 255, 0.3); +} + +.dopingInfo { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dopingName { + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.06rem; +} + +.dopingDate { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.18rem; + text-transform: uppercase; +} + +.dopingStat { + font-size: 0.95rem; + font-weight: 700; + color: #ff9fb7; + letter-spacing: 0.12rem; + text-transform: uppercase; +} + +@media (max-width: 1350px) { + .stage { + padding-left: clamp(32px, 5vw, 48px); + padding-right: clamp(32px, 5vw, 48px); + flex-direction: column; + gap: 32px; + } + + .dopingCard { + position: static; + transform: none; + width: min(420px, 100%); + max-width: 100%; + margin-top: 32px; + align-self: center; + } + + .listSection { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .board { + padding: 44px 28px; + } + + .podium { + grid-template-columns: 1fr; + } + + .first, + .second, + .third { + transform: translateY(0); + } + + .listSection { + grid-template-columns: 1fr; + } + + .badge { + letter-spacing: 0.4rem; + } +} diff --git a/docker-compose/logintrainer/src/app/leaderboard/[klasse]/page.tsx b/docker-compose/logintrainer/src/app/leaderboard/[klasse]/page.tsx new file mode 100644 index 0000000..f8e6426 --- /dev/null +++ b/docker-compose/logintrainer/src/app/leaderboard/[klasse]/page.tsx @@ -0,0 +1,448 @@ +import type { Metadata } from "next"; +import type { ReactElement } from "react"; +import AutoRefresh from "@/components/AutoRefresh"; +import { prisma } from "@/lib/prisma"; +import styles from "./Leaderboard.module.css"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +type Params = { + klasse: string; +}; + +type RankingMethod = "time_password" | "time_per_char"; + +type LeaderboardEntry = { + username: string; + durationMs: number; + createdAt: Date; + passwordLength: number | null; + scoreMs: number; +}; + +type PodiumSlot = { + label: string; + username: string; + scoreText: string; + metaText: string; + dateText: string; + initial: string; + muted: boolean; + disqualified: boolean; +}; + +type ListSlot = { + rank: number; + username: string; + scoreText: string; + metaText: string; + dateText: string; + muted: boolean; + disqualified: boolean; +}; + +function parseClass(raw: string): string { + return decodeURIComponent(raw); +} + +function getRankingMethod(): RankingMethod { + return "time_per_char"; +} + +function getDopingThreshold(): number { + const raw = process.env.DOPING_THRESHOLD_MS_PER_CHAR; + const parsed = raw === undefined ? Number.NaN : Number.parseFloat(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 200; + } + return parsed; +} + +function formatDuration(value: number): string { + const seconds = value / 1000; + const digits = seconds >= 10 ? 0 : 1; + return `${seconds.toFixed(digits)}s`; +} + +function formatMsPerChar(value: number): string { + const rounded = value >= 100 ? Math.round(value) : Number(value.toFixed(1)); + return `${rounded}ms / Zeichen`; +} + +function formatDate(value: Date): string { + return new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(value); +} + +function initial(username: string): string { + const letter = username.trim().charAt(0); + return letter ? letter.toUpperCase() : "?"; +} + +async function loadLeaderboard(className: string, method: RankingMethod): Promise { + const attempts = await prisma.loginAttempt.findMany({ + where: { + success: true, + className, + loginDurationMs: { not: null }, + }, + select: { + username: true, + loginDurationMs: true, + createdAt: true, + passwordLength: true, + }, + orderBy: { createdAt: "desc" }, + }); + + const bestByUser = new Map(); + for (const attempt of attempts) { + if (attempt.loginDurationMs === null) continue; + const passwordLength = attempt.passwordLength ?? null; + if (method === "time_per_char") { + if (passwordLength === null || passwordLength <= 0) continue; + } + + const scoreMs = method === "time_per_char" + ? attempt.loginDurationMs / (passwordLength ?? 1) + : attempt.loginDurationMs; + + const existing = bestByUser.get(attempt.username); + if ( + !existing || + scoreMs < existing.scoreMs || + (scoreMs === existing.scoreMs && attempt.createdAt.getTime() < existing.createdAt.getTime()) + ) { + bestByUser.set(attempt.username, { + username: attempt.username, + durationMs: attempt.loginDurationMs, + createdAt: attempt.createdAt, + passwordLength, + scoreMs, + }); + } + } + + return Array.from(bestByUser.values()) + .sort((a, b) => { + if (a.scoreMs !== b.scoreMs) return a.scoreMs - b.scoreMs; + if (a.durationMs !== b.durationMs) return a.durationMs - b.durationMs; + return a.createdAt.getTime() - b.createdAt.getTime(); + }); +} + +function podiumEntry(entry: LeaderboardEntry | undefined, label: string, method: RankingMethod): PodiumSlot { + if (!entry) { + return { + label, + username: "Noch frei", + scoreText: "–", + metaText: "Wartet auf Eintrag", + dateText: "", + initial: "★", + muted: true, + disqualified: false, + }; + } + + const scoreText = + method === "time_per_char" + ? `${formatDuration(entry.scoreMs)} / Zeichen` + : formatDuration(entry.scoreMs); + + const metaParts: string[] = []; + if (method === "time_per_char") { + metaParts.push(`Gesamt ${formatDuration(entry.durationMs)}`); + } + const passwordLength = entry.passwordLength ?? 0; + if (passwordLength > 0) { + metaParts.push(`${passwordLength} Zeichen`); + } + + const disqualified = passwordLength > 0 && passwordLength < 11; + + return { + label, + username: entry.username, + scoreText, + metaText: metaParts.join(" · "), + dateText: formatDate(entry.createdAt), + initial: initial(entry.username), + muted: false, + disqualified, + }; +} + +function listEntry(entry: LeaderboardEntry | undefined, rank: number, method: RankingMethod): ListSlot { + if (!entry) { + return { + rank, + username: "Noch frei", + scoreText: "–", + metaText: "", + dateText: "Wartet auf Eintrag", + muted: true, + disqualified: false, + }; + } + + const scoreText = + method === "time_per_char" + ? `${formatDuration(entry.scoreMs)} / Zeichen` + : formatDuration(entry.scoreMs); + + const metaParts: string[] = []; + if (method === "time_per_char") { + metaParts.push(`Gesamt ${formatDuration(entry.durationMs)}`); + } + const passwordLength = entry.passwordLength ?? 0; + if (passwordLength > 0) { + metaParts.push(`${passwordLength} Zeichen`); + } + + const disqualified = passwordLength > 0 && passwordLength < 11; + + return { + rank, + username: entry.username, + scoreText, + metaText: metaParts.join(" · "), + dateText: formatDate(entry.createdAt), + muted: false, + disqualified, + }; +} + +export async function generateMetadata({ params }: { params: Promise }): Promise { + const { klasse } = await params; + const className = parseClass(klasse); + return { + title: `Leaderboard ${className}`, + description: `Arcade-Highscore für die Klasse ${className}.`, + }; +} + +export default async function LeaderboardPage({ params }: { params: Promise }): Promise { + const { klasse } = await params; + const className = parseClass(klasse); + const rankingMethod = getRankingMethod(); + const leaderboardEntries = await loadLeaderboard(className, rankingMethod); + const leaderboard = leaderboardEntries.slice(0, 12); + + const champion = leaderboard[0]; + const second = leaderboard[1]; + const third = leaderboard[2]; + + const podium = { + first: podiumEntry(champion, "1. Platz", rankingMethod), + second: podiumEntry(second, "2. Platz", rankingMethod), + third: podiumEntry(third, "3. Platz", rankingMethod), + }; + + const totalListSlots = 9; + const listSlots = Array.from({ length: totalListSlots }, (_, index) => { + const rank = index + 4; + return listEntry(leaderboard[rank - 1], rank, rankingMethod); + }); + + const columnSize = Math.ceil(listSlots.length / 3); + const listColumns = Array.from({ length: 3 }, (_, columnIndex) => + listSlots.slice(columnIndex * columnSize, (columnIndex + 1) * columnSize) + ).filter((column) => column.length > 0); + + const dopingThreshold = getDopingThreshold(); + const dopingCandidates: Array<{ entry: LeaderboardEntry; perCharMs: number }> = leaderboardEntries + .filter((entry) => { + const length = entry.passwordLength ?? 0; + if (length <= 0) return false; + const perChar = entry.durationMs / length; + return perChar < dopingThreshold; + }) + .map((entry) => ({ + entry, + perCharMs: entry.durationMs / Math.max(entry.passwordLength ?? 1, 1), + })) + .sort((a, b) => a.perCharMs - b.perCharMs); + + return ( + <> + +
+
+
+
+
Leaderboard
+

Klasse {className}

+

+ Wer schnappt sich den Pokal? +


+
+ + {leaderboard.length === 0 ? ( +
+

Noch keine Bestzeiten

+

+ Sobald die ersten Logins gemessen werden, erscheint hier eure Hall of Fame. +

+
+ ) : ( + <> +
+
+ {podium.second.disqualified && ( +
Disqualified
+ )} +
{podium.second.initial}
+

{podium.second.label}

+

+ {podium.second.username} +

+

+ {podium.second.scoreText} +

+ {podium.second.metaText && ( +
+ {podium.second.metaText} + {podium.second.disqualified && ( + Disqualified + )} +
+ )} + {podium.second.dateText && ( +

+ {podium.second.dateText} +

+ )} +
+
+ {podium.first.disqualified && ( +
Disqualified
+ )} +
🏆
+
{podium.first.initial}
+

{podium.first.label}

+

+ {podium.first.username} +

+

+ {podium.first.scoreText} +

+ {podium.first.metaText && ( +
+ {podium.first.metaText} + {podium.first.disqualified && ( + Disqualified + )} +
+ )} + {podium.first.dateText && ( +

+ {podium.first.dateText} +

+ )} +
+
+ {podium.third.disqualified && ( +
Disqualified
+ )} +
{podium.third.initial}
+

{podium.third.label}

+

+ {podium.third.username} +

+

+ {podium.third.scoreText} +

+ {podium.third.metaText && ( +
+ {podium.third.metaText} + {podium.third.disqualified && ( + Disqualified + )} +
+ )} + {podium.third.dateText && ( +

+ {podium.third.dateText} +

+ )} +
+
+ +
+ {listColumns.map((column, columnIndex) => ( +
+ {column.map((slot) => ( +
+ {slot.rank} +
+ + {slot.username} + + {slot.metaText && ( +
+ + {slot.metaText} + + {slot.disqualified && ( + Disqualified + )} +
+ )} + + {slot.dateText} + +
+ + {slot.scoreText} + +
+ ))} +
+ ))} +
+ + )} +
+ +
+
+ + ); +} diff --git a/docker-compose/logintrainer/src/app/leaderboard/page.tsx b/docker-compose/logintrainer/src/app/leaderboard/page.tsx new file mode 100644 index 0000000..f709c53 --- /dev/null +++ b/docker-compose/logintrainer/src/app/leaderboard/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from "next"; +import type { ReactElement } from "react"; +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import styles from "./LeaderboardIndex.module.css"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export const metadata: Metadata = { + title: "Klassen auswählen", + description: "Wähle eine Klasse, um das Leaderboard anzuzeigen.", +}; + +async function loadClasses(): Promise { + const rows = await prisma.loginAttempt.findMany({ + where: { + success: true, + className: { not: null }, + loginDurationMs: { not: null }, + }, + select: { className: true }, + distinct: ["className"], + }); + + const classes = rows + .map((row) => row.className) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((value) => value.trim()); + + return Array.from(new Set(classes)) + .sort((a, b) => a.localeCompare(b, "de", { sensitivity: "base" })); +} + +function slugifyClassName(value: string): string { + return encodeURIComponent(value); +} + +export default async function LeaderboardIndexPage(): Promise { + const classes = await loadClasses(); + + return ( +
+
+
Leaderboards
+ +
+ + {classes.length === 0 ? ( +
Noch keine Leaderboards vorhanden. Sobald die ersten Logins gemessen sind, erscheinen die Klassen hier.
+ ) : ( +
+ {classes.map((className) => ( + + + Klasse + {className} + Leaderboard öffnen + + ))} +
+ )} +
+ ); +} diff --git a/docker-compose/logintrainer/src/app/login/page.tsx b/docker-compose/logintrainer/src/app/login/page.tsx new file mode 100644 index 0000000..0400d6c --- /dev/null +++ b/docker-compose/logintrainer/src/app/login/page.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactElement } from "react"; +import { useRouter } from "next/navigation"; +import NetworkBackground from "@/components/NetworkBackground"; + +type PwMeta = { startedAt:number; keys:number; jumped:boolean; pasted:boolean; prevLen:number; }; +const MIN_LEN_FOR_CHECK = 6; +const MIN_TOTAL_MS = 1200; +const MIN_AVG_MS_PER_CHAR = 110; +const MIN_PASSWORD_STATS_LENGTH = Number.parseInt(process.env.PASSWORD_POLICY_MIN_LENGTH ?? "0", 10) || 0; +const SCHOOLNAME = process.env.NEXT_PUBLIC_SCHOOLNAME ?? "Deine Schule"; +const PASSWORD_GATE_MESSAGE = "Achtung! Ohne Benutzernamen macht ein Passwort keinen Sinn! Gib zuerst den Benutzernamen ein!"; +const CAPS_LOCK_MESSAGE = [ + "Oh je! Du hast die Caps-Lock Taste benutzt.", + "", + "Das ist eine sehr, sehr schlechte Angewohnheit.", + "Verwende stattdessen die Shift-Taste mit dem gewünschten Buchstaben zusammen!", + "", + "Schalte jetzt Caps-Lock wieder aus und drücke einmal auf die Shift-Taste, um diese Meldung wegzubekommen.", +].join("\n"); + +// ---- Wrapper: calls only these two hooks, nothing else ---- +export default function LoginPage(): ReactElement | null { + const [mounted, setMounted] = useState(false); + useEffect(() => { setMounted(true); }, []); + if (!mounted) return null; // or a static skeleton + return ; +} + +// ---- All other hooks live here (never conditionally skipped) ---- +function LoginPageInner(): ReactElement { + const router = useRouter(); + + const [showPass, setShowPass] = useState(false); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const userRef = useRef(null); + const passRef = useRef(null); + const usernameStartRef = useRef(null); + + // Random attrs: stable per mount without effects + const userNameAttrRef = useRef(`u_${Math.random().toString(36).slice(2, 8)}`); + const passNameAttrRef = useRef(`p_${Math.random().toString(36).slice(2, 8)}`); + + const [pwMeta, setPwMeta] = useState({ startedAt:0, keys:0, jumped:false, pasted:false, prevLen:0 }); + const [usernameLength, setUsernameLength] = useState(0); + const [passwordLength, setPasswordLength] = useState(0); + const [coachOpen, setCoachOpen] = useState(false); + const [passwordGateVisible, setPasswordGateVisible] = useState(false); + const [capsLockActive, setCapsLockActive] = useState(false); + + useEffect(() => { + const t = setTimeout(() => { + if (userRef.current) userRef.current.value = ""; + if (passRef.current) passRef.current.value = ""; + setUsernameLength(0); + setPasswordLength(0); + setPasswordGateVisible(false); + setMsg(null); + setCapsLockActive(false); + }, 50); + return () => clearTimeout(t); + }, []); + + const armInputs = (): void => { + if (userRef.current?.readOnly) userRef.current.readOnly = false; + if (passRef.current?.readOnly) passRef.current.readOnly = false; + }; + + const onKeyDownGlobal: React.KeyboardEventHandler = (e) => { + const caps = e.getModifierState?.("CapsLock") ?? false; + setCapsLockActive(caps); + if (e.key === "Enter" && !caps) { e.preventDefault(); void handleLogin(); } + }; + + const onKeyUpGlobal: React.KeyboardEventHandler = (e) => { + const caps = e.getModifierState?.("CapsLock") ?? false; + setCapsLockActive(caps); + }; + const onUserPaste: React.ClipboardEventHandler = (e) => { + if (usernameStartRef.current === null && e.clipboardData.getData("text").length > 0) { + usernameStartRef.current = performance.now(); + } + }; + const onUserChange: React.ChangeEventHandler = (e) => { + const value = e.currentTarget.value; + if (value.length === 0) { + usernameStartRef.current = null; + } else if (usernameStartRef.current === null) { + usernameStartRef.current = performance.now(); + } + setUsernameLength(value.length); + if (value.length >= 2) { + if (msg === PASSWORD_GATE_MESSAGE) setMsg(null); + setPasswordGateVisible(false); + } + }; + const onPwKeyDown: React.KeyboardEventHandler = (event) => { + const caps = event.getModifierState?.("CapsLock") ?? false; + setCapsLockActive(caps); + const now = performance.now(); + setPwMeta(m => ({ ...m, startedAt: m.startedAt || now, keys: m.keys + 1 })); + }; + const onPwPaste: React.ClipboardEventHandler = () => setPwMeta(m => ({ ...m, pasted: true })); + const onPwChange: React.ChangeEventHandler = (e) => { + const len = e.currentTarget.value.length; + setPwMeta(m => ({ ...m, jumped: m.jumped || len - m.prevLen > 2, prevLen: len })); + setPasswordLength(len); + }; + + function looksPastedOrTooFast(password: string): boolean { + const len = password.length; + if (len >= MIN_LEN_FOR_CHECK) { + const totalMs = pwMeta.startedAt ? performance.now() - pwMeta.startedAt : 0; + const avg = totalMs > 0 ? totalMs / len : 0; + if (pwMeta.pasted || pwMeta.jumped) return true; + if (totalMs > 0 && (totalMs < MIN_TOTAL_MS || avg < MIN_AVG_MS_PER_CHAR)) return true; + } + return false; + } + + async function actuallyLogin( + rawUsername: string, + password: string, + loginDurationMs: number | null, + passwordTooShort: boolean + ): Promise { + if (userRef.current && passRef.current) { + userRef.current.setAttribute("name", ""); + passRef.current.setAttribute("name", ""); + userRef.current.setAttribute("autocomplete", "off"); + passRef.current.setAttribute("autocomplete", "off"); + } + setBusy(true); + try { + const res = await fetch("/api/login", { + method:"POST", + headers:{ "content-type":"application/json" }, + body: JSON.stringify({ username: rawUsername, password, loginDurationMs }) + }); + const data: { ok?: boolean; error?: string } = await res.json().catch(() => ({ ok:false, error:"server_error" })); + if (data.ok) { + if (userRef.current) userRef.current.value = ""; + if (passRef.current) passRef.current.value = ""; + setUsernameLength(0); + setPasswordLength(0); + setShowPass(false); + setPwMeta({ startedAt:0, keys:0, jumped:false, pasted:false, prevLen:0 }); + usernameStartRef.current = null; + setPasswordGateVisible(false); + const search = new URLSearchParams({ u: rawUsername }); + if (passwordTooShort && MIN_PASSWORD_STATS_LENGTH > 0) search.set("shortpw", "1"); + router.push(`/login/success?${search.toString()}`); + return; + } + switch (data.error) { + case "uppercase_username": router.push(`/login/uppercase?u=${encodeURIComponent(rawUsername)}`); return; + case "bad_request": setMsg("Ungültige Anfrage. Bitte erneut versuchen."); break; + case "server_error": setMsg("Serverfehler beim Login. Bitte später nochmal versuchen."); break; + default: setMsg("Benutzername oder Passwort falsch."); + } + } catch { + setMsg("Netzwerkfehler. Bitte Verbindung prüfen und erneut versuchen."); + } finally { setBusy(false); } + } + + async function handleLogin(): Promise { + if (!userRef.current || !passRef.current) return; + const rawUsername = (userRef.current.value ?? "").trim(); + const password = passRef.current.value ?? ""; + const loginDurationMs = usernameStartRef.current !== null + ? Math.max(0, Math.round(performance.now() - usernameStartRef.current)) + : null; + const passwordTooShort = MIN_PASSWORD_STATS_LENGTH > 0 && password.length < MIN_PASSWORD_STATS_LENGTH; + setMsg(null); + if (rawUsername !== rawUsername.toLowerCase()) { + router.push(`/login/uppercase?u=${encodeURIComponent(rawUsername)}`); + return; + } + if (!rawUsername || !password) { setMsg("Bitte Benutzername und Passwort eingeben."); return; } + if (looksPastedOrTooFast(password)) { setCoachOpen(true); return; } + await actuallyLogin(rawUsername, password, loginDurationMs, passwordTooShort); + } + + function coachRetype(): void { + setCoachOpen(false); + if (passRef.current) passRef.current.value = ""; + setShowPass(false); + setPwMeta({ startedAt:0, keys:0, jumped:false, pasted:false, prevLen:0 }); + setPasswordLength(0); + setTimeout(() => passRef.current?.focus(), 0); + } + + useEffect(() => { + const handler = (event: KeyboardEvent): void => { + const caps = event.getModifierState?.("CapsLock") ?? false; + setCapsLockActive(caps); + }; + window.addEventListener("keydown", handler); + window.addEventListener("keyup", handler); + return () => { + window.removeEventListener("keydown", handler); + window.removeEventListener("keyup", handler); + }; + }, []); + + return ( +
+ + +
+
+
+
+ + { SCHOOLNAME } // Passphrase Lab +
+
Security · Training
+
+ +
+
+
LOGIN
+
Das Passwort muss sitzen!
+
+ + {/* Honeypot */} + +
+ +
+ + Server: h4ckSp4ce +
+ + {msg &&
{msg}
} +
+ )} + +
+ + + {coachOpen && ( +
+
+
Langsamer, Hacker! 🧠
+

+ Das ging sehr schnell oder wurde eingefügt. + Fürs Training zählt nur, was du tippst. +

+
+ +
+
+
+ )} + + ); +} diff --git a/docker-compose/logintrainer/src/app/login/success/page.tsx b/docker-compose/logintrainer/src/app/login/success/page.tsx new file mode 100644 index 0000000..03080f4 --- /dev/null +++ b/docker-compose/logintrainer/src/app/login/success/page.tsx @@ -0,0 +1,166 @@ +// src/app/login/success/page.tsx +import type { ReactElement } from "react"; +import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import SuccessStats from "./success-stats"; +import { adLookupDisplayName } from "@/lib/auth"; + +type RankingMethod = "time_password" | "time_per_char"; + +function getRankingMethod(): RankingMethod { + return "time_per_char"; +} + +function startOfTodayMinusDays(days: number): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - days); + return d; +} + +export default async function SuccessPage({ + searchParams, +}: { + searchParams: Promise<{ u?: string; shortpw?: string }>; +}): Promise { + const rankingMethod = getRankingMethod(); + const sp = await searchParams; + const username = (sp.u || "").trim(); + if (!username) redirect("/login"); + const shortPwFlag = sp.shortpw === "1"; + + const since = startOfTodayMinusDays(5); // inkl. heute = 6 Tage + + // Statistik wie gehabt + const rows = await prisma.loginAttempt.findMany({ + where: { username, success: true, createdAt: { gte: since } }, + select: { createdAt: true }, + orderBy: { createdAt: "desc" }, + }); + const daysWithSuccess = new Set( + rows.map((r) => r.createdAt.toISOString().slice(0, 10)) + ).size; + const totalDays = 6; + const percent = Math.round((daysWithSuccess / totalDays) * 100); + + const durationRows = await prisma.loginAttempt.findMany({ + where: { username, success: true }, + select: { + createdAt: true, + loginDurationMs: true, + passwordLength: true, + passwordPolicyViolation: true, + passwordHasUpper: true, + passwordHasLower: true, + passwordHasDigit: true, + passwordHasSpecial: true, + }, + orderBy: { createdAt: "asc" }, + }); + const durationSeries = durationRows + .map((row) => { + const duration = typeof row.loginDurationMs === "number" && Number.isFinite(row.loginDurationMs) + ? row.loginDurationMs + : null; + if (duration === null) return null; + + if (rankingMethod === "time_per_char") { + const passwordLength = typeof row.passwordLength === "number" && Number.isFinite(row.passwordLength) + ? row.passwordLength + : null; + if (passwordLength === null || passwordLength <= 0) return null; + return { + dateISO: row.createdAt.toISOString(), + ms: Math.max(0, duration / passwordLength), + }; + } + + return { + dateISO: row.createdAt.toISOString(), + ms: Math.max(0, duration), + }; + }) + .filter((entry): entry is { dateISO: string; ms: number } => entry !== null); + + const measurementCount = durationSeries.length; + const currentDurationMs = measurementCount > 0 ? durationSeries[measurementCount - 1].ms : null; + const bestDurationMs = measurementCount > 0 + ? durationSeries.reduce((best, entry) => Math.min(best, entry.ms), durationSeries[0].ms) + : null; + const averageDurationMs = + measurementCount > 0 + ? durationSeries.reduce((total, entry) => total + entry.ms, 0) / measurementCount + : null; + + const latestAttempt = durationRows.length > 0 ? durationRows[durationRows.length - 1] : null; + const latestPasswordLength = typeof latestAttempt?.passwordLength === "number" + ? latestAttempt.passwordLength + : 0; + const isPasswordShort = latestPasswordLength > 0 && latestPasswordLength < 11; + const hasPasswordPolicyViolation = latestAttempt?.passwordPolicyViolation === true; + const latestPasswordHasUpper = latestAttempt?.passwordHasUpper ?? false; + const latestPasswordHasLower = latestAttempt?.passwordHasLower ?? false; + const latestPasswordHasDigit = latestAttempt?.passwordHasDigit ?? false; + const latestPasswordHasSpecial = latestAttempt?.passwordHasSpecial ?? false; + + // AD-Anzeigename (fallback auf username) + const isAD = (process.env.AUTH_MODE || "local") === "ad"; + let displayName = username; + if (isAD) { + const dn = await adLookupDisplayName(username); + if (dn) displayName = dn; + } + + const authMode = + (process.env.NEXT_PUBLIC_AUTH_MODE ?? "local") === "ad" ? "AD-Login" : "Login · Übungsmodus"; + const shortPwMessage = process.env.NEXT_PUBLIC_MIN_PASSWORD_ALERT_MESSAGE?.trim() || + "Dein Passwort entspricht sicher nicht den Anforderungen, die du im Informatik-Unterricht gelernt hast!"; + const policyMinLength = Number.parseInt( + process.env.PASSWORD_POLICY_MIN_LENGTH ?? "14", + 10 + ) || 14; + const metricSuffix = ""; + const currentSubtitle = "Sekunden pro Zeichen"; + const chartTitle = "Zeit pro Zeichen"; + + return ( +
+
+
+ + + + MORZ // Passphrase Lab +
+
Auswertung · {authMode}
+
+ +
+ +
+
+ ); +} diff --git a/docker-compose/logintrainer/src/app/login/success/success-stats.tsx b/docker-compose/logintrainer/src/app/login/success/success-stats.tsx new file mode 100644 index 0000000..9737a46 --- /dev/null +++ b/docker-compose/logintrainer/src/app/login/success/success-stats.tsx @@ -0,0 +1,436 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, type ReactElement, type CSSProperties } from "react"; + +type DurationPoint = { dateISO: string; ms: number }; + +type SuccessStatsProps = { + displayName: string; + days: number; + percent: number; + totalDays: number; + currentDurationMs: number | null; + averageDurationMs: number | null; + bestDurationMs: number | null; + durationSeries: DurationPoint[]; + measurementCount: number; + showShortPasswordAlert: boolean; + shortPasswordMessage: string; + metricSuffix: string; + currentSubtitle: string; + chartTitle: string; + isPasswordShort: boolean; + passwordPolicyViolation: boolean; + passwordLength: number; + passwordHasUpper: boolean; + passwordHasLower: boolean; + passwordHasDigit: boolean; + passwordHasSpecial: boolean; + policyMinLength: number; +}; + +const dateFormatter = new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + timeZone: "UTC", +}); + +const disqualStampWrapperStyle: CSSProperties = { + position: "absolute", + top: "24px", + left: "50%", + transform: "translateX(-50%) rotate(-12deg)", + pointerEvents: "none", + zIndex: 50, +}; + +const disqualStampStyle: CSSProperties = { + position: "relative", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minWidth: "360px", + padding: "18px 54px", + border: "5px solid rgba(239,68,68,0.95)", + background: "rgba(220,38,38,0.18)", + color: "#fee2e2", + fontSize: "1.25rem", + fontWeight: 900, + letterSpacing: "0.58em", + textTransform: "uppercase", + textShadow: "0 16px 38px rgba(220,38,38,0.6)", + boxShadow: "0 20px 46px rgba(220,38,38,0.4)", + whiteSpace: "nowrap", +}; + +const disqualStampInnerStyle: CSSProperties = { + position: "absolute", + inset: "6px", + border: "2px dashed rgba(248,113,113,0.7)", +}; + +export default function SuccessStats({ + displayName, + days, + percent, + totalDays, + currentDurationMs, + averageDurationMs, + bestDurationMs, + durationSeries, + measurementCount, + showShortPasswordAlert, + shortPasswordMessage, + metricSuffix, + currentSubtitle, + chartTitle, + isPasswordShort, + passwordPolicyViolation, + passwordLength, + passwordHasUpper, + passwordHasLower, + passwordHasDigit, + passwordHasSpecial, + policyMinLength, +}: SuccessStatsProps): ReactElement { + const [dAnim, setDAnim] = useState(0); + const [pAnim, setPAnim] = useState(0); + const cardRef = useRef(null); + + useEffect(() => { + animateTo(days, setDAnim, 800); + animateTo(percent, setPAnim, 900); + cardRef.current?.animate( + [ + { boxShadow: "0 10px 32px rgba(0,0,0,.45)" }, + { boxShadow: "0 16px 40px rgba(89,240,168,.18)" }, + { boxShadow: "0 10px 32px rgba(0,0,0,.45)" }, + ], + { duration: 800, easing: "ease-in-out" } + ); + }, [days, percent]); + + const hasMeasurements = measurementCount > 0; + const hasAverage = averageDurationMs !== null; + const currentTone = useMemo(() => { + if (!hasAverage || currentDurationMs === null) return "neutral" as const; + if (currentDurationMs > averageDurationMs!) return "slower" as const; + if (currentDurationMs < averageDurationMs!) return "faster" as const; + return "neutral" as const; + }, [hasAverage, currentDurationMs, averageDurationMs]); + const chartStats = useMemo(() => { + if (!hasMeasurements) return null; + const values = durationSeries.map((point) => point.ms); + const max = Math.max(...values); + const min = Math.min(...values); + return { max, min }; + }, [durationSeries, hasMeasurements]); + + const passwordChecks = useMemo(() => { + const lengthOk = passwordLength >= policyMinLength; + const upperOk = passwordHasUpper; + const lowerOk = passwordHasLower; + const digitOk = passwordHasDigit; + const specialOk = passwordHasSpecial; + return { + lengthOk, + upperOk, + lowerOk, + digitOk, + specialOk, + allOk: lengthOk && upperOk && lowerOk && digitOk && specialOk, + }; + }, [passwordLength, passwordHasUpper, passwordHasLower, passwordHasDigit, passwordHasSpecial, policyMinLength]); + + const stampNode = passwordPolicyViolation ? ( + + ) : null; + + const headerNode = ( +
+

+ {passwordPolicyViolation + ? "Login erfolgreich, aber das Passwort ist nicht gut!" + : "Login erfolgreich!"} +

+

+ {isPasswordShort ? "Dein Passwort ist zu kurz" : "Gut gemacht"},{" "} + {displayName}. +

+
+ ); + + const alertNode = showShortPasswordAlert ? ( +
+ {shortPasswordMessage} +
+ ) : null; + + const passwordTile = ( +
+
Passwort-Check
+
+ + + + + +
+

+ {passwordChecks.allOk + ? "Starke Passwort-Anforderungen erfüllt." + : "Bitte verbessere dein Passwort, um alle Kriterien zu erfüllen."} +

+
+ ); + + if (passwordPolicyViolation) { + return ( +
+ {stampNode} + {headerNode} + {alertNode} +
+ {passwordTile} +
+ +
+ ); + } + + return ( +
+ {stampNode} + {headerNode} + {alertNode} +
+
+
+
Letzte {totalDays} Tage
+
{dAnim}
+
{dAnim === 1 ? "Tag mit Erfolg" : "Tage mit Erfolg"}
+
+
+
Rating
+
{pAnim}%
+
Durchhaltequote
+
+
+
Aktueller Login
+
+ {renderDuration(currentDurationMs, metricSuffix)} +
+
{currentSubtitle}
+ {bestDurationMs !== null && ( +
Deine Bestzeit: {renderDuration(bestDurationMs, metricSuffix)}
+ )} +
+ {passwordTile} +
+
+
+

+ {chartTitle} +

+ + Werte in Sekunden{metricSuffix ? ` ${metricSuffix}` : ""},{" "} + {measurementCount === 1 ? "1 Messung" : `${measurementCount} Messungen`} + +
+ {hasMeasurements && chartStats ? ( + + ) : ( +

+ Noch keine Messwerte vorhanden. Melde dich erneut an, um Daten zu sammeln. +

+ )} +
+
+ +
+ ); +} + +function animateTo(target: number, set: (v: number) => void, duration = 800): void { + const start = performance.now(); + const from = 0; + const to = Math.max(0, Math.round(target)); + const ease = (t: number): number => 1 - Math.pow(1 - t, 3); + function frame(now: number): void { + const p = Math.min(1, (now - start) / duration); + const val = Math.round(from + (to - from) * ease(p)); + set(val); + if (p < 1) requestAnimationFrame(frame); + } + requestAnimationFrame(frame); +} + +function formatDuration(ms: number | null): string { + if (ms === null || ms === undefined) return "–"; + const seconds = ms / 1000; + if (seconds < 60) { + return `${seconds.toFixed(seconds < 10 ? 2 : 1)} s`; + } + const minutes = Math.floor(seconds / 60); + const rest = seconds - minutes * 60; + const restStr = rest >= 10 ? rest.toFixed(0) : rest.toFixed(1); + return `${minutes} min ${restStr} s`; +} + +function renderDuration(ms: number | null, suffix = ""): ReactElement { + if (ms === null || ms === undefined) return <>–; + return ( + <> + {formatDuration(ms)} + {suffix && {suffix}} + + ); +} + +function SecurityRow({ ok, label }: { ok: boolean; label: string }): ReactElement { + return ( +
+ {ok ? "✅" : "🚨"} + {label} +
+ ); +} + +function DurationChart({ + series, + minMs, + maxMs, + valueSuffix, +}: { + series: DurationPoint[]; + minMs: number; + maxMs: number; + valueSuffix: string; +}): ReactElement { + const padding = 8; + const width = 100; + const height = 80; + const usableWidth = width - padding * 2; + const usableHeight = height - padding * 2; + const range = Math.max(1, maxMs - minMs); + + const points = series.map((entry, index) => { + const x = series.length === 1 + ? width / 2 + : padding + (usableWidth * index) / (series.length - 1); + const y = height - (padding + ((entry.ms - minMs) / range) * usableHeight); + return { ...entry, x, y }; + }); + + const polyline = points.length === 1 + ? `${points[0].x},${points[0].y}` + : points.map((p) => `${p.x},${p.y}`).join(" "); + + return ( +
+ + + + + + + + + {points.length > 1 && ( + `${p.x},${p.y}`) + .join(" ")} ${points[points.length - 1].x},${height - padding} ${points[0].x},${height - padding}`} + fill="url(#login-time-gradient)" + opacity={0.35} + /> + )} + + + + + + {points.map((p, idx) => ( + + + {`${dateFormatter.format(new Date(p.dateISO))} · ${(p.ms / 1000).toFixed(2)} s${ + valueSuffix ? ` ${valueSuffix}` : "" + }`} + + + ))} + + +
+ + {(minMs / 1000).toFixed(2)} s{valueSuffix ? ` ${valueSuffix}` : ""} + + + Max {(maxMs / 1000).toFixed(2)} s{valueSuffix ? ` ${valueSuffix}` : ""} + +
+
+ {points.map((p, idx) => ( + {dateFormatter.format(new Date(p.dateISO))} + ))} +
+
+ ); +} diff --git a/docker-compose/logintrainer/src/app/login/uppercase/page.tsx b/docker-compose/logintrainer/src/app/login/uppercase/page.tsx new file mode 100644 index 0000000..889f910 --- /dev/null +++ b/docker-compose/logintrainer/src/app/login/uppercase/page.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from "react"; +import { redirect } from "next/navigation"; +import { adLookupDisplayName } from "@/lib/auth"; +import { logError } from "@/lib/logger"; + +export default async function UppercasePage({ + searchParams, +}: { + searchParams: Promise<{ u?: string }>; +}): Promise { + const sp = await searchParams; + const raw = (sp.u || "").trim(); + if (!raw) redirect("/login"); + + // DisplayName aus AD (falls möglich), sonst Fallback + let displayName = "MORZ-User"; + try { + const dn = await adLookupDisplayName(raw.toLowerCase()); // Suche case-insensitive + if (dn) displayName = dn; + } catch (lookupError: unknown) { + logError("[/login/uppercase] display name lookup failed", lookupError); + } + + return ( +
+
+
+ + + + MORZ // Passphrase Lab +
+
Hinweis
+
+ +
+
+

+ Benutzername in Kleinbuchstaben +

+

💡

+

+ Hallo {displayName}, +

+ +

+ du hast im Benutzernamen Großbuchstaben verwendet. Das kann zu Problemen führen und + deshalb solltest du das nicht tun. +

+ +

+ Achte bitte in Zukunft darauf, den Benutzernamen ausschließlich in + kleinbuchstaben zu tippen. +

+ +

+ Das betrifft nicht das Passwort! Dieses musst du genau so eintippen, wie du es dir ausgesucht hast! +

+ + +
+
+
+ ); +} diff --git a/docker-compose/logintrainer/src/app/page.tsx b/docker-compose/logintrainer/src/app/page.tsx new file mode 100644 index 0000000..f1a1a74 --- /dev/null +++ b/docker-compose/logintrainer/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage(): never { + redirect("/login"); +} diff --git a/docker-compose/logintrainer/src/app/public/.keep b/docker-compose/logintrainer/src/app/public/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose/logintrainer/src/app/register/page.tsx b/docker-compose/logintrainer/src/app/register/page.tsx new file mode 100644 index 0000000..1289378 --- /dev/null +++ b/docker-compose/logintrainer/src/app/register/page.tsx @@ -0,0 +1,36 @@ +"use client"; +import { useState, type FormEvent, type ReactElement } from "react"; + +export default function RegisterPage(): ReactElement { + const [, setMsg] = useState(""); + + async function handleRegister(e: FormEvent): Promise { + e.preventDefault(); + const form = new FormData(e.currentTarget); + const username = form.get("username") as string; + const pw1 = form.get("pw1") as string; + const pw2 = form.get("pw2") as string; + if (pw1 !== pw2) { + setMsg("❌ Passwörter stimmen nicht überein!"); + return; + } + const res = await fetch("/api/register", { + method: "POST", + body: JSON.stringify({ username, password: pw1 }), + }); + if (res.ok) { + // direkt weiter zur Login-Seite + window.location.assign("/login?registered=1"); + return; + } else { + const j = (await res.json()) as { error?: string }; + setMsg(`Fehler: ${j.error ?? "unbekannt"}`); + } + } + + return ( +
+ {/* … Rest wie gehabt … */} +
+ ); +} diff --git a/docker-compose/logintrainer/src/app/teacher/cleanup/CleanupForm.tsx b/docker-compose/logintrainer/src/app/teacher/cleanup/CleanupForm.tsx new file mode 100644 index 0000000..496bd90 --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/cleanup/CleanupForm.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState, type FormEvent, type ReactElement } from "react"; + +export default function CleanupForm(): ReactElement { + const [days, setDays] = useState("14"); + const [users, setUsers] = useState(""); + const [classes, setClasses] = useState(""); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + + async function handleSubmit(e: FormEvent): Promise { + e.preventDefault(); + setBusy(true); + setResult(null); + + const payload: Record = {}; + const parsedDays = Number.parseInt(days, 10); + if (!Number.isNaN(parsedDays) && parsedDays > 0) payload.olderThanDays = parsedDays; + + const userList = users + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + if (userList.length > 0) payload.users = userList; + + const classList = classes + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + if (classList.length > 0) payload.classes = classList; + + try { + const res = await fetch("/api/cleanup", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const json = await res.json(); + if (!res.ok || !json.ok) { + setResult("Fehler beim Löschen. Bitte erneut versuchen."); + } else { + setResult(`Gelöscht: ${json.deleted ?? 0} Datensätze.`); + } + } catch { + setResult("Serverfehler. Bitte später erneut versuchen."); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + setDays(e.target.value)} + className="mt-2 w-full rounded-lg border border-emerald-400/40 bg-transparent px-3 py-2 font-mono text-emerald-100" + placeholder="z. B. 14" + /> +
+ +
+ + setUsers(e.target.value)} + className="mt-2 w-full rounded-lg border border-emerald-400/40 bg-transparent px-3 py-2 font-mono text-emerald-100" + placeholder="z. B. az.schueler, bc.lehrer" + /> +
+ +
+ + setClasses(e.target.value)} + className="mt-2 w-full rounded-lg border border-emerald-400/40 bg-transparent px-3 py-2 text-emerald-100" + placeholder="z. B. 9a, 10b" + /> +
+ + + + {result && ( +
+ {result} +
+ )} +
+ ); +} diff --git a/docker-compose/logintrainer/src/app/teacher/cleanup/page.tsx b/docker-compose/logintrainer/src/app/teacher/cleanup/page.tsx new file mode 100644 index 0000000..e9e0d99 --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/cleanup/page.tsx @@ -0,0 +1,24 @@ +import type { ReactElement } from "react"; +import AuthGate from "@/app/teacher/logs/AuthGate"; +import CleanupForm from "./CleanupForm"; + +export const metadata = { + title: "Datenbereinigung · Teacher", + description: "Login-Datensätze nach Alter, Nutzer oder Klasse löschen.", +}; + +export default function CleanupPage(): ReactElement { + return ( + +
+
+

Datenbereinigung

+

+ Entferne alte oder unerwünschte Login-Datensätze. Aktionen wirken sofort und können nicht rückgängig gemacht werden. +

+
+ +
+
+ ); +} diff --git a/docker-compose/logintrainer/src/app/teacher/dbdump/DumpViewer.tsx b/docker-compose/logintrainer/src/app/teacher/dbdump/DumpViewer.tsx new file mode 100644 index 0000000..d8a8501 --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/dbdump/DumpViewer.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState, type ReactElement } from "react"; +import clsx from "clsx"; + +type DumpRowValue = string | number | boolean | null; + +export type DumpTable = { + name: string; + columns: string[]; + rows: Array>; +}; + +type SortDir = "asc" | "desc"; + +type SortState = { + column: string; + direction: SortDir; +}; + +export default function DumpViewer({ tables }: { tables: DumpTable[] }): ReactElement { + const [filter, setFilter] = useState(""); + const [sortState, setSortState] = useState>({}); + + const filterLower = filter.trim().toLowerCase(); + + const toggleSort = (tableName: string, column: string): void => { + setSortState((prev) => { + const current = prev[tableName]; + if (!current || current.column !== column) { + return { ...prev, [tableName]: { column, direction: "asc" } }; + } + const direction = current.direction === "asc" ? "desc" : "asc"; + return { ...prev, [tableName]: { column, direction } }; + }); + }; + + return ( +
+
+

Filter

+
+ +
+
+ + {tables.map((table) => { + const sort = sortState[table.name]; + const filtered = table.rows.filter((row) => { + if (!filterLower) return true; + const value = row.username; + if (typeof value === "string") { + return value.toLowerCase().includes(filterLower); + } + return false; + }); + + const rows = sort + ? [...filtered].sort((a, b) => { + const va = a[sort.column]; + const vb = b[sort.column]; + if (va === vb) return 0; + if (va === null || va === undefined) return 1; + if (vb === null || vb === undefined) return -1; + if (typeof va === "number" && typeof vb === "number") { + return sort.direction === "asc" ? va - vb : vb - va; + } + const result = String(va).localeCompare(String(vb), "de", { sensitivity: "base" }); + return sort.direction === "asc" ? result : -result; + }) + : filtered; + + return ( +
+
+

{table.name}

+ {rows.length} Datensätze +
+ +
+ + + + {table.columns.map((column) => { + const active = sort?.column === column; + return ( + + ); + })} + + + + {rows.map((row, rowIndex) => ( + + {table.columns.map((column) => ( + + ))} + + ))} + {rows.length === 0 && ( + + + + )} + +
toggleSort(table.name, column)} + > + {column} + {active && {sort?.direction === "asc" ? " ▲" : " ▼"}} +
+ {formatValue(row[column])} +
+ Keine Einträge für den aktuellen Filter. +
+
+
+ ); + })} +
+ ); +} + +function formatValue(value: DumpRowValue): string { + if (value === null || value === undefined) return "—"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : value.toString(); + return value; +} diff --git a/docker-compose/logintrainer/src/app/teacher/dbdump/page.tsx b/docker-compose/logintrainer/src/app/teacher/dbdump/page.tsx new file mode 100644 index 0000000..2f0389f --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/dbdump/page.tsx @@ -0,0 +1,76 @@ +import type { ReactElement } from "react"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import AuthGate from "@/app/teacher/logs/AuthGate"; +import DumpViewer, { type DumpTable } from "./DumpViewer"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +type TableRow = Record; + +function normalizeValue(value: unknown): string | number | boolean | null { + if (value === null || value === undefined) return null; + if (value instanceof Date) return value.toISOString(); + if (typeof value === "bigint") return Number(value); + if (typeof value === "object") return JSON.stringify(value); + return value as string | number | boolean; +} + +async function loadTables(): Promise { + const tablesRaw = await prisma.$queryRaw>(Prisma.sql` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE '_prisma_migrations' + AND name NOT LIKE 'sqlite_%' + ORDER BY name; + `); + + const tableNames = tablesRaw.map((row) => row.name).filter((name) => typeof name === "string" && name.length > 0); + + const result: DumpTable[] = []; + + for (const name of tableNames) { + const columnInfo = await prisma.$queryRawUnsafe>(`PRAGMA table_info("${name}");`); + const columns = columnInfo.map((info) => info.name).filter((column) => column.length > 0); + + const rowsRaw = await prisma.$queryRawUnsafe(`SELECT * FROM "${name}";`); + const rows = rowsRaw.map((row) => { + const normalized: Record = {}; + for (const column of columns) { + normalized[column] = normalizeValue(row[column]); + } + return normalized; + }); + + result.push({ name, columns, rows }); + } + + return result; +} + +export default async function DbDumpPage(): Promise { + const tables = await loadTables(); + + return ( + +
+
+
+

DB Explorer

+

+ Datenbank-Dump +

+

+ Nutze den Filter nach Username + oder sortiere Spalten, um schneller bestimmte Einträge zu finden. +

+
+ + +
+
+
+ ); +} diff --git a/docker-compose/logintrainer/src/app/teacher/logs/AuthGate.tsx b/docker-compose/logintrainer/src/app/teacher/logs/AuthGate.tsx new file mode 100644 index 0000000..c3941eb --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/logs/AuthGate.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState, useEffect, type FormEvent, type ReactElement, type ReactNode } from "react"; + +export default function AuthGate({ children }: { children: ReactNode }): ReactElement { + const [ok, setOk] = useState(false); + const [pw, setPw] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + // auto-login, falls im localStorage gespeichert + const saved = localStorage.getItem("logsAuth"); + if (saved) { + setOk(true); + } + }, []); + + async function handleSubmit(e: FormEvent): Promise { + e.preventDefault(); + const res = await fetch("/api/teacher/auth", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password: pw }), + }); + if (res.ok) { + localStorage.setItem("logsAuth", "yes"); + setOk(true); + } else { + setError("Falsches Passwort"); + setPw(""); + } + } + + if (!ok) { + return ( +
+
+
+ Teacher Area +

Geschützter Bereich

+

Bitte Passwort eingeben, um die Auswertungen aufzurufen.

+
+ +
+
+ + setPw(e.target.value)} + className="teacher-auth__input" + /> +
+ + + + {error && ( +

+ {error} +

+ )} +
+
+
+ ); + } + + return <>{children}; +} diff --git a/docker-compose/logintrainer/src/app/teacher/logs/LogsTable.tsx b/docker-compose/logintrainer/src/app/teacher/logs/LogsTable.tsx new file mode 100644 index 0000000..5833b0b --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/logs/LogsTable.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useMemo, useState, type ReactElement } from "react"; + +type Attempt = { + id: number; + username: string; + success: boolean; + createdAt: string; // ISO + className: string | null; +}; + +type SortKey = "createdAt" | "username" | "success"; +type SortDir = "asc" | "desc"; + +export default function LogsTable({ + initialAttempts, + classes, +}: { + initialAttempts: Attempt[]; + classes: string[]; +}): ReactElement { + const [attempts, setAttempts] = useState(initialAttempts); + const [q, setQ] = useState(""); + const [cls, setCls] = useState(""); // "" = alle Klassen + const [sortKey, setSortKey] = useState("createdAt"); + const [sortDir, setSortDir] = useState("desc"); + const [busy, setBusy] = useState(false); + const [info, setInfo] = useState(null); + + const data = useMemo(() => { + const qNorm = q.trim().toLowerCase(); + let filtered = attempts; + + if (qNorm) filtered = filtered.filter(a => a.username.toLowerCase().includes(qNorm)); + if (cls) filtered = filtered.filter(a => a.className === cls); + + filtered = [...filtered].sort((a, b) => { + let cmp = 0; + if (sortKey === "createdAt") { + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + } else if (sortKey === "username") { + cmp = a.username.localeCompare(b.username, "de", { sensitivity: "base" }); + } else if (sortKey === "success") { + cmp = Number(a.success) - Number(b.success); + } + return sortDir === "asc" ? cmp : -cmp; + }); + + return filtered; + }, [attempts, q, cls, sortKey, sortDir]); + + function toggleSort(key: SortKey): void { + if (key === sortKey) setSortDir(d => (d === "asc" ? "desc" : "asc")); + else { setSortKey(key); setSortDir("desc"); } + } + const arrow = (key: SortKey): string => (sortKey === key ? (sortDir === "asc" ? " ▲" : " ▼") : ""); + + async function purgeFailed(): Promise { + if (!confirm("Alle fehlgeschlagenen Logins wirklich löschen?")) return; + setBusy(true); setInfo(null); + + const headers: Record = { "content-type": "application/json" }; + const saved = localStorage.getItem("purgeToken"); + if (saved) headers["x-admin-token"] = saved; + + let res = await fetch("/api/attempts/purge-failed", { method: "DELETE", headers, cache: "no-store" }); + if (res.status === 401) { + const t = prompt("Admin-Token benötigt:"); + if (!t) { setBusy(false); return; } + localStorage.setItem("purgeToken", t); + headers["x-admin-token"] = t; + res = await fetch("/api/attempts/purge-failed", { method: "DELETE", headers, cache: "no-store" }); + } + + if (!res.ok) { setBusy(false); setInfo(`Fehler beim Löschen (HTTP ${res.status}).`); return; } + const j = (await res.json()) as { deleted?: number }; + setAttempts(prev => prev.filter(a => a.success)); // nur erfolgreiche bleiben + setBusy(false); + setInfo(`Gelöscht: ${j.deleted ?? 0} fehlgeschlagene Versuche.`); + } + + return ( + <> + {/* Filter + Sort */} +
+
+ + setQ(e.target.value)} + placeholder="z. B. max.mueller" + className="w-full p-2 bg-transparent border rounded border-[#7aa2ff]/50" + autoFocus + /> +
+ + {/* Klassen-Dropdown */} +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* Purge-Button */} +
+ + {info && {info}} +
+ + {/* Tabelle */} + + + + + + + + + + + {data.map((a) => ( + + + + + + + ))} + +
toggleSort("createdAt")}> + Zeit{arrow("createdAt")} + toggleSort("username")}> + User{arrow("username")} + Klasse toggleSort("success")}> + Erfolg{arrow("success")} +
{new Date(a.createdAt).toLocaleString("de-DE")}{a.username}{a.className ?? "—"} + {a.success ? "✔" : "✘"} +
+ + ); +} diff --git a/docker-compose/logintrainer/src/app/teacher/logs/page.tsx b/docker-compose/logintrainer/src/app/teacher/logs/page.tsx new file mode 100644 index 0000000..b68790d --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/logs/page.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from "react"; +import { prisma } from "@/lib/prisma"; +import LogsTable from "./LogsTable"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; // kein Cache +export const runtime = "nodejs"; // sicher nicht Edge + +export default async function LogsPage(): Promise { + // 1) Logs (wie gehabt), inkl. className + const rows = await prisma.loginAttempt.findMany({ + orderBy: { createdAt: "desc" }, + take: 1000, + select: { + id: true, + username: true, + success: true, + createdAt: true, + className: true, + }, + }); + + const attempts = rows.map((r) => ({ + id: r.id, + username: r.username, + success: r.success, + createdAt: r.createdAt.toISOString(), + className: r.className ?? null, + })); + + // 2) Alle Klassen (distinct), sortiert + const classRows = await prisma.loginAttempt.findMany({ + where: { className: { not: null } }, + distinct: ["className"], + select: { className: true }, + }); + + const classes = Array.from( + new Set(classRows.map((r) => r.className!).filter(Boolean)) + ).sort((a, b) => a.localeCompare(b, "de", { sensitivity: "base" })); + + return ( +
+
+

Login-Logs

+ + Zur Statistik → + +
+ + +
+ ); +} diff --git a/docker-compose/logintrainer/src/app/teacher/page.tsx b/docker-compose/logintrainer/src/app/teacher/page.tsx new file mode 100644 index 0000000..396ced1 --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/page.tsx @@ -0,0 +1,88 @@ +import type { ReactElement } from "react"; +import AuthGate from "@/app/teacher/logs/AuthGate"; + +type TeacherLink = { + href: string; + title: string; + description: string; + icon: string; + accent: "emerald" | "amber" | "violet" | "rose" | "sky"; +}; + +const links: TeacherLink[] = [ + { + href: "/teacher/logs", + title: "Login-Logs", + description: "Alle Logins durchsuchen, filtern und bereinigen.", + icon: "🗄️", + accent: "emerald", + }, + { + href: "/teacher/stats", + title: "Statistiken", + description: "Kennzahlen, Trends und Diagramme auf einen Blick.", + icon: "📊", + accent: "amber", + }, + { + href: "/leaderboard", + title: "Leaderboards", + description: "Highscores live verfolgen – über alle Klassen hinweg.", + icon: "🏆", + accent: "violet", + }, + { + href: "/teacher/violaters", + title: "Passwort-Verstöße", + description: "Passwort-Verstöße der letzten Tage inklusive Details.", + icon: "🚨", + accent: "rose", + }, + { + href: "/teacher/dbdump", + title: "Datenbank-Dump", + description: "Aktuellen Datenstand exportieren und für Analysen sichern.", + icon: "💾", + accent: "sky", + }, +]; + +export const metadata = { + title: "Teacher Dashboard", + description: "Zentrale Übersicht für Trainer:innen.", +}; + +export default function TeacherIndexPage(): ReactElement { + return ( + +
+
+
+ Teacher Area +

Sammlung verschiedener Ansichten

+

+ Wähle eine Kachel, um direkt zu den wichtigsten Werkzeugen für Auswertung, Wettbewerbe und Administration zu springen. +

+
+ + +
+
+ ); +} diff --git a/docker-compose/logintrainer/src/app/teacher/stats/StatsTable.module.css b/docker-compose/logintrainer/src/app/teacher/stats/StatsTable.module.css new file mode 100644 index 0000000..ccc4636 --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/stats/StatsTable.module.css @@ -0,0 +1,13 @@ +/* Scoped Reset/Styling nur für die Stats-Tabelle */ +.wrap table { width: 100%; border-collapse: separate; border-spacing: 0; } +.wrap :is(th, td) { text-align: center; vertical-align: middle; } +.wrap th, .wrap td { padding: 8px 12px; } + +.wrap thead tr { background: rgba(255,255,255,0.10); } +.wrap tbody tr:nth-child(even) { background: rgba(255,255,255,0.04); } + +.wrap .num { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-variant-numeric: tabular-nums; + text-align: center; +} diff --git a/docker-compose/logintrainer/src/app/teacher/stats/StatsTable.tsx b/docker-compose/logintrainer/src/app/teacher/stats/StatsTable.tsx new file mode 100644 index 0000000..dc3f3a8 --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/stats/StatsTable.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useMemo, useState, type ReactElement, type ReactNode } from "react"; +import styles from "./StatsTable.module.css"; // ⬅️ NEU + +type Row = { + username: string; + className: string | null; + total: number; + success: number; + successLast6: number; + fail: number; + percent: number; + fastestMs: number | null; + avgMs: number | null; +}; + +type SortKey = keyof Row; +type SortDir = "asc" | "desc"; + +export default function StatsTable({ + initial, + classes, +}: { + initial: Row[]; + classes: string[]; +}): ReactElement { + const [rows] = useState(initial); + const [q, setQ] = useState(""); + const [cls, setCls] = useState(""); + const [sortKey, setSortKey] = useState("username"); + const [sortDir, setSortDir] = useState("asc"); + + const data = useMemo(() => { + const qNorm = q.trim().toLowerCase(); + let out = rows; + + if (qNorm) out = out.filter((r) => r.username.toLowerCase().includes(qNorm)); + if (cls) out = out.filter((r) => r.className === cls); + + out = [...out].sort((a, b) => { + let cmp = 0; + const va = a[sortKey]; + const vb = b[sortKey]; + if (typeof va === "number" && typeof vb === "number") cmp = va - vb; + else cmp = String(va ?? "").localeCompare(String(vb ?? ""), "de", { sensitivity: "base" }); + return sortDir === "asc" ? cmp : -cmp; + }); + + return out; + }, [rows, q, cls, sortKey, sortDir]); + + function toggleSort(key: SortKey): void { + if (key === sortKey) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { + setSortKey(key); + setSortDir(key === "username" || key === "className" ? "asc" : "desc"); + } + } + const arrow = (key: SortKey): string => (sortKey === key ? (sortDir === "asc" ? " ▲" : " ▼") : ""); + + function resetFilters(): void { + setQ(""); + setCls(""); + setSortKey("username"); + setSortDir("asc"); + } + + const formatDuration = (value: number | null): string => { + if (value === null) return "—"; + const seconds = value / 1000; + const digits = seconds >= 10 ? 0 : 1; + return `${seconds.toFixed(digits)}s`; + }; + + return ( +
+ {/* Filter */} +
+
+ + setQ(e.target.value)} + placeholder="z. B. max.mueller" + className="w-full rounded-md border border-white/20 bg-white/5 px-3 py-2 outline-none" + /> +
+ +
+ + +
+ + +
+ + {/* Tabelle */} +
+ + + + + + + {data.map((r, i) => ( + + + + + + + + + + + + ))} + {data.length === 0 && ( + + + + )} + +
toggleSort("username")} label={`Nutzername${arrow("username")}`} /> + toggleSort("className")} label={`Klasse${arrow("className")}`} /> + toggleSort("total")} label={`Insgesamt${arrow("total")}`} /> + toggleSort("success")} label={`Erfolgreich${arrow("success")}`} /> + toggleSort("successLast6")} label={`Erfolg (6 Tage)${arrow("successLast6")}`} /> + toggleSort("fail")} label={`Fehlversuche${arrow("fail")}`} /> + toggleSort("percent")} label={`Durchhaltequote${arrow("percent")}`} /> + toggleSort("fastestMs")} label={`Schnellster Versuch${arrow("fastestMs")}`} /> + toggleSort("avgMs")} label={`Ø Zeit${arrow("avgMs")}`} /> +
{r.username}{r.className ?? "—"}{r.total}{r.success}{r.successLast6}{r.fail}{r.percent}%{formatDuration(r.fastestMs)}{formatDuration(r.avgMs)}
+ Keine Treffer für die aktuelle Filter/Suchkombi. +
+
+ +

+ Tipp: Klick auf Spaltenköpfe sortiert; oben live nach Username filtern und per Dropdown nach Klasse einschränken. +

+
+ ); +} + +function Th({ label, onClick }: { label: string; onClick: () => void }): ReactElement { + return ( + + {label} + + ); +} +function Td({ children, className = "" }: { children: ReactNode; className?: string }): ReactElement { + return {children}; +} diff --git a/docker-compose/logintrainer/src/app/teacher/stats/page.tsx b/docker-compose/logintrainer/src/app/teacher/stats/page.tsx new file mode 100644 index 0000000..b270ff9 --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/stats/page.tsx @@ -0,0 +1,139 @@ +import type { ReactElement } from "react"; +import { prisma } from "@/lib/prisma"; +import AuthGate from "../logs/AuthGate"; +import StatsTable from "./StatsTable"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; // kein Cache +export const runtime = "nodejs"; // sicher nicht Edge + +function startOfTodayMinusDays(days: number): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - days); + return d; +} + +export default async function StatsPage(): Promise { + const since = startOfTodayMinusDays(5); // inkl. heute = 6 Tage Fenster + + // 1) Gesamtversuche pro User + const totalByUser = await prisma.loginAttempt.groupBy({ + by: ["username"], + _count: { _all: true }, + }); + + // 2) Erfolgreiche Versuche gesamt pro User + const successByUser = await prisma.loginAttempt.groupBy({ + by: ["username"], + where: { success: true }, + _count: { _all: true }, + }); + + // 3) Erfolgreiche Versuche der letzten 6 Tage (einfach count, NICHT uniq-Tage) + const recentSuccessRows = await prisma.loginAttempt.findMany({ + where: { success: true, createdAt: { gte: since } }, + select: { username: true, createdAt: true }, + }); + + // 4) Letzte bekannte Klasse pro User (neueste className != null) + const classRows = await prisma.loginAttempt.findMany({ + where: { className: { not: null } }, + select: { username: true, className: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + }); + + // 4b) Dauer-Auswertung (nur erfolgreiche Versuche mit Dauer) + const durationAgg = await prisma.loginAttempt.groupBy({ + by: ["username"], + where: { success: true, loginDurationMs: { not: null } }, + _min: { loginDurationMs: true }, + _avg: { loginDurationMs: true }, + }); + + // 5) Distinct Klassenliste für Dropdown + const classDistinct = await prisma.loginAttempt.findMany({ + where: { className: { not: null } }, + distinct: ["className"], + select: { className: true }, + }); + const classes = Array.from(new Set(classDistinct.map(r => r.className!))) + .sort((a, b) => a.localeCompare(b, "de", { sensitivity: "base" })); + + // Maps bauen + const mapTotal = new Map(); + totalByUser.forEach(r => mapTotal.set(r.username, r._count._all)); + + const mapSucc = new Map(); + successByUser.forEach(r => mapSucc.set(r.username, r._count._all)); + + const mapRecentSuccCount = new Map(); + const mapDaysWithSucc = new Map>(); + for (const r of recentSuccessRows) { + mapRecentSuccCount.set(r.username, (mapRecentSuccCount.get(r.username) || 0) + 1); + const day = r.createdAt.toISOString().slice(0, 10); + if (!mapDaysWithSucc.has(r.username)) mapDaysWithSucc.set(r.username, new Set()); + mapDaysWithSucc.get(r.username)!.add(day); + } + + const mapClass = new Map(); + for (const r of classRows) { + if (!mapClass.has(r.username)) { + mapClass.set(r.username, r.className!); + } + } + + const mapFastest = new Map(); + const mapAvg = new Map(); + for (const r of durationAgg) { + if (typeof r._min.loginDurationMs === "number") { + mapFastest.set(r.username, r._min.loginDurationMs); + } + if (typeof r._avg.loginDurationMs === "number" && !Number.isNaN(r._avg.loginDurationMs)) { + mapAvg.set(r.username, r._avg.loginDurationMs); + } + } + + // Alle Usernamen zusammensammeln (Union) + const usernames = new Set(); + for (const k of mapTotal.keys()) usernames.add(k); + for (const k of mapSucc.keys()) usernames.add(k); + for (const r of recentSuccessRows) usernames.add(r.username); + for (const r of classRows) usernames.add(r.username); + + const stats = Array.from(usernames).map(u => { + const total = mapTotal.get(u) || 0; + const success = mapSucc.get(u) || 0; + const successLast6 = mapRecentSuccCount.get(u) || 0; + const fail = total - success; + const daysWith = mapDaysWithSucc.get(u)?.size ?? 0; // 0..6 + const percent = Math.round((daysWith / 6) * 100); + const className = mapClass.get(u) ?? null; + const fastestMs = mapFastest.get(u) ?? null; + const avgMs = mapAvg.get(u) ?? null; + + return { + username: u, + className, + total, + success, + successLast6, + fail, + percent, + fastestMs, + avgMs, + }; + }); + + // Optional: alphabetisch starten + stats.sort((a, b) => a.username.localeCompare(b.username, "de", { sensitivity: "base" })); + + return ( + +
+

Nutzungs-Statistik

+ +
+
+ ); +} diff --git a/docker-compose/logintrainer/src/app/teacher/violaters/ViolatersTable.tsx b/docker-compose/logintrainer/src/app/teacher/violaters/ViolatersTable.tsx new file mode 100644 index 0000000..214d0f1 --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/violaters/ViolatersTable.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useMemo, useState, type ReactElement } from "react"; + +export type ViolaterTableRow = { + username: string; + className: string | null; + latestLoginIso: string; + attempts: number; + shortestPassword: number; + violations: string[]; +}; + +type SortKey = keyof ViolaterTableRow; +type SortDirection = "asc" | "desc"; + +const SORTABLE_COLUMNS: SortKey[] = [ + "username", + "className", + "latestLoginIso", + "attempts", + "shortestPassword", +]; + +type ViolatersTableProps = { + minLength: number; + rows: ViolaterTableRow[]; +}; + +export default function ViolatersTable({ minLength, rows }: ViolatersTableProps): ReactElement { + const [usernameFilter, setUsernameFilter] = useState(""); + const [classFilter, setClassFilter] = useState(""); + const [sortKey, setSortKey] = useState("latestLoginIso"); + const [direction, setDirection] = useState("desc"); + + const filtered = useMemo(() => { + const usernameNeedle = usernameFilter.trim().toLowerCase(); + const classNeedle = classFilter.trim().toLowerCase(); + return rows.filter((row) => { + const matchesUser = usernameNeedle.length === 0 + || row.username.toLowerCase().includes(usernameNeedle); + const matchesClass = classNeedle.length === 0 + || (row.className ?? "").toLowerCase().includes(classNeedle); + return matchesUser && matchesClass; + }); + }, [rows, usernameFilter, classFilter]); + + const sorted = useMemo(() => { + const dir = direction === "asc" ? 1 : -1; + const copy = [...filtered]; + copy.sort((a, b) => { + const av = a[sortKey] ?? ""; + const bv = b[sortKey] ?? ""; + if (sortKey === "latestLoginIso") { + return (new Date(av as string).getTime() - new Date(bv as string).getTime()) * dir; + } + if (typeof av === "number" && typeof bv === "number") { + return (av - bv) * dir; + } + return String(av ?? "").localeCompare(String(bv ?? ""), "de", { sensitivity: "base" }) * dir; + }); + return copy; + }, [filtered, direction, sortKey]); + + const toggleSort = (key: SortKey): void => { + if (!SORTABLE_COLUMNS.includes(key)) return; + setSortKey(key); + setDirection((prev) => (prev === "asc" ? "desc" : "asc")); + }; + + return ( +
+ {rows.length === 0 ? ( +
+ Keine Verstöße gefunden – alle Passwörter erfüllen aktuell die Mindestlänge. +
+ ) : ( +
+
+
+ Benutzername + setUsernameFilter(e.currentTarget.value)} + className="mt-1 w-48 rounded border border-emerald-400/30 bg-transparent px-2 py-1 font-mono text-emerald-100" + placeholder="Filter..." + /> +
+
+ Klasse + setClassFilter(e.currentTarget.value)} + className="mt-1 w-44 rounded border border-emerald-400/30 bg-transparent px-2 py-1 text-emerald-100" + placeholder="Filter..." + /> +
+
+ {sorted.length} Einträge · Mindestlänge {minLength} +
+
+ + + + + + Benutzername + + + Klasse + + + Zuletzt + + + Anzahl + + + Kürzestes Passwort + + + + + + {sorted.map((row) => ( + + + + + + + + + ))} + +
Policy-Verstoß
{row.username}{row.className ?? "–"} + {new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(row.latestLoginIso))} + {row.attempts}{row.shortestPassword} Zeichen + {row.violations.length > 0 ? row.violations.join(" · ") : "—"} +
+
+ )} +
+ ); +} + +function SortableHeader({ + children, + column, + sortKey, + direction, + onClick, +}: { + children: ReactElement | string; + column: SortKey; + sortKey: SortKey; + direction: SortDirection; + onClick: (key: SortKey) => void; +}): ReactElement { + const active = sortKey === column; + return ( + + + + ); +} diff --git a/docker-compose/logintrainer/src/app/teacher/violaters/page.tsx b/docker-compose/logintrainer/src/app/teacher/violaters/page.tsx new file mode 100644 index 0000000..65e58e1 --- /dev/null +++ b/docker-compose/logintrainer/src/app/teacher/violaters/page.tsx @@ -0,0 +1,125 @@ +import type { Metadata } from "next"; +import type { ReactElement } from "react"; +import { prisma } from "@/lib/prisma"; +import AuthGate from "@/app/teacher/logs/AuthGate"; +import ViolatersTable, { type ViolaterTableRow } from "./ViolatersTable"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; +export const runtime = "nodejs"; + +function getRequiredPasswordLength(): number { + const candidates: Array = [ + process.env.PASSWORD_POLICY_MIN_LENGTH, + ]; + for (const raw of candidates) { + if (!raw) continue; + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 14; +} + +function startDate(days: number): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - days); + return d; +} + +export const metadata: Metadata = { + title: "Passwort-Verstöße · Teacher", + description: "Übersicht über Logins mit zu kurzen Passwörtern in den letzten sieben Tagen.", +}; + +export default async function ViolatersPage(): Promise { + const minLength = getRequiredPasswordLength(); + const since = startDate(7); + + const rows = await prisma.loginAttempt.findMany({ + where: { + success: true, + createdAt: { gte: since }, + OR: [ + { passwordLength: { lt: minLength } }, + { passwordLength: null }, + { passwordHasUpper: false }, + { passwordHasLower: false }, + { passwordHasDigit: false }, + { passwordHasSpecial: false }, + { passwordPolicyViolation: true }, + ], + }, + select: { + username: true, + className: true, + createdAt: true, + passwordLength: true, + passwordHasUpper: true, + passwordHasLower: true, + passwordHasDigit: true, + passwordHasSpecial: true, + passwordPolicyViolation: true, + }, + orderBy: { createdAt: "desc" }, + }); + + const grouped = new Map(); + for (const row of rows) { + const key = row.username; + const passwordLength = row.passwordLength ?? 0; + const existing = grouped.get(key); + const violationsForAttempt: string[] = []; + if (passwordLength < minLength) violationsForAttempt.push(`Länge < ${minLength}`); + if (!row.passwordHasUpper) violationsForAttempt.push("Großbuchstaben"); + if (!row.passwordHasLower) violationsForAttempt.push("Kleinbuchstaben"); + if (!row.passwordHasDigit) violationsForAttempt.push("Ziffern"); + if (!row.passwordHasSpecial) violationsForAttempt.push("Sonderzeichen"); + if (row.passwordPolicyViolation && violationsForAttempt.length === 0) { + violationsForAttempt.push("Policy-Verstoß"); + } + + if (!existing) { + grouped.set(key, { + username: key, + className: row.className ?? null, + latestLoginIso: row.createdAt.toISOString(), + attempts: 1, + shortestPassword: passwordLength, + violations: violationsForAttempt, + }); + } else { + existing.attempts += 1; + existing.shortestPassword = Math.min(existing.shortestPassword, passwordLength); + if (row.createdAt > new Date(existing.latestLoginIso)) { + existing.latestLoginIso = row.createdAt.toISOString(); + existing.className = row.className ?? existing.className; + } + const merged = new Set(existing.violations); + violationsForAttempt.forEach((v) => merged.add(v)); + existing.violations = Array.from(merged); + } + } + + const violaters = Array.from(grouped.values()).sort( + (a, b) => new Date(b.latestLoginIso).getTime() - new Date(a.latestLoginIso).getTime() + ); + + return ( + +
+
+

+ Passwort-Verstöße – letzte 7 Tage +

+

+ Mindestlänge laut Richtlinie:{" "} + {minLength} Zeichen +

+
+ + +
+
+ ); +} diff --git a/docker-compose/logintrainer/src/components/AutoRefresh.tsx b/docker-compose/logintrainer/src/components/AutoRefresh.tsx new file mode 100644 index 0000000..a16dece --- /dev/null +++ b/docker-compose/logintrainer/src/components/AutoRefresh.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +type AutoRefreshProps = { + intervalMs?: number; +}; + +export default function AutoRefresh({ intervalMs = 15000 }: AutoRefreshProps): null { + const router = useRouter(); + + useEffect(() => { + if (intervalMs <= 0) return undefined; + const id = setInterval(() => { + router.refresh(); + }, intervalMs); + return () => { + clearInterval(id); + }; + }, [intervalMs, router]); + + return null; +} diff --git a/docker-compose/logintrainer/src/components/NetworkBackground.tsx b/docker-compose/logintrainer/src/components/NetworkBackground.tsx new file mode 100644 index 0000000..4247feb --- /dev/null +++ b/docker-compose/logintrainer/src/components/NetworkBackground.tsx @@ -0,0 +1,167 @@ +"use client"; +import { useEffect } from "react"; + +type Props = { + className?: string; // wird ignoriert, wir setzen Styles direkt + density?: number; // 0.00004–0.00012 + connectDistance?: number; // 80–200 + influenceRadius?: number; // 120–300 + magneticStrength?: number; // 0–1 + color?: string; // CSS-Farbe +}; + +type Node = { + x:number; y:number; + vx:number; vy:number; + ax:number; ay:number; + ax0:number; ay0:number; +}; + +export default function NetworkBackground({ + density = 0.00007, + connectDistance = 130, + influenceRadius = 220, + magneticStrength = 0.8, + color = "rgba(16,185,129,1)", // emerald-500 +}: Props): null { + useEffect(() => { + const prefersReduced = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; + const isSmallViewport = window.matchMedia?.("(max-width: 640px)")?.matches ?? false; + + if (isSmallViewport) { + // Auf sehr kleinen Viewports den Canvas gar nicht erst anlegen. + return () => {}; + } + + // 1) Canvas *direkt* an anhängen + const canvas = document.createElement("canvas"); + canvas.setAttribute("aria-hidden", "true"); + Object.assign(canvas.style, { + position: "fixed", + inset: "0px", + zIndex: "0", // Inhalt bekommt z-10 o.ä. + pointerEvents: "none", + } as CSSStyleDeclaration); + document.body.appendChild(canvas); + + const ctx = canvas.getContext("2d")!; + let width = 0, height = 0 + const dpr = Math.max(1, window.devicePixelRatio || 1); + let nodes: Node[] = []; + let raf = 0; + let running = true; + let mouseX: number | null = null; + let mouseY: number | null = null; + + function spawn(): Node { + const x = Math.random() * width; + const y = Math.random() * height; + return { x, y, vx:(Math.random()-0.5)*0.05, vy:(Math.random()-0.5)*0.05, ax:0, ay:0, ax0:x, ay0:y }; + } + + function resize(): void { + const w = Math.floor((window.visualViewport?.width ?? window.innerWidth)); + const h = Math.floor((window.visualViewport?.height ?? window.innerHeight)); + width = w; height = h; + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + canvas.style.width = w + "px"; + canvas.style.height = h + "px"; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const target = Math.min(160, Math.max(60, Math.floor(w * h * density))); + if (nodes.length === 0) nodes = Array.from({ length: target }, spawn); + else if (nodes.length < target) nodes.push(...Array.from({ length: target - nodes.length }, spawn)); + else nodes.length = target; + } + + function drawFrame(): void { + const springK = 0.015, damping = 0.96, maxSpeed = 1.2; + const r2 = influenceRadius * influenceRadius; + + ctx.clearRect(0,0,width,height); + + for (const n of nodes) { + // Rückstellkraft + const dx0 = n.ax0 - n.x, dy0 = n.ay0 - n.y; + n.ax = dx0 * springK; n.ay = dy0 * springK; + + // Maus-Magnet + if (mouseX !== null && mouseY !== null) { + const dx = mouseX - n.x, dy = mouseY - n.y; + const d2 = dx*dx + dy*dy; + if (d2 < r2) { + const inv = 1 / Math.max(40, d2); + const f = magneticStrength * 1200 * inv; + n.ax += dx * f; n.ay += dy * f; + } + } + + n.vx = (n.vx + n.ax) * damping; + n.vy = (n.vy + n.ay) * damping; + + const sp2 = n.vx*n.vx + n.vy*n.vy; + if (sp2 > maxSpeed*maxSpeed) { + const s = maxSpeed / Math.sqrt(sp2); + n.vx *= s; n.vy *= s; + } + + n.x += n.vx; n.y += n.vy; + if (n.x<0){n.x=0;n.vx*=-0.5;} if (n.y<0){n.y=0;n.vy*=-0.5;} + if (n.x>width){n.x=width;n.vx*=-0.5;} if (n.y>height){n.y=height;n.vy*=-0.5;} + } + + // Linien + ctx.lineWidth = 1; ctx.shadowBlur = 6; ctx.shadowColor = color; ctx.strokeStyle = color; + const cd2 = connectDistance*connectDistance; + for (let i=0;i { + running = false; + cancelAnimationFrame(raf); + window.removeEventListener("resize", resize); + (window.visualViewport)?.removeEventListener("resize", resize); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseleave", onLeave); + // Canvas sauber entfernen + canvas.remove(); + }; + }, [color, density, connectDistance, influenceRadius, magneticStrength]); + + // Kein sichtbarer JSX-Knoten nötig + return null; +} diff --git a/docker-compose/logintrainer/src/lib/auth.ts b/docker-compose/logintrainer/src/lib/auth.ts new file mode 100644 index 0000000..11a96b5 --- /dev/null +++ b/docker-compose/logintrainer/src/lib/auth.ts @@ -0,0 +1,146 @@ +import { Client } from "ldapts"; +import { logError } from "@/lib/logger"; + +/** Ergebnis für AD-Auth + Attribute */ +export type AdAuthResult = { + ok: boolean; + dn?: string; + displayName?: string | null; + className?: string | null; // sophomorixAdminClass + error?: string; +}; + +function req(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing env ${name}`); + return v; +} + +/** Repräsentiert einen generischen LDAP-Sucheintrag. */ +type LdapEntry = Record & { dn?: string }; + +/** Liest ein string-Attribut robust aus einem LDAP-Eintrag. */ +function getStr(entry: LdapEntry | undefined, key: string): string | null { + if (!entry) return null; + const v = entry[key]; + if (typeof v === "string") return v; + if (Array.isArray(v)) { + const s = (v as unknown[]).find((x): x is string => typeof x === "string"); + return s ?? null; + } + return null; +} + +export async function adAuthenticate(usernameInput: string, password: string): Promise { + const url = req("LDAP_URL"); + const baseDN = req("LDAP_BASE_DN"); + const bindDN = process.env.LDAP_BIND_DN || ""; + const bindPW = process.env.LDAP_BIND_PW || ""; + const domainPrefix = process.env.LDAP_DOMAIN_PREFIX || ""; // z.B. "SCHULE" + + const client = new Client({ + url, + timeout: 5000, + connectTimeout: 5000, + tlsOptions: { rejectUnauthorized: true }, + }); + + let userDN = ""; + let displayName: string | null = null; + let className: string | null = null; + + try { + if (bindDN && bindPW) { + // 1) Suchen mit Service-Account (Attribute holen) + await client.bind(bindDN, bindPW); + + const { searchEntries } = await client.search(baseDN, { + scope: "sub", + filter: `(sAMAccountName=${usernameInput})`, + attributes: ["dn", "displayName", "givenName", "cn", "sophomorixAdminClass"], + sizeLimit: 2, + }); + + if (!searchEntries.length) return { ok: false, error: "user_not_found" }; + + const e = (searchEntries[0] ?? {}) as LdapEntry; + userDN = getStr(e, "dn") ?? ""; // bei ldapts ist dn meist direkt als string vorhanden + displayName = getStr(e, "displayName") ?? getStr(e, "givenName") ?? getStr(e, "cn"); + className = getStr(e, "sophomorixAdminClass"); + + // 2) Passwortprüfung via Simple-Bind als User + await client.bind(userDN, password); + return { ok: true, dn: userDN, displayName, className }; + } else { + // Kein Service-Account: direkt Simple-Bind per DOMAIN\user oder plain + const bindUser = domainPrefix ? `${domainPrefix}\\${usernameInput}` : usernameInput; + await client.bind(bindUser, password); + + // Nach Erfolg versuchen, Attribute zu lesen (wenn erlaubt) + try { + const { searchEntries } = await client.search(baseDN, { + scope: "sub", + filter: `(sAMAccountName=${usernameInput})`, + attributes: ["displayName", "givenName", "cn", "sophomorixAdminClass"], + sizeLimit: 1, + }); + const e = (searchEntries?.[0] ?? {}) as LdapEntry; + displayName = getStr(e, "displayName") ?? getStr(e, "givenName") ?? getStr(e, "cn"); + className = getStr(e, "sophomorixAdminClass"); + } catch (attributeLookupError: unknown) { + logError("[adAuthenticate] LDAP attribute lookup failed", attributeLookupError); + } + return { ok: true, dn: bindUser, displayName, className }; + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, error: msg }; + } finally { + try { + await client.unbind(); + } catch (unbindError: unknown) { + logError("[adAuthenticate] LDAP unbind failed", unbindError); + } + } +} + +/** Nur den Anzeigenamen aus AD holen (für die Erfolgsseite). */ +export async function adLookupDisplayName(usernameInput: string): Promise { + const url = req("LDAP_URL"); + const baseDN = req("LDAP_BASE_DN"); + const bindDN = process.env.LDAP_BIND_DN || ""; + const bindPW = process.env.LDAP_BIND_PW || ""; + + if (!bindDN || !bindPW) return null; // ohne Service-Account meist kein Leserecht + + const client = new Client({ + url, + timeout: 5000, + connectTimeout: 5000, + tlsOptions: { rejectUnauthorized: true }, + }); + + try { + await client.bind(bindDN, bindPW); + + const { searchEntries } = await client.search(baseDN, { + scope: "sub", + filter: `(sAMAccountName=${usernameInput})`, + attributes: ["displayName", "givenName", "cn"], + sizeLimit: 2, + }); + + if (!searchEntries.length) return null; + const e = (searchEntries[0] ?? {}) as LdapEntry; + + return getStr(e, "displayName") ?? getStr(e, "givenName") ?? getStr(e, "cn"); + } catch { + return null; + } finally { + try { + await client.unbind(); + } catch (unbindError: unknown) { + logError("[adLookupDisplayName] LDAP unbind failed", unbindError); + } + } +} diff --git a/docker-compose/logintrainer/src/lib/logger.ts b/docker-compose/logintrainer/src/lib/logger.ts new file mode 100644 index 0000000..5edaca5 --- /dev/null +++ b/docker-compose/logintrainer/src/lib/logger.ts @@ -0,0 +1,19 @@ +type ErrorPayload = + | { name: string; message: string; stack?: string } + | { value: string }; + +export function logError(message: string, error: unknown): void { + const formattedError: ErrorPayload = + error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : { value: String(error) }; + + const entry = JSON.stringify({ + level: "error", + message, + error: formattedError, + timestamp: new Date().toISOString(), + }); + + process.stderr.write(`${entry}\n`); +} diff --git a/docker-compose/logintrainer/src/lib/prisma.ts b/docker-compose/logintrainer/src/lib/prisma.ts new file mode 100644 index 0000000..ace90dc --- /dev/null +++ b/docker-compose/logintrainer/src/lib/prisma.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from "@prisma/client"; + +const globalForPrisma = global as unknown as { prisma: PrismaClient }; + +export const prisma = + globalForPrisma.prisma || + new PrismaClient({ + log: ["query", "error", "warn"], + }); + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; diff --git a/docker-compose/logintrainer/src/styles/globals.css b/docker-compose/logintrainer/src/styles/globals.css new file mode 100644 index 0000000..01e84f6 --- /dev/null +++ b/docker-compose/logintrainer/src/styles/globals.css @@ -0,0 +1,442 @@ +@import "./tailwind.generated.css"; + +/* ===== MORZ Diceware/Hacker Theme (Tailwind-friendly) ===== */ +:root{ + --dw-bg-1:#051014; + --dw-bg-2:#071b1f; + --dw-fg:#e6fff6; + --dw-muted:#97b7aa; + --dw-accent:#59f0a8; /* neon green */ + --dw-accent-2:#7ff7c6; /* lighter tone */ + --dw-danger:#ff8b8b; +} + +/* Global background + monospace */ +html,body{ height:100%; } +body{ + background: + radial-gradient(1200px 600px at 10% 8%, rgba(89,240,168,0.06), transparent 12%), + radial-gradient(1000px 500px at 90% 92%, rgba(89,240,168,0.04), transparent 14%), + linear-gradient(180deg,var(--dw-bg-1),var(--dw-bg-2) 60%); + color:var(--dw-fg); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; +} + +/* Header like on MORZ Diceware */ +.dw-header{ + display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px; +} +.dw-brand{ + color:var(--dw-accent); font-weight:700; letter-spacing:.4px; display:flex; gap:10px; align-items:center; +} +.dw-badge{ + font-size:.82rem; color:var(--dw-accent); + background:rgba(89,240,168,.10); + border:1px solid rgba(89,240,168,.18); + padding:6px 10px; border-radius:999px; +} + +/* Card container */ +.dw-card{ + --dw-card-max: 520px; + position:relative; + background:linear-gradient(80deg,rgba(255,255,255,.052),rgba(255,255,255,.1401)); + border:3px solid rgba(89,240,168,.14); + border-radius:14px; padding:18px; + box-shadow:0 50px 200px rgba(0,255,0,.75); + max-width: min(var(--dw-card-max), 100%); + width:100%; + margin:0 auto; /* center */ + box-sizing:border-box; + overflow: hidden; /* keep focus glow contained */ +} +.dw-card--wide{ + --dw-card-max: 960px; +} +@media (max-width: 1024px){ + .dw-card--wide{ --dw-card-max: min(92vw, 680px); } +} + +/* Subtle scanlines overlay */ +.dw-card::after{ + content:""; + pointer-events:none; + position:absolute; inset:0; + background-image: linear-gradient(rgba(255,255,255,0.006) 1px, transparent 1px); + background-size: 100% 18px; + border-radius: inherit; +} + +/* Head spacing */ +.dw-card-head{ margin-bottom:0.75rem; } + +/* Input fields — very clear */ +.dw-field{ position:relative; margin-top:0; } /* no margin-top; we use stack gaps */ + +.dw-input{ + width:100%; + padding:12px 12px; + border-radius:10px; + border:1px solid rgba(89,240,168,.22); + background: rgba(8,27,31,.65); + color:var(--dw-fg); + outline:none; + transition: box-shadow .12s, border-color .12s, transform .06s; + box-sizing: border-box; /* important: prevent overflow */ +} +.dw-input::placeholder{ color: rgba(230,255,246,.35); } +.dw-input:focus{ + border-color: var(--dw-accent-2); + box-shadow: 0 0 0 3px rgba(89,240,168,.18), 0 10px 28px rgba(89,240,168,.10); + transform: translateY(-1px); +} + +/* Password toggle INSIDE the field (right) */ +.dw-toggle{ + position:absolute; + right:10px; + top:50%; + transform: translateY(-50%); + background: transparent; + color: var(--dw-accent); + border:1px solid rgba(89,240,168,.20); + padding:4px 8px; + border-radius:8px; + font-size:.85rem; +} +.dw-input--with-toggle{ padding-right:88px; } + +/* Buttons */ +.dw-btn{ + display:inline-flex; align-items:center; gap:8px; + padding:10px 14px; border-radius:10px; border:1px solid rgba(255,255,255,.06); + background:linear-gradient(90deg,var(--dw-accent), var(--dw-accent-2)); + color:#072319; font-weight:800; cursor:pointer; + transition: transform .08s, filter .12s; + white-space:nowrap; +} +.dw-btn:active{ transform: translateY(1px) scale(.995); } +.dw-btn-ghost{ + background:transparent; color:var(--dw-accent); + border:1px solid rgba(89,240,168,.20); + white-space:nowrap; +} + +/* Pills */ +.dw-pill{ padding:6px 10px; border-radius:999px; font-size:.8rem; font-weight:700; } +.dw-pill-ok{ background:rgba(125,240,178,.12); color:#7ef0b2; border:1px solid rgba(125,240,178,.18); } +.dw-pill-bad{ background:rgba(255,139,139,.08); color:var(--dw-danger); border:1px solid rgba(255,139,139,.18); } + +/* Hint text */ +.dw-hint{ color:var(--dw-muted); font-size:.9rem; } + +/* Simple vertical stack helpers (Tailwind-ish) */ +.dw-stack{ display:flex; flex-direction:column; gap:0.75rem; } /* ~ space-y-3 */ +.dw-stack-lg{ gap:1rem; } + +/* Responsive nits */ +@media (max-width:640px){ + .dw-card{ padding:14px; border-radius:12px; } + .dw-toggle{ right:8px; } +} +/* --- Spacing Helpers (größere Gaps) --- */ +.dw-stack { display:flex; flex-direction:column; gap: 1rem; } /* ~ space-y-4 */ +.dw-stack-lg { gap: 1.25rem; } /* ~ space-y-5 */ +.dw-stack-xl { gap: 1.75rem; } /* ~ space-y-7 */ + +/* optional: Karte innen etwas großzügiger */ +@media (max-width:640px){ + .dw-card{ padding:18px; } /* auf Mobile wieder etwas kompakter */ +} + +/* --- Stabiler Card-Container & Focus-Clipping --- */ +.dw-card { + max-width: min(var(--dw-card-max, 520px), 100%); + width: 100%; + margin: 0 auto; + box-sizing: border-box; + overflow: hidden; /* Glow bleibt in der Karte */ + padding: 22px; /* etwas mehr Luft innen */ +} + +/* --- Verlässliches Vertical-Stacking (statt mt/mb-Gefrickel) --- */ +.dw-stack { display:flex; flex-direction:column; gap: 1rem; } /* ~ space-y-4 */ +.dw-stack-lg { gap: 1.25rem; } /* ~ space-y-5 */ +.dw-stack-xl { gap: 1.75rem; } /* ~ space-y-7 */ + +/* Head-Bereich der Karte bekommt Grundluft */ +.dw-card-head { margin-bottom: 0.75rem; } + +/* --- Input robust & im Rahmen --- */ +.dw-field { position: relative; margin-top: 0; } /* eigenes MT raus */ +.dw-input { box-sizing: border-box; } /* verhindert Überlauf */ + +/* Toggle sitzt IM Feld, Text kollidiert nicht */ +.dw-toggle { + position: absolute; right: 10px; top: 50%; transform: translateY(-50%); +} +.dw-input--with-toggle { padding-right: 88px; } + +/* Buttons umbrechen nicht kaputt, Hidden respektieren */ +.dw-btn, .dw-btn-ghost { white-space: nowrap; } +.dw-btn.hidden, .dw-btn-ghost.hidden { display: none !important; } /* falls du doch mal hidden nutzt */ + +/* --- Tiles on success page --- */ +.dw-tiles { display:grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 1rem; } +@media (max-width: 640px){ .dw-tiles { grid-template-columns: 1fr; } } + +.dw-tile{ + background: var(--dw-tile-bg, linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01))); + border: 1px solid var(--dw-tile-border, rgba(89,240,168,.14)); + border-radius: 14px; + padding: 18px; + text-align: inherit; + box-shadow: 0 8px 26px rgba(0,0,0,.35); +} +.dw-tile-title{ + color: var(--dw-accent); + font-weight: 700; + letter-spacing: .3px; + margin-bottom: .25rem; +} +.dw-tile-number{ + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 900; + line-height: 1; + color: var(--dw-fg); + font-size: clamp(2.25rem, 4vw, 3.5rem); /* groß, responsiv */ + white-space: nowrap; +} +.dw-tile-number--better{ + color: #5be7ac; + text-shadow: 0 0 18px rgba(91, 231, 172, 0.25); +} +.dw-tile-number--slower{ + color: #ffb3b3; + text-shadow: 0 0 18px rgba(255, 66, 108, 0.2); +} +.dw-tile-sub{ + color: var(--dw-muted); + margin-top: .3rem; +} +.dw-tile-peer{ + margin-top: .35rem; + font-size: 0.75rem; + color: rgba(226, 255, 244, 0.7); + font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + letter-spacing: 0.02em; +} +.dw-stats-layout{ + display:grid; + grid-template-columns: minmax(0,1.1fr) minmax(0,0.9fr); + gap:1.5rem; + align-items:stretch; +} +.dw-stats-layout > *{ min-width:0; } +.dw-stats-tiles{ align-content:stretch; } +.dw-stats-chart{ min-height:0; height:100%; } +@media (max-width: 900px){ + .dw-stats-layout{ grid-template-columns: 1fr; } + .dw-card--wide{ --dw-card-max: min(94vw, 640px); } +} +.glow-red{ + color:#ff8b8b; + text-shadow: + 0 0 6px rgba(255,139,139,.55), + 0 0 14px rgba(255,139,139,.35); +} + +.dw-input[data-mask="true"] { + -webkit-text-security: disc; /* Chrome/Safari/Edge */ + text-security: disc; /* nicht standardisiert; einige Browser */ +} +.dw-mask { + -webkit-text-security: disc; /* Chrome/Safari/Edge */ + text-security: disc; /* (non-standard, manche Browser) */ +} +.teacher-shell{ + min-height: 100vh; + background: #f5f7fb; + padding: clamp(3rem, 5vw, 5rem) clamp(1.5rem, 4vw, 4rem); + box-sizing: border-box; +} +.teacher-shell__inner{ + max-width: 1100px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: clamp(2.5rem, 5vw, 3.75rem); +} +.teacher-shell__ribbon{ + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 1rem; + border-radius: 999px; + background: #dcfce7; + border: 1px solid #bbf7d0; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.28em; + color: #047857; + font-weight: 600; +} +.teacher-shell__title{ + margin-top: 1.5rem; + font-size: clamp(2rem, 4vw, 2.6rem); + color: #0f172a; + font-weight: 700; +} +.teacher-shell__subtitle{ + margin-top: 0.75rem; + max-width: 620px; + color: #475569; + line-height: 1.6; + font-size: clamp(1rem, 2.3vw, 1.1rem); +} +.teacher-grid{ + display: grid; + gap: 1.25rem; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); +} +.teacher-card{ + display: flex; + flex-direction: column; + gap: 0.85rem; + background: #ffffff; + border-radius: 14px; + padding: 1.3rem; + border: 1px solid #e2e8f0; + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08); + transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; + text-decoration: none; + color: inherit; +} +.teacher-card:hover{ + transform: translateY(-4px); + border-color: #bae6fd; + box-shadow: 0 22px 45px rgba(15, 23, 42, 0.12); +} +.teacher-card__accent{ + height: 6px; + width: 56px; + border-radius: 999px; + background: linear-gradient(90deg,#34d399,#6ee7b7); +} +.teacher-card[data-accent="emerald"] .teacher-card__accent{ background: linear-gradient(90deg,#34d399,#6ee7b7); } +.teacher-card[data-accent="amber"] .teacher-card__accent{ background: linear-gradient(90deg,#facc15,#fb923c); } +.teacher-card[data-accent="violet"] .teacher-card__accent{ background: linear-gradient(90deg,#a855f7,#ec4899); } +.teacher-card[data-accent="rose"] .teacher-card__accent{ background: linear-gradient(90deg,#fb7185,#ef4444); } +.teacher-card[data-accent="sky"] .teacher-card__accent{ background: linear-gradient(90deg,#38bdf8,#3b82f6); } +.teacher-card__icon{ + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 12px; + background: #ecfdf5; + font-size: 1.6rem; +} +.teacher-card__title{ + font-size: 1.05rem; + font-weight: 600; + color: #0f172a; +} +.teacher-card:hover .teacher-card__title{ + color: #047857; +} +.teacher-card__desc{ + font-size: 0.9rem; + line-height: 1.55; + color: #475569; +} +.teacher-card__cta{ + margin-top: auto; + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.28em; + color: #059669; +} +.teacher-card:hover .teacher-card__cta{ + color: #047857; +} + +.teacher-auth{ + min-height: 100vh; + background: #f5f7fb; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(3rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem); + box-sizing: border-box; +} +.teacher-auth__card{ + width: min(360px, 100%); + background: #ffffff; + border-radius: 16px; + padding: 1.8rem; + box-shadow: 0 22px 48px rgba(15, 23, 42, 0.12); + border: 1px solid #e2e8f0; +} +.teacher-auth__header{ + text-align: center; + margin-bottom: 1.5rem; +} +.teacher-auth__badge{ + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.28em; + color: #22c55e; +} +.teacher-auth__title{ + margin-top: 0.85rem; + font-size: 1.5rem; + font-weight: 600; + color: #0f172a; +} +.teacher-auth__text{ + margin-top: 0.5rem; + font-size: 0.9rem; + color: #475569; +} +.teacher-auth__input{ + width: 100%; + border-radius: 12px; + border: 1px solid #cbd5f5; + padding: 0.75rem 1rem; + font-size: 0.95rem; + color: #0f172a; + background: #ffffff; + transition: border-color .18s; +} +.teacher-auth__input:focus{ + outline: none; + border-color: #38bdf8; + box-shadow: 0 0 0 3px rgba(56,189,248,0.2); +} +.teacher-auth__button{ + display: inline-flex; + width: 100%; + justify-content: center; + align-items: center; + border-radius: 12px; + padding: 0.75rem; + background: linear-gradient(90deg,#22c55e,#0ea5e9); + color: #ffffff; + font-weight: 600; + transition: filter .18s; + border: none; +} +.teacher-auth__button:hover{ + filter: brightness(1.05); +} +.teacher-auth__error{ + color: #dc2626; + font-size: 0.85rem; + text-align: center; +} diff --git a/docker-compose/logintrainer/src/styles/tailwind.generated.css b/docker-compose/logintrainer/src/styles/tailwind.generated.css new file mode 100644 index 0000000..fed935e --- /dev/null +++ b/docker-compose/logintrainer/src/styles/tailwind.generated.css @@ -0,0 +1,621 @@ +/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ +@layer properties; +.absolute { + position: absolute; +} +.fixed { + position: fixed; +} +.relative { + position: relative; +} +.static { + position: static; +} +.z-10 { + z-index: 10; +} +.z-20 { + z-index: 20; +} +.z-50 { + z-index: 50; +} +.container { + width: 100%; +} +.mx-auto { + margin-inline: auto; +} +.mt-auto { + margin-top: auto; +} +.ml-auto { + margin-left: auto; +} +.block { + display: block; +} +.contents { + display: contents; +} +.flex { + display: flex; +} +.grid { + display: grid; +} +.inline-block { + display: inline-block; +} +.inline-flex { + display: inline-flex; +} +.table { + display: table; +} +.h-full { + height: 100%; +} +.min-h-\[calc\(100vh-6rem\)\] { + min-height: calc(100vh - 6rem); +} +.min-h-screen { + min-height: 100vh; +} +.min-h-svh { + min-height: 100svh; +} +.w-\[90\%\] { + width: 90%; +} +.w-\[220px\] { + width: 220px; +} +.w-full { + width: 100%; +} +.min-w-full { + min-width: 100%; +} +.flex-1 { + flex: 1; +} +.border-collapse { + border-collapse: collapse; +} +.border-separate { + border-collapse: separate; +} +.transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); +} +.cursor-not-allowed { + cursor: not-allowed; +} +.cursor-pointer { + cursor: pointer; +} +.resize { + resize: both; +} +.flex-col { + flex-direction: column; +} +.flex-wrap { + flex-wrap: wrap; +} +.place-items-center { + place-items: center; +} +.items-center { + align-items: center; +} +.items-end { + align-items: flex-end; +} +.justify-between { + justify-content: space-between; +} +.justify-center { + justify-content: center; +} +.divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } +} +.overflow-hidden { + overflow: hidden; +} +.overflow-x-auto { + overflow-x: auto; +} +.overflow-x-hidden { + overflow-x: hidden; +} +.rounded-full { + border-radius: calc(infinity * 1px); +} +.border { + border-style: var(--tw-border-style); + border-width: 1px; +} +.border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; +} +.border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; +} +.border-\[\#7aa2ff\]\/30 { + border-color: color-mix(in oklab, #7aa2ff 30%, transparent); +} +.border-\[\#7aa2ff\]\/50 { + border-color: color-mix(in oklab, #7aa2ff 50%, transparent); +} +.bg-\[\#7aa2ff\]\/20 { + background-color: color-mix(in oklab, #7aa2ff 20%, transparent); +} +.bg-\[\#07121f\]\/80 { + background-color: color-mix(in oklab, #07121f 80%, transparent); +} +.bg-\[\#08111f\] { + background-color: #08111f; +} +.bg-\[\#10172a\] { + background-color: #10172a; +} +.bg-\[\#081226\]\/80 { + background-color: color-mix(in oklab, #081226 80%, transparent); +} +.bg-\[\#F41a33\]\/95 { + background-color: color-mix(in oklab, #F41a33 95%, transparent); +} +.bg-\[\#ff8b8b\] { + background-color: #ff8b8b; +} +.bg-transparent { + background-color: transparent; +} +.bg-gradient-to-br { + --tw-gradient-position: to bottom right in oklab; + background-image: linear-gradient(var(--tw-gradient-stops)); +} +.bg-gradient-to-r { + --tw-gradient-position: to right in oklab; + background-image: linear-gradient(var(--tw-gradient-stops)); +} +.from-\[\#050b16\] { + --tw-gradient-from: #050b16; + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); +} +.via-\[\#07132b\] { + --tw-gradient-via: #07132b; + --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-via-stops); +} +.to-\[\#04152e\] { + --tw-gradient-to: #04152e; + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); +} +.text-center { + text-align: center; +} +.text-left { + text-align: left; +} +.align-baseline { + vertical-align: baseline; +} +.align-middle { + vertical-align: middle; +} +.align-top { + vertical-align: top; +} +.text-\[0\.7rem\] { + font-size: 0.7rem; +} +.text-\[0\.65rem\] { + font-size: 0.65rem; +} +.leading-none { + --tw-leading: 1; + line-height: 1; +} +.tracking-\[0\.3em\] { + --tw-tracking: 0.3em; + letter-spacing: 0.3em; +} +.tracking-\[0\.22em\] { + --tw-tracking: 0.22em; + letter-spacing: 0.22em; +} +.tracking-\[0\.25em\] { + --tw-tracking: 0.25em; + letter-spacing: 0.25em; +} +.tracking-\[0\.28em\] { + --tw-tracking: 0.28em; + letter-spacing: 0.28em; +} +.tracking-\[0\.32em\] { + --tw-tracking: 0.32em; + letter-spacing: 0.32em; +} +.tracking-\[0\.35em\] { + --tw-tracking: 0.35em; + letter-spacing: 0.35em; +} +.text-\[\#7aa2ff\] { + color: #7aa2ff; +} +.text-\[\#d8ffe3\] { + color: #d8ffe3; +} +.text-\[\#e8ebff\] { + color: #e8ebff; +} +.uppercase { + text-transform: uppercase; +} +.antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.opacity-70 { + opacity: 70%; +} +.opacity-80 { + opacity: 80%; +} +.ring-1 { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} +.filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); +} +.transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, ease); + transition-duration: var(--tw-duration, 0s); +} +.transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, ease); + transition-duration: var(--tw-duration, 0s); +} +.duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; +} +.outline-none { + --tw-outline-style: none; + outline-style: none; +} +.select-none { + -webkit-user-select: none; + user-select: none; +} +.hover\:bg-\[\#7aa2ff\]\/10 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in oklab, #7aa2ff 10%, transparent); + } + } +} +.hover\:underline { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } +} +.hover\:brightness-110 { + &:hover { + @media (hover: hover) { + --tw-brightness: brightness(110%); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + } +} +.focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } +} +.focus-visible\:outline { + &:focus-visible { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } +} +.focus-visible\:outline-2 { + &:focus-visible { + outline-style: var(--tw-outline-style); + outline-width: 2px; + } +} +.focus-visible\:outline-offset-2 { + &:focus-visible { + outline-offset: 2px; + } +} +.disabled\:cursor-not-allowed { + &:disabled { + cursor: not-allowed; + } +} +.disabled\:opacity-60 { + &:disabled { + opacity: 60%; + } +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-gradient-position { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-via { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-to { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-via-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from-position { + syntax: ""; + inherits: false; + initial-value: 0%; +} +@property --tw-gradient-via-position { + syntax: ""; + inherits: false; + initial-value: 50%; +} +@property --tw-gradient-to-position { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-divide-y-reverse: 0; + --tw-border-style: solid; + --tw-gradient-position: initial; + --tw-gradient-from: #0000; + --tw-gradient-via: #0000; + --tw-gradient-to: #0000; + --tw-gradient-stops: initial; + --tw-gradient-via-stops: initial; + --tw-gradient-from-position: 0%; + --tw-gradient-via-position: 50%; + --tw-gradient-to-position: 100%; + --tw-leading: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-duration: initial; + --tw-outline-style: solid; + } + } +} \ No newline at end of file diff --git a/docker-compose/logintrainer/tailwind.config.cjs b/docker-compose/logintrainer/tailwind.config.cjs new file mode 100644 index 0000000..9024638 --- /dev/null +++ b/docker-compose/logintrainer/tailwind.config.cjs @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/docker-compose/logintrainer/tsconfig.json b/docker-compose/logintrainer/tsconfig.json new file mode 100644 index 0000000..76c3a02 --- /dev/null +++ b/docker-compose/logintrainer/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "types": ["node", "react", "react-dom"], + "baseUrl": "src", + "paths": { + "@/*": ["*"] + }, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}