diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts new file mode 100644 index 0000000..bb45621 --- /dev/null +++ b/web/app/api/health/route.ts @@ -0,0 +1,16 @@ +import { collectHealth } from '@/lib/health/check' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Wird vom Healthcheck des Dienstes `web` benutzt, siehe docs/11-betrieb.md. + * 200 solange die Datenbank erreichbar ist, 503 sonst. + */ +export async function GET(): Promise { + const report = await collectHealth() + return Response.json(report, { + status: report.status === 'down' ? 503 : 200, + headers: { 'Cache-Control': 'no-store' }, + }) +} diff --git a/web/app/error.tsx b/web/app/error.tsx new file mode 100644 index 0000000..453c684 --- /dev/null +++ b/web/app/error.tsx @@ -0,0 +1,19 @@ +'use client' + +import { de } from '@/lib/i18n/de' + +export default function Error({ reset }: { error: Error; reset: () => void }) { + return ( +
+

{de.errors.unexpected.title}

+

{de.errors.unexpected.body}

+ +
+ ) +} diff --git a/web/app/fonts.css b/web/app/fonts.css new file mode 100644 index 0000000..23b71b0 --- /dev/null +++ b/web/app/fonts.css @@ -0,0 +1,89 @@ +/* IBM Plex, selbst gehostet, als woff2 untergesetzt (latin und latin-ext). + Die Wahl ist in docs/10-design-system.md begruendet: Plex wurde als Hausschrift + einer Institution entworfen, hat einen schmalen Schnitt fuer Tabellenkoepfe und + eine passende Mono fuer Kennzeichen. + font-display: swap nach docs/09-performance-und-ux.md. */ + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/ibm-plex-sans-latin-400-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/ibm-plex-sans-latin-ext-400-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, + U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, + U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/ibm-plex-sans-latin-500-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/ibm-plex-sans-latin-ext-500-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, + U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, + U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'IBM Plex Sans Condensed'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/ibm-plex-sans-condensed-latin-500-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'IBM Plex Sans Condensed'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/ibm-plex-sans-condensed-latin-ext-500-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, + U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, + U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/ibm-plex-mono-latin-400-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/ibm-plex-mono-latin-ext-400-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, + U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, + U+2C60-2C7F, U+A720-A7FF; +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..f447f86 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,230 @@ +@import 'tailwindcss'; +@import './fonts.css'; + +/* --------------------------------------------------------------------------- + Design-Tokens. Fachliche Grundlage: docs/10-design-system.md. + Bezugspunkt ist eine Registratur im Nachtdienst: lackierter Stahl, + Aktenpapier, Stempelfarbe. Die einzige Farbe kommt von der Klassifizierung. + --------------------------------------------------------------------------- */ + +@theme { + /* Grundpalette, dunkel ist der Auslieferungszustand */ + --color-ink-900: #15181a; /* Grundflaeche, kuehles Anthrazit, leicht gruenstichig */ + --color-ink-800: #1d2124; /* Panel */ + --color-ink-700: #262b2f; /* erhoehte Flaeche, Eingabefelder */ + --color-ink-500: #3a4147; /* Linien und Rahmen */ + --color-paper: #e4e7e4; /* Primaertext, papierfarbenes Off-White */ + --color-paper-dim: #939c99; /* Sekundaertext */ + --color-steel: #7a97a6; /* interaktiv: Links, Fokus, aktive Zustaende */ + + /* Klassifizierungsfarben. Das einzige Buntsystem, nirgends sonst benutzt. */ + --color-stamp-0: #8a918d; /* Offen, Grau */ + --color-stamp-1: #6d8a9c; /* Intern, Stahlblau */ + --color-stamp-2: #4e8f72; /* Vertraulich, Gruenspan */ + --color-stamp-3: #b08637; /* Geheim, Messing */ + --color-stamp-4: #b4543c; /* Streng geheim, Oxidrot */ + --color-stamp-5: #7e5e96; /* Leitungssache, Tiefviolett */ + + /* Semantische Ebene. Das helle Thema tauscht nur diese Werte. */ + --color-surface: var(--color-ink-900); + --color-panel: var(--color-ink-800); + --color-raised: var(--color-ink-700); + --color-line: var(--color-ink-500); + --color-text: var(--color-paper); + --color-text-dim: var(--color-paper-dim); + --color-accent: var(--color-steel); + + /* Schrift. IBM Plex, selbst gehostet, drei Rollen aus einer Familie. */ + --font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; + --font-condensed: 'IBM Plex Sans Condensed', 'IBM Plex Sans', ui-sans-serif, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, monospace; + + /* Skala 12 / 13 / 15 / 18 / 24 px */ + --text-2xs: 0.75rem; /* 12 */ + --text-xs: 0.8125rem; /* 13 */ + --text-sm: 0.9375rem; /* 15 */ + --text-base: 0.9375rem; /* 15, Fliesstext */ + --text-lg: 1.125rem; /* 18 */ + --text-xl: 1.5rem; /* 24 */ + + --text-2xs--line-height: 1.4; + --text-xs--line-height: 1.45; + --text-sm--line-height: 1.6; + --text-base--line-height: 1.6; + --text-lg--line-height: 1.45; + --text-xl--line-height: 1.3; + + /* Struktur: Eckenradius 2 px, ueberall gleich. Akten sind nicht abgerundet. */ + --radius-none: 0px; + --radius-sm: 2px; + --radius-DEFAULT: 2px; + --radius-md: 2px; + --radius-lg: 2px; + --radius-xl: 2px; + + /* Keine Schatten ausser dem Fokusring. */ + --shadow-sm: none; + --shadow-DEFAULT: none; + --shadow-md: none; + --shadow-lg: none; + + /* Zeilenlaenge hoechstens 72 Zeichen */ + --container-measure: 72ch; + + /* Die 3 px breite Klassifizierungskante links am Objekt */ + --spacing-edge: 3px; +} + +/* Helles Thema. Verpflichtend mitzuliefern, ausgebaut in Phase 9. */ +[data-theme='light'] { + --color-surface: #eceeea; + --color-panel: #e2e5e1; + --color-raised: #f4f5f2; + --color-line: #b9beb8; + --color-text: #1b1f21; + --color-text-dim: #545c58; + --color-accent: #3d5f70; +} + +@layer base { + *, + *::before, + *::after { + border-color: var(--color-line); + } + + html { + color-scheme: dark; + background-color: var(--color-surface); + } + + [data-theme='light'] { + color-scheme: light; + } + + body { + background-color: var(--color-surface); + color: var(--color-text); + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + } + + /* Spaltenkoepfe, Stempel und Reiter tragen den schmalen Schnitt. + Kein Versalsatz: der Stempelcharakter kommt aus Schnitt, Rahmen und Farbe. */ + th, + legend { + font-family: var(--font-condensed); + font-weight: 500; + text-transform: none; + } + + /* Aktenzeichen, Zeitstempel, Hashes und Dienstnummern in Mono. */ + code, + kbd, + samp, + time { + font-family: var(--font-mono); + } + + h1, + h2, + h3 { + font-weight: 500; + text-wrap: balance; + } + + h1 { + font-size: var(--text-xl); + } + h2 { + font-size: var(--text-lg); + } + h3 { + font-size: var(--text-base); + } + + a { + color: var(--color-accent); + text-underline-offset: 2px; + } + + /* Sichtbarer Fokusring in --steel, der einzige Schatten im System. */ + :focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: 2px; + } + + ::selection { + background-color: color-mix(in srgb, var(--color-accent) 40%, transparent); + } +} + +@layer components { + /* Der eine mutige Moment: die Schwaerzung. Ein massiver Balken mit feiner + Rahmenlinie, der beim ersten Erscheinen einmal von links nach rechts + durchwischt. Sonst bewegt sich in der Oberflaeche nichts von selbst. */ + .redaction { + display: inline-block; + position: relative; + overflow: hidden; + vertical-align: baseline; + background-color: var(--color-ink-900); + border: 1px solid var(--color-line); + border-radius: 2px; + color: transparent; + user-select: none; + } + + .redaction::after { + content: ''; + position: absolute; + inset: 0; + background-color: var(--color-ink-700); + transform-origin: left center; + animation: redaction-wipe 150ms ease-out 1 both; + } + + @keyframes redaction-wipe { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } + } + + /* Zeitachse des Verlaufs: durchgehende senkrechte Linie. Der Chat hat keine. + Der Unterschied muss ohne Beschriftung erkennbar sein. */ + .timeline { + position: relative; + padding-left: 1.5rem; + } + + .timeline::before { + content: ''; + position: absolute; + left: 0.4375rem; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--color-line); + } +} + +@media (prefers-reduced-motion: reduce) { + .redaction::after { + animation: none; + transform: scaleX(0); + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..c7ec80e --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata, Viewport } from 'next' +import { de } from '@/lib/i18n/de' +import './globals.css' + +export const metadata: Metadata = { + title: { default: de.app.name, template: `%s · ${de.app.shortName}` }, + description: de.app.description, + robots: { index: false, follow: false }, +} + +export const viewport: Viewport = { + themeColor: '#15181A', + width: 'device-width', + initialScale: 1, +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {/* Nur die beiden Schnitte vorladen, die auf jeder Seite vorkommen. */} + + + + + + {de.nav.skipToContent} + + {children} + + + ) +} diff --git a/web/app/loading.tsx b/web/app/loading.tsx new file mode 100644 index 0000000..fccf29d --- /dev/null +++ b/web/app/loading.tsx @@ -0,0 +1,24 @@ +import { SkeletonLine, Skeleton } from '@/components/Skeleton' + +/** + * Jede Route hat ein Skelett, das die spaetere Struktur nachbildet. + * Nie eine leere Seite mit Spinner, siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + +
+
+ +
+ + + +
+
+ ) +} diff --git a/web/app/not-found.tsx b/web/app/not-found.tsx new file mode 100644 index 0000000..759bd3e --- /dev/null +++ b/web/app/not-found.tsx @@ -0,0 +1,10 @@ +import { de } from '@/lib/i18n/de' + +export default function NotFound() { + return ( +
+

{de.errors.notFound.title}

+

{de.errors.notFound.body}

+
+ ) +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..0a6123e --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,72 @@ +import { Suspense } from 'react' +import { collectHealth, type ComponentState } from '@/lib/health/check' +import { de } from '@/lib/i18n/de' +import { SkeletonLine } from '@/components/Skeleton' + +export const dynamic = 'force-dynamic' + +const STATE_LABEL: Record = { + up: de.health.ok, + degraded: de.health.degraded, + down: de.health.down, +} + +async function SystemState() { + const report = await collectHealth() + return ( + + + + + + + + + + {Object.entries(report.components).map(([key, value]) => ( + + + + + ))} + +
+ {de.health.checkedAt(new Date(report.checkedAt).toLocaleString('de-DE'))} +
+ {de.health.service} + + {de.health.state} +
{de.health.components[key] ?? key} + {/* Zustand als Text, nicht nur als Farbe. */} + {STATE_LABEL[value.status]} + {value.detail ? ( + {value.detail} + ) : null} +
+ ) +} + +export default function Home() { + return ( +
+

{de.app.name}

+

{de.app.description}

+ +
+ +

{de.health.heading}

+ + + + + + + } + > + + +
+ ) +} diff --git a/web/components/Skeleton.tsx b/web/components/Skeleton.tsx new file mode 100644 index 0000000..c28b087 --- /dev/null +++ b/web/components/Skeleton.tsx @@ -0,0 +1,24 @@ +/** + * Platzhalterflaeche fuer Ladezustaende. Das Skelett bildet die spaetere + * Struktur exakt nach (gleiche Hoehen, gleiche Spaltenbreiten), damit beim + * Eintreffen der Daten nichts springt. Siehe docs/09-performance-und-ux.md. + */ +export function Skeleton({ + className = '', + width, +}: { + className?: string + width?: string | number +}) { + return ( + + ) +} + +export function SkeletonLine({ width = '100%' }: { width?: string | number }) { + return +} diff --git a/web/components/StampMark.tsx b/web/components/StampMark.tsx new file mode 100644 index 0000000..3fc76f9 --- /dev/null +++ b/web/components/StampMark.tsx @@ -0,0 +1,38 @@ +import { de } from '@/lib/i18n/de' + +const STAMP_COLOR: Record = { + 0: 'var(--color-stamp-0)', + 1: 'var(--color-stamp-1)', + 2: 'var(--color-stamp-2)', + 3: 'var(--color-stamp-3)', + 4: 'var(--color-stamp-4)', + 5: 'var(--color-stamp-5)', +} + +export function stampColor(level: number): string { + return STAMP_COLOR[level] ?? STAMP_COLOR[0]! +} + +/** + * Stempelmarke oben rechts am Objekt. Der Stempelcharakter kommt aus dem + * schmalen Schnitt, der Rahmenlinie und der Farbe, nicht aus Grossbuchstaben. + * Die Stufe steht zusaetzlich als Text, nicht nur als Farbe. + */ +export function StampMark({ level }: { level: number }) { + return ( + + + {level} + + {de.clearance.names[level] ?? de.common.unknown} + + ) +} + +/** Die 3 px breite Klassifizierungskante links am Objekt. */ +export function classificationEdgeStyle(level: number): React.CSSProperties { + return { borderLeft: `var(--spacing-edge) solid ${stampColor(level)}` } +} diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..96aa586 --- /dev/null +++ b/web/e2e/smoke.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test' + +test('Startseite liefert Titel und Systemzustand', async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('heading', { level: 1 })).toHaveText('AfiS-Aktensystem') + await expect(page.getByRole('heading', { name: 'Systemzustand' })).toBeVisible() + await expect(page.getByRole('cell', { name: 'Datenbank' })).toBeVisible() +}) + +test('Gesundheitsendpunkt antwortet mit allen drei Abhaengigkeiten', async ({ request }) => { + const response = await request.get('/api/health') + expect([200, 503]).toContain(response.status()) + const body = await response.json() + expect(Object.keys(body.components).sort()).toEqual(['clamav', 'database', 'discordGateway']) +}) + +test('Antwort traegt eine strikte Inhaltsrichtlinie', async ({ page }) => { + const response = await page.goto('/') + const csp = response?.headers()['content-security-policy'] ?? '' + expect(csp).toContain("object-src 'none'") + expect(csp).toContain("frame-ancestors 'none'") + expect(csp).toMatch(/script-src [^;]*'nonce-/) + expect(csp).not.toMatch(/script-src [^;]*'unsafe-inline'/) +}) diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000..effef50 --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,21 @@ +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { FlatCompat } from '@eslint/eslintrc' + +const compat = new FlatCompat({ baseDirectory: dirname(fileURLToPath(import.meta.url)) }) + +export default [ + { ignores: ['.next/**', 'node_modules/**', 'next-env.d.ts', 'playwright-report/**'] }, + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + rules: { + // Sprachkonvention: Oberflaechentexte gehoeren nach lib/i18n/de.ts, + // nie hart ins JSX. Siehe CLAUDE.md. + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'error', + }, + }, +] diff --git a/web/lib/auth/permissions.ts b/web/lib/auth/permissions.ts new file mode 100644 index 0000000..942513d --- /dev/null +++ b/web/lib/auth/permissions.ts @@ -0,0 +1,57 @@ +/** + * Rechte-Enum aus docs/03-auth-und-rechte.md. RoleMapping.permissions ist in der + * Datenbank ein String[], diese Liste ist die verbindliche Menge gültiger Werte + * und die Grundlage der Kästchen im Adminbereich. + */ +export const PERMISSIONS = [ + 'case.read', + 'case.create', + 'case.edit', + 'case.close', + 'case.reopen', + 'case.delete', + 'case.edit_closed', + 'entry.create', + 'entry.correct', + 'entry.edit_own', + 'entry.edit_any', + 'entry.retract', + 'personnel.read', + 'personnel.edit', + 'template.manage', + 'roles.manage', + 'audit.read', + 'export.approve', + 'admin', +] as const + +export type Permission = (typeof PERMISSIONS)[number] + +/** + * Diese drei sind im Auslieferungszustand keiner Rolle zugeordnet. Der Leiter + * schaltet sie bewusst frei. Der Adminbereich weist beim Zuweisen darauf hin. + */ +export const OPT_IN_PERMISSIONS: readonly Permission[] = [ + 'entry.edit_own', + 'entry.edit_any', + 'entry.retract', +] + +export function isPermission(value: string): value is Permission { + return (PERMISSIONS as readonly string[]).includes(value) +} + +/** Aufgelöste Rechte eines Nutzers für genau einen Request. */ +export type EffectivePermissions = { + userId: string + /** Höchste Stufe aller Rollen. */ + clearanceLevel: number + /** Vereinigung aller Rollen. */ + permissions: Set + /** Aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'. */ + isAdmin: boolean +} + +export function can(p: EffectivePermissions, permission: Permission): boolean { + return p.isAdmin || p.permissions.has(permission) +} diff --git a/web/lib/db.ts b/web/lib/db.ts new file mode 100644 index 0000000..e4f63c1 --- /dev/null +++ b/web/lib/db.ts @@ -0,0 +1,14 @@ +import { createPrismaClient, type PrismaClient } from '@afis/db' + +/** + * Ein Client pro Prozess. Im Dev-Server ueberlebt er den Hot Reload ueber + * globalThis, sonst reisst jede Aenderung eine neue Verbindung auf. + * Der Pool liegt im Betrieb hinter pgbouncer im Transaktionsmodus. + */ +const globalForPrisma = globalThis as unknown as { afisPrisma?: PrismaClient } + +export const db: PrismaClient = globalForPrisma.afisPrisma ?? createPrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.afisPrisma = db +} diff --git a/web/lib/env.ts b/web/lib/env.ts new file mode 100644 index 0000000..23a7dd7 --- /dev/null +++ b/web/lib/env.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' + +/** + * Umgebungsvariablen werden beim Start einmal geprueft. Fehlt etwas, soll der + * Container sofort und mit klarer Meldung sterben statt spaeter beim ersten + * Request. Die Werte stehen im .env-Reiter von Dockge, Vorlage ist .env.example. + */ +const schema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + + DATABASE_URL: z.string().min(1), + DIRECT_DATABASE_URL: z.string().min(1).optional(), + + APP_URL: z.url(), + AUTH_SECRET: z.string().min(32), + + DISCORD_CLIENT_ID: z.string().min(1), + DISCORD_CLIENT_SECRET: z.string().min(1), + DISCORD_BOT_TOKEN: z.string().min(1), + DISCORD_GUILD_ID: z.string().min(1), + + /// Notfallzugang. Diese Discord-IDs haben immer Stufe 5 und alle Rechte, + /// unabhaengig vom Rollen-Mapping. Siehe docs/03-auth-und-rechte.md. + BOOTSTRAP_ADMIN_IDS: z + .string() + .default('') + .transform((v) => v.split(',').map((s) => s.trim()).filter(Boolean)), + + FILES_DIR: z.string().default('/data/files'), + CLAMAV_SOCKET: z.string().default('/var/run/clamav/clamd.sock'), + PDF_SERVICE_URL: z.url().optional(), + + TZ: z.string().default('Europe/Berlin'), +}) + +export type Env = z.infer + +let cached: Env | null = null + +export function getEnv(): Env { + if (cached) return cached + const parsed = schema.safeParse(process.env) + if (!parsed.success) { + const missing = parsed.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('\n ') + throw new Error(`Konfiguration unvollständig:\n ${missing}`) + } + cached = parsed.data + return cached +} + +/** Nur fuer Tests, damit eine geaenderte Umgebung neu gelesen wird. */ +export function resetEnvCache(): void { + cached = null +} diff --git a/web/lib/health/check.ts b/web/lib/health/check.ts new file mode 100644 index 0000000..cde47c5 --- /dev/null +++ b/web/lib/health/check.ts @@ -0,0 +1,77 @@ +import { db } from '@/lib/db' +import { getEnv } from '@/lib/env' +import { pingClamav } from '@/lib/health/clamav' + +export type ComponentState = 'up' | 'degraded' | 'down' + +export type HealthReport = { + status: ComponentState + checkedAt: string + components: Record<'database' | 'discordGateway' | 'clamav', { + status: ComponentState + detail?: string + }> +} + +/** Der Worker gilt als abgehaengt, wenn sein Lebenszeichen aelter ist. */ +const HEARTBEAT_MAX_AGE_MS = 3 * 60_000 + +async function checkDatabase(): Promise<{ status: ComponentState; detail?: string }> { + try { + await db.$queryRaw`SELECT 1` + return { status: 'up' } + } catch (error) { + return { status: 'down', detail: error instanceof Error ? error.message : 'unbekannt' } + } +} + +async function checkDiscordGateway(): Promise<{ status: ComponentState; detail?: string }> { + try { + const beat = await db.serviceHeartbeat.findUnique({ where: { service: 'worker' } }) + if (!beat) return { status: 'down', detail: 'kein Lebenszeichen' } + + const ageMs = Date.now() - beat.at.getTime() + if (ageMs > HEARTBEAT_MAX_AGE_MS) { + return { status: 'down', detail: `Lebenszeichen ${Math.round(ageMs / 1000)} s alt` } + } + if (beat.status !== 'up') { + return { status: beat.status === 'degraded' ? 'degraded' : 'down', detail: beat.detail ?? undefined } + } + return { status: 'up' } + } catch { + return { status: 'down', detail: 'nicht abfragbar' } + } +} + +async function checkClamav(): Promise<{ status: ComponentState; detail?: string }> { + const ok = await pingClamav(getEnv().CLAMAV_SOCKET) + return ok ? { status: 'up' } : { status: 'down', detail: 'clamd antwortet nicht' } +} + +/** + * Sammelt den Zustand der drei Abhaengigkeiten. Die Gesamtbewertung faellt + * bewusst nur auf `down`, wenn die Datenbank weg ist: Worker, clamav und pdf + * haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll + * nicht den ganzen Web-Container als tot markieren. Sichtbar bleibt der Ausfall + * trotzdem, in dieser Antwort und in Dockge am jeweiligen Dienst. + */ +export async function collectHealth(): Promise { + const [database, discordGateway, clamav] = await Promise.all([ + checkDatabase(), + checkDiscordGateway(), + checkClamav(), + ]) + + const status: ComponentState = + database.status === 'down' + ? 'down' + : discordGateway.status === 'up' && clamav.status === 'up' + ? 'up' + : 'degraded' + + return { + status, + checkedAt: new Date().toISOString(), + components: { database, discordGateway, clamav }, + } +} diff --git a/web/lib/health/clamav.ts b/web/lib/health/clamav.ts new file mode 100644 index 0000000..8ad1e07 --- /dev/null +++ b/web/lib/health/clamav.ts @@ -0,0 +1,24 @@ +import net from 'node:net' + +/** + * Fragt clamd ueber den Unix-Socket. Der Socket liegt im geteilten Volume + * clamav_socket, siehe compose.yaml. Antwort auf zPING ist "PONG". + */ +export function pingClamav(socketPath: string, timeoutMs = 2000): Promise { + return new Promise((resolve) => { + let settled = false + const done = (ok: boolean) => { + if (settled) return + settled = true + socket.destroy() + resolve(ok) + } + + const socket = net.createConnection(socketPath) + socket.setTimeout(timeoutMs) + socket.on('connect', () => socket.write('zPING\0')) + socket.on('data', (chunk) => done(chunk.toString().includes('PONG'))) + socket.on('timeout', () => done(false)) + socket.on('error', () => done(false)) + }) +} diff --git a/web/lib/i18n/de.ts b/web/lib/i18n/de.ts new file mode 100644 index 0000000..3804fed --- /dev/null +++ b/web/lib/i18n/de.ts @@ -0,0 +1,160 @@ +/** + * Alle Oberflaechentexte. Nie deutsche Strings hart ins JSX, siehe CLAUDE.md. + * Die Begriffe folgen dem Glossar in docs/01-produkt.md. + * + * Schreibweise nach docs/10-design-system.md: + * - Aktive Verben, die sagen, was passiert: "Vermerk speichern", nicht "Absenden" + * - Dieselbe Bezeichnung durch den ganzen Ablauf + * - Fehler nennen Ursache und naechsten Schritt, ohne Entschuldigung + * - Leere Zustaende sind eine Aufforderung + * - Keine Ausrufezeichen, kein "bitte", kein "erfolgreich" + */ +export const de = { + app: { + name: 'AfiS-Aktensystem', + shortName: 'AfiS', + description: 'Fallakten, Personalakten und Ermittlungen der Abteilung für innere Sicherheit', + }, + + nav: { + dashboard: 'Übersicht', + cases: 'Fallakten', + personnel: 'Personalakten', + search: 'Suche', + admin: 'Verwaltung', + skipToContent: 'Zum Inhalt springen', + }, + + auth: { + signIn: 'Mit Discord anmelden', + signOut: 'Abmelden', + signedInAs: 'Angemeldet als', + notInGuild: { + title: 'Du bist nicht auf dem AfiS-Discord', + body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.', + }, + sessionEnded: { + title: 'Sitzung beendet', + body: 'Deine Rollen haben sich geändert. Melde dich neu an.', + }, + error: { + title: 'Anmeldung nicht möglich', + body: 'Der Anmeldevorgang wurde abgebrochen. Erneut versuchen.', + }, + }, + + clearance: { + label: 'Freigabestufe', + classification: 'Klassifizierung', + level: (n: number) => `Stufe ${n}`, + names: { + 0: 'Offen', + 1: 'Intern', + 2: 'Vertraulich', + 3: 'Geheim', + 4: 'Streng geheim', + 5: 'Leitungssache', + } as Record, + locked: 'Inhalt gesperrt', + lockedHint: (n: number) => `Sichtbar ab Stufe ${n}`, + redacted: 'Geschwärzt', + redactedHint: (n: number) => `Geschwärzt, ab Stufe ${n} sichtbar`, + }, + + case: { + one: 'Fallakte', + many: 'Fallakten', + number: 'Aktenzeichen', + title: 'Titel', + type: 'Fallart', + status: 'Status', + dueAt: 'Wiedervorlage', + outcome: 'Ergebnis', + createdBy: 'Angelegt von', + timeline: 'Verlauf', + sections: 'Abschnitte', + chat: 'Aktenchat', + changes: 'Änderungen', + participants: 'Beteiligte', + links: 'Verknüpfungen', + attachments: 'Anhänge', + empty: 'Noch keine Fallakte. Erste Fallakte anlegen.', + statusNames: { + DRAFT: 'Entwurf', + OPEN: 'Offen', + DORMANT: 'Ruhend', + REVIEW: 'In Prüfung', + CLOSED: 'Abgeschlossen', + ARCHIVED: 'Archiviert', + SEALED: 'Versiegelt', + } as Record, + }, + + entry: { + one: 'Vermerk', + many: 'Vermerke', + create: 'Vermerk speichern', + empty: 'Noch kein Vermerk. Ersten Vermerk anlegen.', + edited: (v: number) => `bearbeitet · v${v}`, + history: 'Änderungsverlauf', + changeReason: 'Begründung', + retracted: (date: string, person: string, reason: string) => + `Eintrag zurückgezogen am ${date} durch ${person} · Begründung: ${reason}`, + types: { + NOTE: 'Vermerk', + INTERROGATION: 'Befragung', + EVIDENCE: 'Beweismittel', + STATUS_CHANGE: 'Statuswechsel', + CORRECTION: 'Korrektur', + SYSTEM: 'System', + } as Record, + }, + + personnel: { + one: 'Personalakte', + many: 'Personalakten', + serviceNumber: 'Dienstnummer', + rank: 'Dienstgrad', + department: 'Abteilung', + empty: 'Noch keine Personalakte. Erste Personalakte anlegen.', + }, + + common: { + save: 'Speichern', + cancel: 'Abbrechen', + undo: 'Rückgängig', + retry: 'Erneut versuchen', + loading: 'Wird geladen', + offline: 'Keine Verbindung, wird nachgesendet', + unknown: 'Unbekannt', + }, + + errors: { + notFound: { + title: 'Nicht gefunden', + body: 'Die Seite gibt es nicht, oder du darfst sie nicht sehen.', + }, + unexpected: { + title: 'Nicht geladen', + body: 'Der Abruf ist fehlgeschlagen. Erneut versuchen.', + }, + conflict: 'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.', + }, + + health: { + heading: 'Systemzustand', + service: 'Dienst', + state: 'Zustand', + checkedAt: (when: string) => `Zustand der Dienste, abgefragt ${when}`, + ok: 'Betriebsbereit', + degraded: 'Eingeschränkt', + down: 'Gestört', + components: { + database: 'Datenbank', + discordGateway: 'Discord-Gateway', + clamav: 'Virenprüfung', + } as Record, + }, +} as const + +export type Messages = typeof de diff --git a/web/middleware.ts b/web/middleware.ts new file mode 100644 index 0000000..89b283d --- /dev/null +++ b/web/middleware.ts @@ -0,0 +1,51 @@ +import { NextResponse, type NextRequest } from 'next/server' + +/** + * Strikte CSP mit Nonce. Kein 'unsafe-inline' fuer Skripte, siehe + * docs/07-dateien-und-sicherheit.md. Next.js liest die CSP aus dem Request und + * haengt den Nonce an seine eigenen Skript-Tags. + * + * Im Dev-Server braucht der React Refresh Runtime 'unsafe-eval'. + */ +export function middleware(request: NextRequest): NextResponse { + const nonce = btoa(crypto.randomUUID()) + const isDev = process.env.NODE_ENV === 'development' + + const csp = [ + `default-src 'self'`, + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''}`, + // Tailwind erzeugt zur Laufzeit keine Styles, aber Next setzt inline-Styles + // fuer den Bildplatzhalter. Style-Nonces unterstuetzt Next nicht. + `style-src 'self' 'unsafe-inline'`, + `img-src 'self' blob: data:`, + `font-src 'self'`, + // Der SSE-Endpunkt laeuft ueber dieselbe Herkunft. + `connect-src 'self'`, + `object-src 'none'`, + `base-uri 'none'`, + `form-action 'self'`, + `frame-ancestors 'none'`, + `upgrade-insecure-requests`, + ].join('; ') + + const headers = new Headers(request.headers) + headers.set('x-nonce', nonce) + headers.set('content-security-policy', csp) + + const response = NextResponse.next({ request: { headers } }) + response.headers.set('content-security-policy', csp) + return response +} + +export const config = { + matcher: [ + // Statische Assets und Bild-Optimierung brauchen keine CSP-Berechnung. + { + source: '/((?!_next/static|_next/image|fonts|favicon.ico).*)', + missing: [ + { type: 'header', key: 'next-router-prefetch' }, + { type: 'header', key: 'purpose', value: 'prefetch' }, + ], + }, + ], +} diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 0000000..08f83af --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,51 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { NextConfig } from 'next' +import bundleAnalyzer from '@next/bundle-analyzer' + +const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true' }) + +const nextConfig: NextConfig = { + // Standalone-Build fuer das Container-Image, siehe docker/web.Dockerfile. + output: 'standalone', + // fileURLToPath statt URL.pathname: der Projektpfad enthält ein Leerzeichen, + // das pathname als %20 stehen ließe. Der Standalone-Build landete dadurch in + // einem falsch benannten Unterordner. + outputFileTracingRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), + + reactStrictMode: true, + poweredByHeader: false, + + // Der Cloudflare Tunnel komprimiert, im Stack ist dafuer nichts zu tun. + compress: false, + + experimental: { + // Autoren-, Rollen- und Vorlagendaten werden prozesslokal gecacht, + // siehe docs/09-performance-und-ux.md. + staleTimes: { dynamic: 30, static: 180 }, + }, + + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'same-origin' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()', + }, + ], + }, + { + // Selbst gehostete Schriften sind unveraenderlich. + source: '/fonts/:file*', + headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }], + }, + ] + }, +} + +export default withBundleAnalyzer(nextConfig) diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f54fdd3 --- /dev/null +++ b/web/package.json @@ -0,0 +1,48 @@ +{ + "name": "@afis/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint app lib components tests --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "analyze": "ANALYZE=true next build" + }, + "dependencies": { + "@afis/db": "workspace:*", + "next": "15.5.25", + "next-auth": "5.0.0-beta.32", + "pg": "8.13.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "uuidv7": "1.0.2", + "zod": "^4.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@fontsource/ibm-plex-mono": "^5.0.0", + "@fontsource/ibm-plex-sans": "^5.0.0", + "@fontsource/ibm-plex-sans-condensed": "^5.0.0", + "@next/bundle-analyzer": "15.5.25", + "@playwright/test": "1.62.1", + "@tailwindcss/postcss": "4.3.3", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.1.0", + "@types/node": "^22.10.0", + "@types/pg": "^8.11.10", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "9.39.5", + "eslint-config-next": "15.5.25", + "jsdom": "^25.0.1", + "tailwindcss": "4.3.3", + "typescript": "5.9.3", + "vitest": "4.1.11" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..c9f34e6 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test' + +const PORT = Number(process.env.E2E_PORT ?? 3100) +const baseURL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}` + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL, + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + trace: 'on-first-retry', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'mobil', use: { ...devices['Pixel 7'] } }, + ], + webServer: process.env.E2E_BASE_URL + ? undefined + : { + // Derselbe Standalone-Server, den auch das Container-Image startet. + command: `pnpm build && node .next/standalone/web/server.js`, + url: `${baseURL}/api/health`, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + env: { PORT: String(PORT), HOSTNAME: '127.0.0.1' }, + }, +}) diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs new file mode 100644 index 0000000..a7f73a2 --- /dev/null +++ b/web/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/web/public/fonts/ibm-plex-mono-latin-400-normal.woff2 b/web/public/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/web/public/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/web/public/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 b/web/public/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 new file mode 100644 index 0000000..732c64d Binary files /dev/null and b/web/public/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 differ diff --git a/web/public/fonts/ibm-plex-sans-condensed-latin-500-normal.woff2 b/web/public/fonts/ibm-plex-sans-condensed-latin-500-normal.woff2 new file mode 100644 index 0000000..cae4541 Binary files /dev/null and b/web/public/fonts/ibm-plex-sans-condensed-latin-500-normal.woff2 differ diff --git a/web/public/fonts/ibm-plex-sans-condensed-latin-ext-500-normal.woff2 b/web/public/fonts/ibm-plex-sans-condensed-latin-ext-500-normal.woff2 new file mode 100644 index 0000000..3274745 Binary files /dev/null and b/web/public/fonts/ibm-plex-sans-condensed-latin-ext-500-normal.woff2 differ diff --git a/web/public/fonts/ibm-plex-sans-latin-400-normal.woff2 b/web/public/fonts/ibm-plex-sans-latin-400-normal.woff2 new file mode 100644 index 0000000..f0ee65d Binary files /dev/null and b/web/public/fonts/ibm-plex-sans-latin-400-normal.woff2 differ diff --git a/web/public/fonts/ibm-plex-sans-latin-500-normal.woff2 b/web/public/fonts/ibm-plex-sans-latin-500-normal.woff2 new file mode 100644 index 0000000..6d5527e Binary files /dev/null and b/web/public/fonts/ibm-plex-sans-latin-500-normal.woff2 differ diff --git a/web/public/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 b/web/public/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 new file mode 100644 index 0000000..ef4be2d Binary files /dev/null and b/web/public/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 differ diff --git a/web/public/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 b/web/public/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 new file mode 100644 index 0000000..6b51734 Binary files /dev/null and b/web/public/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 differ diff --git a/web/tests/design-tokens.test.ts b/web/tests/design-tokens.test.ts new file mode 100644 index 0000000..32757e9 --- /dev/null +++ b/web/tests/design-tokens.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const css = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8') + +describe('Design-Tokens', () => { + it('enthaelt die Grundpalette aus dem Design-System', () => { + for (const hex of ['#15181a', '#1d2124', '#262b2f', '#3a4147', '#e4e7e4', '#939c99', '#7a97a6']) { + expect(css).toContain(hex) + } + }) + + it('enthaelt alle sechs Klassifizierungsfarben', () => { + for (const hex of ['#8a918d', '#6d8a9c', '#4e8f72', '#b08637', '#b4543c', '#7e5e96']) { + expect(css).toContain(hex) + } + }) + + it('setzt den Eckenradius durchgehend auf 2 px', () => { + const radii = [...css.matchAll(/--radius-[\w-]+:\s*([^;]+);/g)].map((m) => m[1]!.trim()) + expect(radii.length).toBeGreaterThan(0) + for (const value of radii) expect(['0px', '2px']).toContain(value) + }) + + it('kennt keine Schatten ausser dem Fokusring', () => { + const shadows = [...css.matchAll(/--shadow-[\w-]+:\s*([^;]+);/g)].map((m) => m[1]!.trim()) + expect(shadows.length).toBeGreaterThan(0) + for (const value of shadows) expect(value).toBe('none') + }) + + it('respektiert prefers-reduced-motion bei der Schwaerzung', () => { + expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/) + }) +}) diff --git a/web/tests/health.test.ts b/web/tests/health.test.ts new file mode 100644 index 0000000..eb6d208 --- /dev/null +++ b/web/tests/health.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/db', () => ({ + db: { + $queryRaw: vi.fn(async () => [{ '?column?': 1 }]), + serviceHeartbeat: { findUnique: vi.fn(async () => ({ service: 'worker', status: 'up', detail: null, at: new Date() })) }, + }, +})) + +vi.mock('@/lib/env', () => ({ + getEnv: () => ({ CLAMAV_SOCKET: '/var/run/clamav/clamd.sock' }), +})) + +vi.mock('@/lib/health/clamav', () => ({ pingClamav: vi.fn(async () => true) })) + +describe('collectHealth', () => { + it('meldet betriebsbereit, wenn alle drei Abhaengigkeiten antworten', async () => { + const { collectHealth } = await import('@/lib/health/check') + const report = await collectHealth() + expect(report.status).toBe('up') + expect(report.components.database.status).toBe('up') + expect(report.components.discordGateway.status).toBe('up') + expect(report.components.clamav.status).toBe('up') + }) + + it('bleibt nur eingeschraenkt, wenn allein die Virenpruefung ausfaellt', async () => { + const clamav = await import('@/lib/health/clamav') + vi.mocked(clamav.pingClamav).mockResolvedValueOnce(false) + const { collectHealth } = await import('@/lib/health/check') + const report = await collectHealth() + expect(report.status).toBe('degraded') + }) +}) diff --git a/web/tests/i18n.test.ts b/web/tests/i18n.test.ts new file mode 100644 index 0000000..0283390 --- /dev/null +++ b/web/tests/i18n.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { de } from '@/lib/i18n/de' + +describe('Sprachkonvention', () => { + it('kennt alle sechs Freigabestufen', () => { + for (let level = 0; level <= 5; level++) { + expect(de.clearance.names[level]).toBeTypeOf('string') + } + expect(de.clearance.names[3]).toBe('Geheim') + }) + + it('verzichtet auf Ausrufezeichen und Entschuldigungen', () => { + const flat = JSON.stringify(de) + expect(flat).not.toMatch(/!/) + expect(flat.toLowerCase()).not.toMatch(/erfolgreich|entschuldigung/) + }) + + it('haelt deutsche Oberflaechentexte aus dem JSX heraus', () => { + // Umlaute und Eszett sind der billigste verlaessliche Marker fuer einen + // deutschen Text. Alles Sichtbare gehoert nach lib/i18n/de.ts, siehe CLAUDE.md. + const offenders: string[] = [] + const roots = ['app', 'components'] + + function walk(dir: string) { + for (const name of readdirSync(dir)) { + const path = join(dir, name) + if (statSync(path).isDirectory()) { + walk(path) + continue + } + if (!name.endsWith('.tsx')) continue + const source = readFileSync(path, 'utf8') + for (const [index, line] of source.split('\n').entries()) { + const withoutComments = line.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '') + if (/[äöüÄÖÜß]/.test(withoutComments)) { + offenders.push(`${path}:${index + 1}`) + } + } + } + } + + for (const root of roots) walk(join(process.cwd(), root)) + expect(offenders).toEqual([]) + }) +}) diff --git a/web/tests/setup.ts b/web/tests/setup.ts new file mode 100644 index 0000000..92fc0e5 --- /dev/null +++ b/web/tests/setup.ts @@ -0,0 +1,3 @@ +import '@testing-library/dom' + +process.env.TZ = 'Europe/Berlin' diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..503406d --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2023"], + "jsx": "preserve", + "moduleResolution": "Bundler", + "allowJs": true, + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "e2e"] +} diff --git a/web/vitest.config.mts b/web/vitest.config.mts new file mode 100644 index 0000000..bc50411 --- /dev/null +++ b/web/vitest.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + resolve: { tsconfigPaths: true }, + test: { + environment: 'jsdom', + globals: true, + include: ['tests/**/*.test.{ts,tsx}'], + setupFiles: ['./tests/setup.ts'], + }, +})