logintrainer

This commit is contained in:
Jesko Anschütz 2025-11-04 21:35:45 +01:00
parent aa88d750f6
commit ea52d39e6c
55 changed files with 5885 additions and 0 deletions

View 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

View 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"]

View 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.

View 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

View 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 "$@"

View 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" ],
},
];

View 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.

View file

@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
basePath: "",
experimental: {
useLightningcss: false,
},
};
export default nextConfig;

View 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"
}
}

Binary file not shown.

View file

@ -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");

View file

@ -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"

View 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])
}

View 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);
});

View file

@ -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",
},
});
}

View 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 });
}
}

View file

@ -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 });
}

View 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 });
}
}

View 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 });
}
}

View file

@ -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 });
}

View 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>
);
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage(): never {
redirect("/login");
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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;
}

View 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>
);
}

View file

@ -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}</>;
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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;
}

View 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>;
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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;
}

View file

@ -0,0 +1,167 @@
"use client";
import { useEffect } from "react";
type Props = {
className?: string; // wird ignoriert, wir setzen Styles direkt
density?: number; // 0.000040.00012
connectDistance?: number; // 80200
influenceRadius?: number; // 120300
magneticStrength?: number; // 01
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;
}

View 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);
}
}
}

View 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`);
}

View 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;

View 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;
}

View 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;
}
}
}

View file

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [],
};

View 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"]
}