logintrainer
This commit is contained in:
parent
aa88d750f6
commit
ea52d39e6c
55 changed files with 5885 additions and 0 deletions
28
docker-compose/logintrainer/.env
Normal file
28
docker-compose/logintrainer/.env
Normal file
|
|
@ -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
|
||||
60
docker-compose/logintrainer/Dockerfile
Normal file
60
docker-compose/logintrainer/Dockerfile
Normal file
|
|
@ -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"]
|
||||
48
docker-compose/logintrainer/README.md
Normal file
48
docker-compose/logintrainer/README.md
Normal file
|
|
@ -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.
|
||||
16
docker-compose/logintrainer/docker-compose.yml
Normal file
16
docker-compose/logintrainer/docker-compose.yml
Normal file
|
|
@ -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
|
||||
19
docker-compose/logintrainer/docker/entrypoint.sh
Normal file
19
docker-compose/logintrainer/docker/entrypoint.sh
Normal file
|
|
@ -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 "$@"
|
||||
23
docker-compose/logintrainer/eslint.config.mjs
Normal file
23
docker-compose/logintrainer/eslint.config.mjs
Normal file
|
|
@ -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" ],
|
||||
},
|
||||
];
|
||||
6
docker-compose/logintrainer/next-env.d.ts
vendored
Normal file
6
docker-compose/logintrainer/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
9
docker-compose/logintrainer/next.config.mjs
Normal file
9
docker-compose/logintrainer/next.config.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
basePath: "",
|
||||
experimental: {
|
||||
useLightningcss: false,
|
||||
},
|
||||
};
|
||||
export default nextConfig;
|
||||
0
docker-compose/logintrainer/nginx-config.conf
Normal file
0
docker-compose/logintrainer/nginx-config.conf
Normal file
44
docker-compose/logintrainer/package.json
Normal file
44
docker-compose/logintrainer/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
docker-compose/logintrainer/prisma/dev.db
Normal file
BIN
docker-compose/logintrainer/prisma/dev.db
Normal file
Binary file not shown.
|
|
@ -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");
|
||||
|
|
@ -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"
|
||||
42
docker-compose/logintrainer/prisma/schema.prisma
Normal file
42
docker-compose/logintrainer/prisma/schema.prisma
Normal file
|
|
@ -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])
|
||||
}
|
||||
21
docker-compose/logintrainer/scripts/generate-tailwind.cjs
Normal file
21
docker-compose/logintrainer/scripts/generate-tailwind.cjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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<boolean> {
|
||||
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<NextResponse> {
|
||||
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<NextResponse> {
|
||||
return purge(req);
|
||||
}
|
||||
export async function DELETE(req: Request): Promise<NextResponse> {
|
||||
return purge(req);
|
||||
}
|
||||
export async function OPTIONS(): Promise<Response> {
|
||||
// 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
50
docker-compose/logintrainer/src/app/api/cleanup/route.ts
Normal file
50
docker-compose/logintrainer/src/app/api/cleanup/route.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<Response> {
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!isPayload(body)) return NextResponse.json({ ok: false, error: "bad_request" }, { status: 400 });
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string | undefined> = [
|
||||
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<Response> {
|
||||
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<string, { className: string; violation: boolean }>();
|
||||
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<string, PerformerResponse["performers"]>();
|
||||
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<PerformerResponse & { minPasswordLength: number }> = 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 });
|
||||
}
|
||||
137
docker-compose/logintrainer/src/app/api/login/route.ts
Normal file
137
docker-compose/logintrainer/src/app/api/login/route.ts
Normal file
|
|
@ -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<Response> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
42
docker-compose/logintrainer/src/app/api/register/route.ts
Normal file
42
docker-compose/logintrainer/src/app/api/register/route.ts
Normal file
|
|
@ -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<Response> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NextResponse> {
|
||||
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 });
|
||||
}
|
||||
18
docker-compose/logintrainer/src/app/layout.tsx
Normal file
18
docker-compose/logintrainer/src/app/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<html lang="de">
|
||||
<body className="min-h-screen bg-gradient-to-br from-[#050b16] via-[#07132b] to-[#04152e] font-mono text-[#e8ebff] antialiased">
|
||||
<main className="min-h-screen w-full overflow-x-hidden">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LeaderboardEntry[]> {
|
||||
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<string, LeaderboardEntry>();
|
||||
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<Params> }): Promise<Metadata> {
|
||||
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<Params> }): Promise<ReactElement> {
|
||||
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 (
|
||||
<>
|
||||
<AutoRefresh intervalMs={15000} />
|
||||
<div className={styles.page}>
|
||||
<div className={styles.stage}>
|
||||
<div className={styles.board}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.badge}>Leaderboard</div>
|
||||
<h1 className={styles.title}>Klasse {className}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Wer schnappt sich den Pokal?
|
||||
<br /><br /><br /></p>
|
||||
</header>
|
||||
|
||||
{leaderboard.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<p className={styles.emptyHeadline}>Noch keine Bestzeiten</p>
|
||||
<p className={styles.emptyText}>
|
||||
Sobald die ersten Logins gemessen werden, erscheint hier eure Hall of Fame.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className={styles.podium}>
|
||||
<article className={`${styles.podiumCard} ${styles.second}`}>
|
||||
{podium.second.disqualified && (
|
||||
<div className={styles.podiumBadge}>Disqualified</div>
|
||||
)}
|
||||
<div className={styles.avatar}>{podium.second.initial}</div>
|
||||
<h2 className={styles.podiumLabel}>{podium.second.label}</h2>
|
||||
<p className={`${styles.podiumName} ${podium.second.muted ? styles.muted : ""}`}>
|
||||
{podium.second.username}
|
||||
</p>
|
||||
<p className={`${styles.podiumScore} ${podium.second.muted ? styles.muted : ""}`}>
|
||||
{podium.second.scoreText}
|
||||
</p>
|
||||
{podium.second.metaText && (
|
||||
<div className={`${styles.metaWrapper} ${podium.second.muted ? styles.muted : ""}`}>
|
||||
<span className={styles.podiumMeta}>{podium.second.metaText}</span>
|
||||
{podium.second.disqualified && (
|
||||
<span className={styles.diagonalStamp}>Disqualified</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{podium.second.dateText && (
|
||||
<p className={`${styles.podiumDate} ${podium.second.muted ? styles.muted : ""}`}>
|
||||
{podium.second.dateText}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
<article className={`${styles.podiumCard} ${styles.first}`}>
|
||||
{podium.first.disqualified && (
|
||||
<div className={styles.podiumBadge}>Disqualified</div>
|
||||
)}
|
||||
<div className={styles.crown}>🏆</div>
|
||||
<div className={styles.avatar}>{podium.first.initial}</div>
|
||||
<h2 className={styles.podiumLabel}>{podium.first.label}</h2>
|
||||
<p className={`${styles.podiumName} ${podium.first.muted ? styles.muted : ""}`}>
|
||||
{podium.first.username}
|
||||
</p>
|
||||
<p className={`${styles.podiumScore} ${podium.first.muted ? styles.muted : ""}`}>
|
||||
{podium.first.scoreText}
|
||||
</p>
|
||||
{podium.first.metaText && (
|
||||
<div className={`${styles.metaWrapper} ${podium.first.muted ? styles.muted : ""}`}>
|
||||
<span className={styles.podiumMeta}>{podium.first.metaText}</span>
|
||||
{podium.first.disqualified && (
|
||||
<span className={styles.diagonalStamp}>Disqualified</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{podium.first.dateText && (
|
||||
<p className={`${styles.podiumDate} ${podium.first.muted ? styles.muted : ""}`}>
|
||||
{podium.first.dateText}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
<article className={`${styles.podiumCard} ${styles.third}`}>
|
||||
{podium.third.disqualified && (
|
||||
<div className={styles.podiumBadge}>Disqualified</div>
|
||||
)}
|
||||
<div className={styles.avatar}>{podium.third.initial}</div>
|
||||
<h2 className={styles.podiumLabel}>{podium.third.label}</h2>
|
||||
<p className={`${styles.podiumName} ${podium.third.muted ? styles.muted : ""}`}>
|
||||
{podium.third.username}
|
||||
</p>
|
||||
<p className={`${styles.podiumScore} ${podium.third.muted ? styles.muted : ""}`}>
|
||||
{podium.third.scoreText}
|
||||
</p>
|
||||
{podium.third.metaText && (
|
||||
<div className={`${styles.metaWrapper} ${podium.third.muted ? styles.muted : ""}`}>
|
||||
<span className={styles.podiumMeta}>{podium.third.metaText}</span>
|
||||
{podium.third.disqualified && (
|
||||
<span className={styles.diagonalStamp}>Disqualified</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{podium.third.dateText && (
|
||||
<p className={`${styles.podiumDate} ${podium.third.muted ? styles.muted : ""}`}>
|
||||
{podium.third.dateText}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className={styles.listSection}>
|
||||
{listColumns.map((column, columnIndex) => (
|
||||
<div key={columnIndex} className={styles.listColumn}>
|
||||
{column.map((slot) => (
|
||||
<div
|
||||
key={slot.rank}
|
||||
className={`${styles.listItem} ${slot.disqualified ? styles.listDisqualified : ""}`}
|
||||
>
|
||||
<span className={styles.rankBadge}>{slot.rank}</span>
|
||||
<div className={styles.listInfo}>
|
||||
<span className={`${styles.listName} ${slot.muted ? styles.muted : ""}`}>
|
||||
{slot.username}
|
||||
</span>
|
||||
{slot.metaText && (
|
||||
<div className={styles.listMetaWrapper}>
|
||||
<span className={`${styles.listMeta} ${slot.muted ? styles.muted : ""}`}>
|
||||
{slot.metaText}
|
||||
</span>
|
||||
{slot.disqualified && (
|
||||
<span className={styles.diagonalStampSmall}>Disqualified</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className={`${styles.listDate} ${slot.muted ? styles.muted : ""}`}>
|
||||
{slot.dateText}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`${styles.listScore} ${slot.muted ? styles.muted : ""}`}>
|
||||
{slot.scoreText}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<aside className={styles.dopingCard} aria-label="Doping-Kontrolle">
|
||||
<div className={styles.dopingHeader}>
|
||||
<h2 className={styles.dopingTitle}>Doping-Kontrolle</h2>
|
||||
<p className={styles.dopingSubtitle}>
|
||||
Meldungen unter {formatMsPerChar(dopingThreshold)} werden geprüft.
|
||||
</p>
|
||||
</div>
|
||||
{dopingCandidates.length === 0 ? (
|
||||
<p className={styles.dopingClean}>
|
||||
Alles sauber! Keine verdächtigen Tippgeschwindigkeiten.
|
||||
</p>
|
||||
) : (
|
||||
<ul className={styles.dopingList}>
|
||||
{dopingCandidates.map(({ entry, perCharMs }) => (
|
||||
<li key={entry.username} className={styles.dopingItem}>
|
||||
<div className={styles.dopingUser}>
|
||||
<span className={styles.dopingInitial}>{initial(entry.username)}</span>
|
||||
<div className={styles.dopingInfo}>
|
||||
<span className={styles.dopingName}>{entry.username}</span>
|
||||
<span className={styles.dopingDate}>{formatDate(entry.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.dopingStat}>{formatMsPerChar(perCharMs)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
docker-compose/logintrainer/src/app/leaderboard/page.tsx
Normal file
69
docker-compose/logintrainer/src/app/leaderboard/page.tsx
Normal file
|
|
@ -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<string[]> {
|
||||
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<ReactElement> {
|
||||
const classes = await loadClasses();
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.hero}>
|
||||
<div className={styles.heroBadge}>Leaderboards</div>
|
||||
|
||||
</div>
|
||||
|
||||
{classes.length === 0 ? (
|
||||
<div className={styles.empty}>Noch keine Leaderboards vorhanden. Sobald die ersten Logins gemessen sind, erscheinen die Klassen hier.</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{classes.map((className) => (
|
||||
<Link
|
||||
key={className}
|
||||
href={`/leaderboard/${slugifyClassName(className)}`}
|
||||
className={styles.card}
|
||||
>
|
||||
<span className={styles.cardGlow} />
|
||||
<span className={styles.cardBadge}>Klasse</span>
|
||||
<span className={styles.cardLabel}>{className}</span>
|
||||
<span className={styles.cardAction}>Leaderboard öffnen</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
356
docker-compose/logintrainer/src/app/login/page.tsx
Normal file
356
docker-compose/logintrainer/src/app/login/page.tsx
Normal file
|
|
@ -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 <LoginPageInner />;
|
||||
}
|
||||
|
||||
// ---- 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<string | null>(null);
|
||||
|
||||
const userRef = useRef<HTMLInputElement>(null);
|
||||
const passRef = useRef<HTMLInputElement>(null);
|
||||
const usernameStartRef = useRef<number | null>(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<PwMeta>({ startedAt:0, keys:0, jumped:false, pasted:false, prevLen:0 });
|
||||
const [usernameLength, setUsernameLength] = useState<number>(0);
|
||||
const [passwordLength, setPasswordLength] = useState<number>(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<HTMLInputElement> = (e) => {
|
||||
if (usernameStartRef.current === null && e.clipboardData.getData("text").length > 0) {
|
||||
usernameStartRef.current = performance.now();
|
||||
}
|
||||
};
|
||||
const onUserChange: React.ChangeEventHandler<HTMLInputElement> = (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<HTMLInputElement> = (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<HTMLInputElement> = () => setPwMeta(m => ({ ...m, pasted: true }));
|
||||
const onPwChange: React.ChangeEventHandler<HTMLInputElement> = (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<void> {
|
||||
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<void> {
|
||||
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 (
|
||||
<div className="relative min-h-svh overflow-hidden">
|
||||
<NetworkBackground density={0.00018} color="rgba(52, 211, 153, 1)" />
|
||||
|
||||
<div className="relative z-10 grid min-h-svh place-items-center p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-emerald-500/40 bg-emerald-950/80 p-6 shadow-[0_50px_160px_rgba(16,185,129,0.35)] backdrop-blur">
|
||||
<header className="dw-header mb-4">
|
||||
<div className="dw-brand">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M3 12h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
{ SCHOOLNAME } // Passphrase Lab
|
||||
</div>
|
||||
<div className="dw-badge">Security · Training</div>
|
||||
</header>
|
||||
|
||||
<main className="dw-card" onKeyDown={onKeyDownGlobal} onKeyUp={onKeyUpGlobal}>
|
||||
<div className="dw-card-head">
|
||||
<div className="text-sm font-bold" style={{ color: "var(--dw-accent)" }}>LOGIN</div>
|
||||
<div className="dw-hint mt-2">Das Passwort muss sitzen!</div>
|
||||
</div>
|
||||
|
||||
{/* Honeypot */}
|
||||
<form aria-hidden="true" autoComplete="on" tabIndex={-1}
|
||||
style={{ position:"fixed", left:"-10000px", top:"-10000px", width:1, height:1, opacity:0, pointerEvents:"none" }}>
|
||||
<input type="text" name="username" autoComplete="username" />
|
||||
<input type="password" name="password" autoComplete="current-password" />
|
||||
<button type="submit" tabIndex={-1} />
|
||||
</form>
|
||||
|
||||
{capsLockActive ? (
|
||||
<div
|
||||
className="dw-alert dw-alert--warn mt-6 text-center text-base font-semibold leading-relaxed"
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
role="alert"
|
||||
>
|
||||
{CAPS_LOCK_MESSAGE}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dw-stack" role="form" aria-label="Login-Formular">
|
||||
<div className="dw-pill dw-pill-ok">Übung: Täglich hier anmelden!</div>
|
||||
|
||||
<div className="dw-field">
|
||||
<input
|
||||
ref={userRef}
|
||||
className="dw-input"
|
||||
placeholder="Benutzername (Schulnetz, NICHT moodle)"
|
||||
name={userNameAttrRef.current}
|
||||
id={userNameAttrRef.current}
|
||||
autoComplete="off" autoCorrect="off" autoCapitalize="none" spellCheck={false}
|
||||
readOnly
|
||||
onFocus={armInputs}
|
||||
onKeyDown={armInputs}
|
||||
onMouseDown={armInputs}
|
||||
onChange={onUserChange}
|
||||
onPaste={(e) => { armInputs(); onUserPaste(e); }}
|
||||
data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-dashlane="true" data-keeper-lock="true"
|
||||
inputMode="text"
|
||||
disabled={passwordLength > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dw-field relative">
|
||||
{usernameLength < 2 && passwordGateVisible && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="assertive"
|
||||
className="absolute -top-8 left-0 right-0 z-20 rounded-md bg-amber-500/90 px-3 py-2 text-xs font-semibold text-neutral-900 shadow-lg"
|
||||
>
|
||||
{PASSWORD_GATE_MESSAGE}
|
||||
</p>
|
||||
)}
|
||||
{usernameLength < 2 && (
|
||||
<div
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 z-10 cursor-not-allowed"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setPasswordGateVisible(true);
|
||||
setMsg(PASSWORD_GATE_MESSAGE);
|
||||
userRef.current?.focus();
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
setPasswordGateVisible(true);
|
||||
setMsg(PASSWORD_GATE_MESSAGE);
|
||||
userRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={passRef}
|
||||
className="dw-input dw-input--with-toggle"
|
||||
type={showPass ? "text" : "password"}
|
||||
placeholder="Passwort"
|
||||
name={passNameAttrRef.current}
|
||||
id={passNameAttrRef.current}
|
||||
autoComplete="new-password" autoCorrect="off" autoCapitalize="none" spellCheck={false}
|
||||
readOnly
|
||||
onFocus={(e) => {
|
||||
armInputs();
|
||||
if (e.currentTarget.value.length===0)
|
||||
setPwMeta({ startedAt:0, keys:0, jumped:false, pasted:false, prevLen:0 });
|
||||
}}
|
||||
onKeyDown={onPwKeyDown} onPaste={onPwPaste} onChange={onPwChange}
|
||||
data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-dashlane="true" data-keeper-lock="true"
|
||||
inputMode="text"
|
||||
disabled={usernameLength < 2}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPass(s => !s)} className="dw-toggle"
|
||||
aria-label={showPass ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
disabled={usernameLength < 2}>
|
||||
{showPass ? "verbergen" : "anzeigen"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="dw-btn" onClick={() => void handleLogin()} disabled={busy}>
|
||||
{busy ? "..." : "Anmelden"}
|
||||
</button>
|
||||
<span className="ml-auto text-xs dw-hint">Server: <span className="font-mono">h4ckSp4ce</span></span>
|
||||
</div>
|
||||
|
||||
{msg && <div className="mt-3 text-xs text-red-300">{msg}</div>}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{coachOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="w-[90%] max-w-md rounded-xl border border-emerald-400/30 bg-[#10172a] p-5 shadow-2xl">
|
||||
<div className="text-lg font-bold text-emerald-300 mb-2">Langsamer, Hacker! 🧠</div>
|
||||
<p className="text-sm leading-relaxed text-[#d8ffe3]">
|
||||
Das ging <span className="font-semibold">sehr schnell</span> oder wurde eingefügt.
|
||||
Fürs Training zählt nur, was du <em>tippst</em>.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="dw-btn" onClick={coachRetype}>Alles klar – ich tippe neu</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
docker-compose/logintrainer/src/app/login/success/page.tsx
Normal file
166
docker-compose/logintrainer/src/app/login/success/page.tsx
Normal file
|
|
@ -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<ReactElement> {
|
||||
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 (
|
||||
<div>
|
||||
<header className="dw-header">
|
||||
<div className="dw-brand">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M3 12h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
MORZ // Passphrase Lab
|
||||
</div>
|
||||
<div className="dw-badge">Auswertung · {authMode}</div>
|
||||
</header>
|
||||
|
||||
<main className="dw-card dw-card--wide">
|
||||
<SuccessStats
|
||||
displayName={displayName}
|
||||
days={daysWithSuccess}
|
||||
percent={percent}
|
||||
totalDays={totalDays}
|
||||
currentDurationMs={currentDurationMs}
|
||||
averageDurationMs={averageDurationMs}
|
||||
bestDurationMs={bestDurationMs}
|
||||
durationSeries={durationSeries}
|
||||
measurementCount={measurementCount}
|
||||
showShortPasswordAlert={shortPwFlag || isPasswordShort}
|
||||
shortPasswordMessage={shortPwMessage}
|
||||
metricSuffix={metricSuffix}
|
||||
currentSubtitle={currentSubtitle}
|
||||
chartTitle={chartTitle}
|
||||
isPasswordShort={isPasswordShort}
|
||||
passwordPolicyViolation={hasPasswordPolicyViolation}
|
||||
passwordLength={latestPasswordLength}
|
||||
passwordHasUpper={latestPasswordHasUpper}
|
||||
passwordHasLower={latestPasswordHasLower}
|
||||
passwordHasDigit={latestPasswordHasDigit}
|
||||
passwordHasSpecial={latestPasswordHasSpecial}
|
||||
policyMinLength={policyMinLength}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement | null>(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 ? (
|
||||
<div aria-hidden="true" style={disqualStampWrapperStyle}>
|
||||
<div style={disqualStampStyle}>
|
||||
<span style={disqualStampInnerStyle} />
|
||||
<span style={{ position: "relative", display: "inline-block" }}>Disqualifiziert</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const headerNode = (
|
||||
<div className="dw-stack">
|
||||
<h1
|
||||
className={`text-3xl md:text-4xl font-extrabold tracking-wide ${passwordPolicyViolation ? "text-red-200" : ""}`}
|
||||
style={{ color: passwordPolicyViolation ? undefined : "var(--dw-accent)" }}
|
||||
>
|
||||
{passwordPolicyViolation
|
||||
? "Login erfolgreich, aber das Passwort ist nicht gut!"
|
||||
: "Login erfolgreich!"}
|
||||
</h1>
|
||||
<p className={`dw-hint ${passwordPolicyViolation ? "text-red-200/80" : ""}`}>
|
||||
{isPasswordShort ? "Dein Passwort ist zu kurz" : "Gut gemacht"},{" "}
|
||||
<span className="font-mono glow-red">{displayName}</span>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const alertNode = showShortPasswordAlert ? (
|
||||
<div className="dw-alert dw-alert--warn" role="alert">
|
||||
{shortPasswordMessage}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const passwordTile = (
|
||||
<div
|
||||
className="dw-tile space-y-2"
|
||||
style={{
|
||||
textAlign: "left",
|
||||
borderColor: passwordChecks.allOk ? undefined : "rgba(248,113,113,0.4)",
|
||||
background: passwordChecks.allOk ? "linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01))" : "rgba(248,113,113,0.08)",
|
||||
}}
|
||||
>
|
||||
<div className="dw-tile-title">Passwort-Check</div>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<SecurityRow
|
||||
ok={passwordChecks.lengthOk}
|
||||
label={`Passwort-Länge ≥ ${policyMinLength} Zeichen`}
|
||||
/>
|
||||
<SecurityRow ok={passwordChecks.upperOk} label="Großbuchstaben" />
|
||||
<SecurityRow ok={passwordChecks.lowerOk} label="Kleinbuchstaben" />
|
||||
<SecurityRow ok={passwordChecks.digitOk} label="Ziffern" />
|
||||
<SecurityRow ok={passwordChecks.specialOk} label="Sonderzeichen" />
|
||||
</div>
|
||||
<p className="mt-4 text-xs uppercase tracking-widest text-emerald-100/60">
|
||||
{passwordChecks.allOk
|
||||
? "Starke Passwort-Anforderungen erfüllt."
|
||||
: "Bitte verbessere dein Passwort, um alle Kriterien zu erfüllen."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (passwordPolicyViolation) {
|
||||
return (
|
||||
<div ref={cardRef} className="relative dw-stack dw-stack-lg" style={{ paddingTop: "120px" }}>
|
||||
{stampNode}
|
||||
{headerNode}
|
||||
{alertNode}
|
||||
<div className="dw-stats-layout">
|
||||
{passwordTile}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<a href="/login" className="dw-btn dw-btn-ghost">Zurück zum Login</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={cardRef} className="relative dw-stack dw-stack-lg">
|
||||
{stampNode}
|
||||
{headerNode}
|
||||
{alertNode}
|
||||
<div className="dw-stats-layout">
|
||||
<div className="dw-tiles dw-stats-tiles">
|
||||
<div className="dw-tile">
|
||||
<div className="dw-tile-title">Letzte {totalDays} Tage</div>
|
||||
<div className="dw-tile-number" aria-live="polite">{dAnim}</div>
|
||||
<div className="dw-tile-sub">{dAnim === 1 ? "Tag mit Erfolg" : "Tage mit Erfolg"}</div>
|
||||
</div>
|
||||
<div className="dw-tile">
|
||||
<div className="dw-tile-title">Rating</div>
|
||||
<div className="dw-tile-number" aria-live="polite">{pAnim}%</div>
|
||||
<div className="dw-tile-sub">Durchhaltequote</div>
|
||||
</div>
|
||||
<div className="dw-tile">
|
||||
<div className="dw-tile-title">Aktueller Login</div>
|
||||
<div
|
||||
className={`dw-tile-number ${currentTone === "faster" ? "dw-tile-number--better" : ""}${currentTone === "slower" ? " dw-tile-number--slower" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{renderDuration(currentDurationMs, metricSuffix)}
|
||||
</div>
|
||||
<div className="dw-tile-sub">{currentSubtitle}</div>
|
||||
{bestDurationMs !== null && (
|
||||
<div className="dw-tile-peer">Deine Bestzeit: {renderDuration(bestDurationMs, metricSuffix)}</div>
|
||||
)}
|
||||
</div>
|
||||
{passwordTile}
|
||||
</div>
|
||||
<section className="dw-stack dw-stats-chart">
|
||||
<header className="flex flex-wrap items-end gap-2">
|
||||
<h2 className="text-base font-semibold uppercase tracking-wide text-emerald-200">
|
||||
{chartTitle}
|
||||
</h2>
|
||||
<span className="text-xs text-emerald-100/70">
|
||||
Werte in Sekunden{metricSuffix ? ` ${metricSuffix}` : ""},{" "}
|
||||
{measurementCount === 1 ? "1 Messung" : `${measurementCount} Messungen`}
|
||||
</span>
|
||||
</header>
|
||||
{hasMeasurements && chartStats ? (
|
||||
<DurationChart
|
||||
series={durationSeries}
|
||||
minMs={chartStats.min}
|
||||
maxMs={chartStats.max}
|
||||
valueSuffix={metricSuffix}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-emerald-100/70">
|
||||
Noch keine Messwerte vorhanden. Melde dich erneut an, um Daten zu sammeln.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<a href="/login" className="dw-btn dw-btn-ghost">Zurück zum Login</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<span>{formatDuration(ms)}</span>
|
||||
{suffix && <span className="ml-1 align-baseline text-xs uppercase tracking-widest">{suffix}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityRow({ ok, label }: { ok: boolean; label: string }): ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className={ok ? "text-emerald-300" : "text-red-300"}>{ok ? "✅" : "🚨"}</span>
|
||||
<span className={ok ? "text-emerald-100" : "text-red-100"}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="w-full overflow-hidden rounded-xl border border-emerald-400/20 bg-[#08111f] px-3 py-4">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label="Verlauf der Login-Zeiten"
|
||||
className="h-40 w-full"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="login-time-gradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgba(16, 185, 129, 0.8)" />
|
||||
<stop offset="100%" stopColor="rgba(16, 185, 129, 0.1)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{points.length > 1 && (
|
||||
<polygon
|
||||
points={`${points
|
||||
.map((p) => `${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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<line
|
||||
x1={padding}
|
||||
x2={width - padding}
|
||||
y1={height - padding}
|
||||
y2={height - padding}
|
||||
stroke="rgba(74, 222, 128, 0.3)"
|
||||
strokeWidth={0.8}
|
||||
/>
|
||||
|
||||
<polyline
|
||||
points={polyline}
|
||||
fill="none"
|
||||
stroke="rgba(16, 185, 129, 0.8)"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{points.map((p, idx) => (
|
||||
<circle
|
||||
key={`point-${idx}`}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={2.4}
|
||||
fill="rgba(45, 212, 191, 0.9)"
|
||||
stroke="rgba(8, 145, 178, 0.8)"
|
||||
strokeWidth={0.8}
|
||||
>
|
||||
<title>
|
||||
{`${dateFormatter.format(new Date(p.dateISO))} · ${(p.ms / 1000).toFixed(2)} s${
|
||||
valueSuffix ? ` ${valueSuffix}` : ""
|
||||
}`}
|
||||
</title>
|
||||
</circle>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between text-[0.7rem] uppercase tracking-wide text-emerald-100/60">
|
||||
<span>
|
||||
{(minMs / 1000).toFixed(2)} s{valueSuffix ? ` ${valueSuffix}` : ""}
|
||||
</span>
|
||||
<span>
|
||||
Max {(maxMs / 1000).toFixed(2)} s{valueSuffix ? ` ${valueSuffix}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-[0.65rem] font-mono text-emerald-100/50">
|
||||
{points.map((p, idx) => (
|
||||
<span key={`label-${idx}`}>{dateFormatter.format(new Date(p.dateISO))}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
docker-compose/logintrainer/src/app/login/uppercase/page.tsx
Normal file
67
docker-compose/logintrainer/src/app/login/uppercase/page.tsx
Normal file
|
|
@ -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<ReactElement> {
|
||||
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 (
|
||||
<div>
|
||||
<header className="dw-header">
|
||||
<div className="dw-brand">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M3 12h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
MORZ // Passphrase Lab
|
||||
</div>
|
||||
<div className="dw-badge">Hinweis</div>
|
||||
</header>
|
||||
|
||||
<main className="dw-card">
|
||||
<div className="dw-stack">
|
||||
<h1 className="text-2xl font-extrabold tracking-wide" style={{ color: "var(--dw-accent)" }}>
|
||||
Benutzername in Kleinbuchstaben
|
||||
</h1>
|
||||
<p className="text-xl">💡</p>
|
||||
<p className="dw-hint">
|
||||
Hallo <span className="font-mono glow-red">{displayName}</span>,
|
||||
</p>
|
||||
|
||||
<p className="dw-hint">
|
||||
du hast im <em>Benutzernamen</em> Großbuchstaben verwendet. Das kann zu Problemen führen und
|
||||
deshalb solltest du das nicht tun.
|
||||
</p>
|
||||
|
||||
<p className="dw-hint">
|
||||
Achte bitte in Zukunft darauf, den <strong>Benutzernamen</strong> ausschließlich in
|
||||
<strong> kleinbuchstaben</strong> zu tippen.
|
||||
</p>
|
||||
|
||||
<p className="dw-hint">
|
||||
<strong>Das betrifft nicht das Passwort!</strong> Dieses musst du genau so eintippen, wie du es dir ausgesucht hast!
|
||||
</p>
|
||||
|
||||
<div className="pt-1">
|
||||
<a href="/login" className="dw-btn dw-btn-ghost">Versuche es nochmal</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
docker-compose/logintrainer/src/app/page.tsx
Normal file
5
docker-compose/logintrainer/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage(): never {
|
||||
redirect("/login");
|
||||
}
|
||||
0
docker-compose/logintrainer/src/app/public/.keep
Normal file
0
docker-compose/logintrainer/src/app/public/.keep
Normal file
36
docker-compose/logintrainer/src/app/register/page.tsx
Normal file
36
docker-compose/logintrainer/src/app/register/page.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
import { useState, type FormEvent, type ReactElement } from "react";
|
||||
|
||||
export default function RegisterPage(): ReactElement {
|
||||
const [, setMsg] = useState<string>("");
|
||||
|
||||
async function handleRegister(e: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
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 (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
{/* … Rest wie gehabt … */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-emerald-400/20 bg-[#081226]/80 p-6 shadow-2xl">
|
||||
<div>
|
||||
<label htmlFor="days" className="block text-sm font-semibold uppercase tracking-[0.3em] text-emerald-200/80">
|
||||
Älter als (Tage)
|
||||
</label>
|
||||
<input
|
||||
id="days"
|
||||
type="number"
|
||||
min={1}
|
||||
value={days}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="users" className="block text-sm font-semibold uppercase tracking-[0.3em] text-emerald-200/80">
|
||||
Benutzer (Kommagetrennt, optional)
|
||||
</label>
|
||||
<input
|
||||
id="users"
|
||||
value={users}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="classes" className="block text-sm font-semibold uppercase tracking-[0.3em] text-emerald-200/80">
|
||||
Klassen (Kommagetrennt, optional)
|
||||
</label>
|
||||
<input
|
||||
id="classes"
|
||||
value={classes}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full rounded-lg bg-emerald-500/80 px-4 py-2 font-semibold text-neutral-900 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Bereinige …" : "Ausgewählte Datensätze löschen"}
|
||||
</button>
|
||||
|
||||
{result && (
|
||||
<div className="rounded-lg border border-emerald-400/30 bg-emerald-500/10 p-3 text-sm text-emerald-100/80">
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
24
docker-compose/logintrainer/src/app/teacher/cleanup/page.tsx
Normal file
24
docker-compose/logintrainer/src/app/teacher/cleanup/page.tsx
Normal file
|
|
@ -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 (
|
||||
<AuthGate>
|
||||
<div className="space-y-6">
|
||||
<header className="border-b border-emerald-400/40 pb-4">
|
||||
<h1 className="text-2xl font-bold tracking-wide text-emerald-200">Datenbereinigung</h1>
|
||||
<p className="mt-2 text-sm text-emerald-100/70">
|
||||
Entferne alte oder unerwünschte Login-Datensätze. Aktionen wirken sofort und können nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
</header>
|
||||
<CleanupForm />
|
||||
</div>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Record<string, DumpRowValue>>;
|
||||
};
|
||||
|
||||
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<Record<string, SortState>>({});
|
||||
|
||||
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 (
|
||||
<div className="flex w-full flex-col gap-8">
|
||||
<section className="rounded-2xl border border-white/15 bg-white/5 p-6 shadow-xl backdrop-blur-sm">
|
||||
<h2 className="text-lg font-semibold uppercase tracking-[0.35em] text-white/80">Filter</h2>
|
||||
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<label className="flex flex-col text-sm uppercase tracking-widest text-white/60">
|
||||
Nach Username filtern
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="z. B. max.mueller"
|
||||
className="mt-2 w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2 text-base text-white placeholder-white/40 outline-none focus:border-emerald-300"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{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 (
|
||||
<section key={table.name} className="overflow-hidden rounded-3xl border border-white/15 bg-white/5 shadow-2xl">
|
||||
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-white/10 bg-white/10 px-5 py-4">
|
||||
<h2 className="text-xl font-bold uppercase tracking-[0.3em] text-white">{table.name}</h2>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-white/60">{rows.length} Datensätze</span>
|
||||
</header>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border-collapse text-sm text-white">
|
||||
<thead className="bg-white/10 text-xs uppercase tracking-[0.25em] text-white/70">
|
||||
<tr>
|
||||
{table.columns.map((column) => {
|
||||
const active = sort?.column === column;
|
||||
return (
|
||||
<th
|
||||
key={column}
|
||||
scope="col"
|
||||
className={clsx(
|
||||
"cursor-pointer px-4 py-3 text-left font-semibold",
|
||||
active && "text-emerald-300"
|
||||
)}
|
||||
onClick={() => toggleSort(table.name, column)}
|
||||
>
|
||||
<span>{column}</span>
|
||||
{active && <span>{sort?.direction === "asc" ? " ▲" : " ▼"}</span>}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={`${table.name}-${rowIndex}`} className={rowIndex % 2 === 0 ? "bg-white/5" : "bg-white/0"}>
|
||||
{table.columns.map((column) => (
|
||||
<td key={column} className="px-4 py-3 align-top text-xs font-mono text-white/90">
|
||||
{formatValue(row[column])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={table.columns.length} className="px-4 py-6 text-center text-sm text-white/60">
|
||||
Keine Einträge für den aktuellen Filter.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
76
docker-compose/logintrainer/src/app/teacher/dbdump/page.tsx
Normal file
76
docker-compose/logintrainer/src/app/teacher/dbdump/page.tsx
Normal file
|
|
@ -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<string, unknown>;
|
||||
|
||||
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<DumpTable[]> {
|
||||
const tablesRaw = await prisma.$queryRaw<Array<{ name: string }>>(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<Array<{ name: string }>>(`PRAGMA table_info("${name}");`);
|
||||
const columns = columnInfo.map((info) => info.name).filter((column) => column.length > 0);
|
||||
|
||||
const rowsRaw = await prisma.$queryRawUnsafe<TableRow[]>(`SELECT * FROM "${name}";`);
|
||||
const rows = rowsRaw.map((row) => {
|
||||
const normalized: Record<string, string | number | boolean | null> = {};
|
||||
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<ReactElement> {
|
||||
const tables = await loadTables();
|
||||
|
||||
return (
|
||||
<AuthGate>
|
||||
<div className="min-h-[calc(100vh-6rem)] bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-900 px-4 py-10 text-white">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-10">
|
||||
<header className="text-center">
|
||||
<p className="text-sm uppercase tracking-[0.35em] text-white/70">DB Explorer</p>
|
||||
<h1 className="mt-3 text-3xl font-extrabold uppercase tracking-[0.3em] text-white md:text-4xl">
|
||||
Datenbank-Dump
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-white/70 md:text-base">
|
||||
Nutze den Filter nach Username
|
||||
oder sortiere Spalten, um schneller bestimmte Einträge zu finden.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DumpViewer tables={tables} />
|
||||
</div>
|
||||
</div>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// auto-login, falls im localStorage gespeichert
|
||||
const saved = localStorage.getItem("logsAuth");
|
||||
if (saved) {
|
||||
setOk(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
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 (
|
||||
<div className="teacher-auth">
|
||||
<div className="teacher-auth__card">
|
||||
<div className="teacher-auth__header">
|
||||
<span className="teacher-auth__badge">Teacher Area</span>
|
||||
<h2 className="teacher-auth__title">Geschützter Bereich</h2>
|
||||
<p className="teacher-auth__text">Bitte Passwort eingeben, um die Auswertungen aufzurufen.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="teacher-password" className="text-xs font-semibold uppercase tracking-[0.22em] text-emerald-600">
|
||||
Zugangscode
|
||||
</label>
|
||||
<input
|
||||
id="teacher-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={pw}
|
||||
onChange={(e) => setPw(e.target.value)}
|
||||
className="teacher-auth__input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="teacher-auth__button">
|
||||
Anmelden
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<p className="teacher-auth__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
183
docker-compose/logintrainer/src/app/teacher/logs/LogsTable.tsx
Normal file
183
docker-compose/logintrainer/src/app/teacher/logs/LogsTable.tsx
Normal file
|
|
@ -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<Attempt[]>(initialAttempts);
|
||||
const [q, setQ] = useState("");
|
||||
const [cls, setCls] = useState<string>(""); // "" = alle Klassen
|
||||
const [sortKey, setSortKey] = useState<SortKey>("createdAt");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(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<void> {
|
||||
if (!confirm("Alle fehlgeschlagenen Logins wirklich löschen?")) return;
|
||||
setBusy(true); setInfo(null);
|
||||
|
||||
const headers: Record<string, string> = { "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 */}
|
||||
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm opacity-80 mb-1">Filter (Username, live)</label>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="z. B. max.mueller"
|
||||
className="w-full p-2 bg-transparent border rounded border-[#7aa2ff]/50"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Klassen-Dropdown */}
|
||||
<div className="w-[220px]">
|
||||
<label className="block text-sm opacity-80 mb-1">Klasse</label>
|
||||
<select
|
||||
value={cls}
|
||||
onChange={(e) => setCls(e.target.value)}
|
||||
className="w-full p-2 bg-transparent border rounded border-[#7aa2ff]/50"
|
||||
>
|
||||
<option value="">Alle Klassen</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm opacity-80 mb-1">Sortieren nach</label>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||
className="p-2 bg-transparent border rounded border-[#7aa2ff]/50"
|
||||
>
|
||||
<option value="createdAt">Zeit</option>
|
||||
<option value="username">Username</option>
|
||||
<option value="success">Erfolg</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm opacity-80 mb-1">Richtung</label>
|
||||
<select
|
||||
value={sortDir}
|
||||
onChange={(e) => setSortDir(e.target.value as SortDir)}
|
||||
className="p-2 bg-transparent border rounded border-[#7aa2ff]/50"
|
||||
>
|
||||
<option value="desc">↓ absteigend</option>
|
||||
<option value="asc">↑ aufsteigend</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Purge-Button */}
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={purgeFailed}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded bg-[#ff8b8b] text-black disabled:opacity-60"
|
||||
title="Löscht alle Fehlversuche dauerhaft"
|
||||
>
|
||||
{busy ? "Lösche…" : "Alle Fehlversuche löschen"}
|
||||
</button>
|
||||
{info && <span className="text-sm opacity-80">{info}</span>}
|
||||
</div>
|
||||
|
||||
{/* Tabelle */}
|
||||
<table className="w-full text-sm border border-[#7aa2ff]/30">
|
||||
<thead className="bg-[#7aa2ff]/20">
|
||||
<tr>
|
||||
<th className="p-1 cursor-pointer select-none" onClick={() => toggleSort("createdAt")}>
|
||||
Zeit{arrow("createdAt")}
|
||||
</th>
|
||||
<th className="p-1 cursor-pointer select-none" onClick={() => toggleSort("username")}>
|
||||
User{arrow("username")}
|
||||
</th>
|
||||
<th className="p-1">Klasse</th>
|
||||
<th className="p-1 cursor-pointer select-none" onClick={() => toggleSort("success")}>
|
||||
Erfolg{arrow("success")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((a) => (
|
||||
<tr key={a.id} className="border-t border-[#7aa2ff]/30">
|
||||
<td className="p-1">{new Date(a.createdAt).toLocaleString("de-DE")}</td>
|
||||
<td className="p-1">{a.username}</td>
|
||||
<td className="p-1">{a.className ?? "—"}</td>
|
||||
<td className={`p-1 ${a.success ? "text-green-400" : "text-red-400"}`}>
|
||||
{a.success ? "✔" : "✘"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
docker-compose/logintrainer/src/app/teacher/logs/page.tsx
Normal file
54
docker-compose/logintrainer/src/app/teacher/logs/page.tsx
Normal file
|
|
@ -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<ReactElement> {
|
||||
// 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 (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-bold text-[#7aa2ff]">Login-Logs</h1>
|
||||
<a href="/teacher/stats" className="px-3 py-1 rounded border border-[#7aa2ff]/30 text-[#7aa2ff] hover:bg-[#7aa2ff]/10">
|
||||
Zur Statistik →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<LogsTable initialAttempts={attempts} classes={classes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
docker-compose/logintrainer/src/app/teacher/page.tsx
Normal file
88
docker-compose/logintrainer/src/app/teacher/page.tsx
Normal file
|
|
@ -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 (
|
||||
<AuthGate>
|
||||
<section className="teacher-shell">
|
||||
<div className="teacher-shell__inner">
|
||||
<header className="text-center md:text-left">
|
||||
<span className="teacher-shell__ribbon">Teacher Area</span>
|
||||
<h1 className="teacher-shell__title">Sammlung verschiedener Ansichten</h1>
|
||||
<p className="teacher-shell__subtitle">
|
||||
Wähle eine Kachel, um direkt zu den wichtigsten Werkzeugen für Auswertung, Wettbewerbe und Administration zu springen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="teacher-grid">
|
||||
{links.map((link) => (
|
||||
<a key={link.href} href={link.href} className="teacher-card" data-accent={link.accent}>
|
||||
<div className="teacher-card__accent" />
|
||||
<span className="teacher-card__icon">{link.icon}</span>
|
||||
<div className="space-y-2">
|
||||
<h2 className="teacher-card__title">{link.title}</h2>
|
||||
<p className="teacher-card__desc">{link.description}</p>
|
||||
</div>
|
||||
<span className="teacher-card__cta">
|
||||
Öffnen
|
||||
<span aria-hidden className="text-base leading-none">↗</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
166
docker-compose/logintrainer/src/app/teacher/stats/StatsTable.tsx
Normal file
166
docker-compose/logintrainer/src/app/teacher/stats/StatsTable.tsx
Normal file
|
|
@ -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<Row[]>(initial);
|
||||
const [q, setQ] = useState("");
|
||||
const [cls, setCls] = useState<string>("");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("username");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("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 (
|
||||
<div className={`space-y-3 ${styles.wrap}`}>
|
||||
{/* Filter */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-sm opacity-80">Filter (Username)</label>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[220px]">
|
||||
<label className="mb-1 block text-sm opacity-80">Klasse</label>
|
||||
<select
|
||||
value={cls}
|
||||
onChange={(e) => setCls(e.target.value)}
|
||||
className="w-full rounded-md border border-white/20 bg-white/5 px-3 py-2 outline-none"
|
||||
>
|
||||
<option value="">Alle Klassen</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onClick={resetFilters} className="rounded-md border border-white/20 bg-white/5 px-3 py-2">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabelle */}
|
||||
<div className="overflow-x-auto rounded-lg border border-white/20">
|
||||
<table className="min-w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th onClick={() => toggleSort("username")} label={`Nutzername${arrow("username")}`} />
|
||||
<Th onClick={() => toggleSort("className")} label={`Klasse${arrow("className")}`} />
|
||||
<Th onClick={() => toggleSort("total")} label={`Insgesamt${arrow("total")}`} />
|
||||
<Th onClick={() => toggleSort("success")} label={`Erfolgreich${arrow("success")}`} />
|
||||
<Th onClick={() => toggleSort("successLast6")} label={`Erfolg (6 Tage)${arrow("successLast6")}`} />
|
||||
<Th onClick={() => toggleSort("fail")} label={`Fehlversuche${arrow("fail")}`} />
|
||||
<Th onClick={() => toggleSort("percent")} label={`Durchhaltequote${arrow("percent")}`} />
|
||||
<Th onClick={() => toggleSort("fastestMs")} label={`Schnellster Versuch${arrow("fastestMs")}`} />
|
||||
<Th onClick={() => toggleSort("avgMs")} label={`Ø Zeit${arrow("avgMs")}`} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((r, i) => (
|
||||
<tr key={r.username} className={i % 2 === 0 ? "bg-white/[.02]" : "bg-white/[.05]"}>
|
||||
<Td>{r.username}</Td>
|
||||
<Td>{r.className ?? "—"}</Td>
|
||||
<Td className={styles.num}>{r.total}</Td>
|
||||
<Td className={`${styles.num} text-green-400`}>{r.success}</Td>
|
||||
<Td className={styles.num}>{r.successLast6}</Td>
|
||||
<Td className={`${styles.num} text-red-400`}>{r.fail}</Td>
|
||||
<Td className={`${styles.num} font-bold`}>{r.percent}%</Td>
|
||||
<Td className={styles.num}>{formatDuration(r.fastestMs)}</Td>
|
||||
<Td className={styles.num}>{formatDuration(r.avgMs)}</Td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<tr>
|
||||
<td className="p-4 text-center opacity-70" colSpan={9}>
|
||||
Keine Treffer für die aktuelle Filter/Suchkombi.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-70">
|
||||
Tipp: Klick auf Spaltenköpfe sortiert; oben live nach Username filtern und per Dropdown nach Klasse einschränken.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ label, onClick }: { label: string; onClick: () => void }): ReactElement {
|
||||
return (
|
||||
<th onClick={onClick} className="cursor-pointer select-none text-sm font-semibold" scope="col">
|
||||
{label}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
function Td({ children, className = "" }: { children: ReactNode; className?: string }): ReactElement {
|
||||
return <td className={`align-middle ${className}`}>{children}</td>;
|
||||
}
|
||||
139
docker-compose/logintrainer/src/app/teacher/stats/page.tsx
Normal file
139
docker-compose/logintrainer/src/app/teacher/stats/page.tsx
Normal file
|
|
@ -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<ReactElement> {
|
||||
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<string, number>();
|
||||
totalByUser.forEach(r => mapTotal.set(r.username, r._count._all));
|
||||
|
||||
const mapSucc = new Map<string, number>();
|
||||
successByUser.forEach(r => mapSucc.set(r.username, r._count._all));
|
||||
|
||||
const mapRecentSuccCount = new Map<string, number>();
|
||||
const mapDaysWithSucc = new Map<string, Set<string>>();
|
||||
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<string, string>();
|
||||
for (const r of classRows) {
|
||||
if (!mapClass.has(r.username)) {
|
||||
mapClass.set(r.username, r.className!);
|
||||
}
|
||||
}
|
||||
|
||||
const mapFastest = new Map<string, number>();
|
||||
const mapAvg = new Map<string, number>();
|
||||
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<string>();
|
||||
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 (
|
||||
<AuthGate>
|
||||
<div>
|
||||
<h1 className="text-xl mb-4">Nutzungs-Statistik</h1>
|
||||
<StatsTable initial={stats} classes={classes} />
|
||||
</div>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<SortKey>("latestLoginIso");
|
||||
const [direction, setDirection] = useState<SortDirection>("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 (
|
||||
<div className="space-y-4">
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-emerald-400/30 bg-emerald-900/20 p-6 text-emerald-100/80">
|
||||
Keine Verstöße gefunden – alle Passwörter erfüllen aktuell die Mindestlänge.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-emerald-400/30 bg-[#07121f]/80 shadow-xl">
|
||||
<div className="flex flex-wrap items-end gap-4 px-4 pt-4 text-sm text-emerald-100/80">
|
||||
<div>
|
||||
<span className="block uppercase text-xs tracking-widest text-emerald-200/70">Benutzername</span>
|
||||
<input
|
||||
value={usernameFilter}
|
||||
onChange={(e) => 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..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block uppercase text-xs tracking-widest text-emerald-200/70">Klasse</span>
|
||||
<input
|
||||
value={classFilter}
|
||||
onChange={(e) => 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..."
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto text-xs uppercase tracking-widest text-emerald-200/70">
|
||||
{sorted.length} Einträge · Mindestlänge {minLength}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="min-w-full divide-y divide-emerald-400/20">
|
||||
<thead className="bg-emerald-400/10 text-left text-sm uppercase tracking-widest text-emerald-200">
|
||||
<tr>
|
||||
<SortableHeader onClick={toggleSort} sortKey={sortKey} direction={direction} column="username">
|
||||
Benutzername
|
||||
</SortableHeader>
|
||||
<SortableHeader onClick={toggleSort} sortKey={sortKey} direction={direction} column="className">
|
||||
Klasse
|
||||
</SortableHeader>
|
||||
<SortableHeader onClick={toggleSort} sortKey={sortKey} direction={direction} column="latestLoginIso">
|
||||
Zuletzt
|
||||
</SortableHeader>
|
||||
<SortableHeader onClick={toggleSort} sortKey={sortKey} direction={direction} column="attempts">
|
||||
Anzahl
|
||||
</SortableHeader>
|
||||
<SortableHeader onClick={toggleSort} sortKey={sortKey} direction={direction} column="shortestPassword">
|
||||
Kürzestes Passwort
|
||||
</SortableHeader>
|
||||
<th className="px-4 py-3 text-emerald-200">Policy-Verstoß</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-emerald-400/10 text-sm text-emerald-100/80">
|
||||
{sorted.map((row) => (
|
||||
<tr key={row.username} className="transition-colors hover:bg-emerald-400/5">
|
||||
<td className="px-4 py-3 font-mono text-emerald-100">{row.username}</td>
|
||||
<td className="px-4 py-3">{row.className ?? "–"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(row.latestLoginIso))}
|
||||
</td>
|
||||
<td className="px-4 py-3">{row.attempts}</td>
|
||||
<td className="px-4 py-3">{row.shortestPassword} Zeichen</td>
|
||||
<td className="px-4 py-3 text-emerald-100/80">
|
||||
{row.violations.length > 0 ? row.violations.join(" · ") : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<th className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(column)}
|
||||
className={`flex items-center gap-1 text-left hover:underline ${active ? "text-emerald-100" : ""}`}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active && <span className="text-xs">{direction === "asc" ? "▲" : "▼"}</span>}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
125
docker-compose/logintrainer/src/app/teacher/violaters/page.tsx
Normal file
125
docker-compose/logintrainer/src/app/teacher/violaters/page.tsx
Normal file
|
|
@ -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<string | undefined> = [
|
||||
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<ReactElement> {
|
||||
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<string, ViolaterTableRow>();
|
||||
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 (
|
||||
<AuthGate>
|
||||
<div className="space-y-6">
|
||||
<header className="border-b border-emerald-400/40 pb-4">
|
||||
<h1 className="text-2xl font-bold tracking-wide text-emerald-200">
|
||||
Passwort-Verstöße – letzte 7 Tage
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-emerald-100/70">
|
||||
Mindestlänge laut Richtlinie:{" "}
|
||||
<span className="font-semibold text-emerald-100">{minLength} Zeichen</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ViolatersTable minLength={minLength} rows={violaters} />
|
||||
</div>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
24
docker-compose/logintrainer/src/components/AutoRefresh.tsx
Normal file
24
docker-compose/logintrainer/src/components/AutoRefresh.tsx
Normal file
|
|
@ -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;
|
||||
}
|
||||
167
docker-compose/logintrainer/src/components/NetworkBackground.tsx
Normal file
167
docker-compose/logintrainer/src/components/NetworkBackground.tsx
Normal file
|
|
@ -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 <body> 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<nodes.length;i++){
|
||||
const a = nodes[i];
|
||||
for (let j=i+1;j<nodes.length;j++){
|
||||
const b = nodes[j];
|
||||
const dx=a.x-b.x, dy=a.y-b.y, d2=dx*dx+dy*dy;
|
||||
if (d2 < cd2){
|
||||
const d = Math.sqrt(d2);
|
||||
ctx.globalAlpha = Math.max(0, 1 - d / connectDistance) * 0.45;
|
||||
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Punkte
|
||||
ctx.globalAlpha = 1; ctx.shadowBlur = 10; ctx.fillStyle = color;
|
||||
for (const n of nodes){ ctx.beginPath(); ctx.arc(n.x,n.y,1.3,0,Math.PI*2); ctx.fill(); }
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
function animate(): void {
|
||||
if (!running) return;
|
||||
drawFrame();
|
||||
raf = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function onMove(e: MouseEvent): void { mouseX=e.clientX; mouseY=e.clientY; }
|
||||
function onLeave(): void { mouseX = mouseY = null; }
|
||||
|
||||
window.addEventListener("resize", resize, { passive:true });
|
||||
(window.visualViewport)?.addEventListener("resize", resize);
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseleave", onLeave);
|
||||
|
||||
resize();
|
||||
if (prefersReduced) drawFrame(); else raf = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
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;
|
||||
}
|
||||
146
docker-compose/logintrainer/src/lib/auth.ts
Normal file
146
docker-compose/logintrainer/src/lib/auth.ts
Normal file
|
|
@ -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<string, unknown> & { 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<AdAuthResult> {
|
||||
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<string | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
docker-compose/logintrainer/src/lib/logger.ts
Normal file
19
docker-compose/logintrainer/src/lib/logger.ts
Normal file
|
|
@ -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`);
|
||||
}
|
||||
11
docker-compose/logintrainer/src/lib/prisma.ts
Normal file
11
docker-compose/logintrainer/src/lib/prisma.ts
Normal file
|
|
@ -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;
|
||||
442
docker-compose/logintrainer/src/styles/globals.css
Normal file
442
docker-compose/logintrainer/src/styles/globals.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
621
docker-compose/logintrainer/src/styles/tailwind.generated.css
Normal file
621
docker-compose/logintrainer/src/styles/tailwind.generated.css
Normal file
|
|
@ -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: "<color>";
|
||||
inherits: false;
|
||||
initial-value: #0000;
|
||||
}
|
||||
@property --tw-gradient-via {
|
||||
syntax: "<color>";
|
||||
inherits: false;
|
||||
initial-value: #0000;
|
||||
}
|
||||
@property --tw-gradient-to {
|
||||
syntax: "<color>";
|
||||
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: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
@property --tw-gradient-via-position {
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 50%;
|
||||
}
|
||||
@property --tw-gradient-to-position {
|
||||
syntax: "<length-percentage>";
|
||||
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: "<percentage>";
|
||||
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: "<percentage>";
|
||||
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: "<length>";
|
||||
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: "<percentage>";
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
docker-compose/logintrainer/tailwind.config.cjs
Normal file
8
docker-compose/logintrainer/tailwind.config.cjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
26
docker-compose/logintrainer/tsconfig.json
Normal file
26
docker-compose/logintrainer/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue