Web-Anwendung: Design-Tokens, Texte, Gesundheitsendpunkt

Design-Tokens aus docs/10-design-system.md als CSS-Variablen: Grundpalette,
die sechs Klassifizierungsfarben, Eckenradius durchgehend 2 px, keine Schatten
ausser dem Fokusring. Der Schwärzungsbalken mit dem einmaligen Durchwischen
respektiert prefers-reduced-motion. Vier Tests halten die Tokens gegen das
Dokument.

IBM Plex selbst gehostet als woff2, latin und latin-ext getrennt über
unicode-range, font-display: swap, immutable-Cache.

Alle Oberflächentexte in lib/i18n/de.ts. Ein Test durchsucht app/ und
components/ nach Umlauten im JSX und schlägt fehl, wenn dort deutscher Text
steht. Er hat beim ersten Lauf gleich einen Verstoss gefunden.

/api/health prüft Datenbank, Discord-Gateway und ClamAV. Die Gesamtbewertung
fällt nur auf "down", wenn die Datenbank weg ist: die anderen Dienste haben
eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll nicht
den Web-Container als tot markieren.

Strikte CSP mit Nonce, kein unsafe-inline für Skripte. Die erste Route liegt
bei 100 kB JavaScript nach Kompression, Budget sind 120 kB.
This commit is contained in:
Harlekin
2026-09-01 00:01:41 +02:00
parent 8f0644e053
commit ade977538d
38 changed files with 1343 additions and 0 deletions
+16
View File
@@ -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<Response> {
const report = await collectHealth()
return Response.json(report, {
status: report.status === 'down' ? 503 : 200,
headers: { 'Cache-Control': 'no-store' },
})
}
+19
View File
@@ -0,0 +1,19 @@
'use client'
import { de } from '@/lib/i18n/de'
export default function Error({ reset }: { error: Error; reset: () => void }) {
return (
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
<h1 className="font-condensed">{de.errors.unexpected.title}</h1>
<p className="mt-2 text-text-dim">{de.errors.unexpected.body}</p>
<button
type="button"
onClick={reset}
className="mt-4 border border-line bg-raised px-3 py-1.5 font-condensed text-xs"
>
{de.common.retry}
</button>
</main>
)
}
+89
View File
@@ -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;
}
+230
View File
@@ -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;
}
}
+48
View File
@@ -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 (
<html lang="de">
<head>
{/* Nur die beiden Schnitte vorladen, die auf jeder Seite vorkommen. */}
<link
rel="preload"
href="/fonts/ibm-plex-sans-latin-400-normal.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/ibm-plex-mono-latin-400-normal.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
</head>
<body>
<a
href="#inhalt"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:border focus:border-line focus:bg-panel focus:px-3 focus:py-2"
>
{de.nav.skipToContent}
</a>
{children}
</body>
</html>
)
}
+24
View File
@@ -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 (
<main className="mx-auto max-w-(--container-measure) px-6 py-12">
<Skeleton className="h-[1.5rem]" width="18rem" />
<div className="mt-3">
<SkeletonLine />
<SkeletonLine width="70%" />
</div>
<hr className="my-8 border-line" />
<Skeleton className="h-[1.125rem]" width="10rem" />
<div className="mt-4">
<SkeletonLine />
<SkeletonLine />
<SkeletonLine />
</div>
</main>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { de } from '@/lib/i18n/de'
export default function NotFound() {
return (
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
<h1 className="font-condensed">{de.errors.notFound.title}</h1>
<p className="mt-2 text-text-dim">{de.errors.notFound.body}</p>
</main>
)
}
+72
View File
@@ -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<ComponentState, string> = {
up: de.health.ok,
degraded: de.health.degraded,
down: de.health.down,
}
async function SystemState() {
const report = await collectHealth()
return (
<table className="w-full max-w-(--container-measure) border-collapse text-sm">
<caption className="pb-2 text-left font-condensed text-xs text-text-dim">
{de.health.checkedAt(new Date(report.checkedAt).toLocaleString('de-DE'))}
</caption>
<thead>
<tr className="border-b border-line text-left text-xs text-text-dim">
<th scope="col" className="py-1.5 pr-4 font-normal">
{de.health.service}
</th>
<th scope="col" className="py-1.5 font-normal">
{de.health.state}
</th>
</tr>
</thead>
<tbody>
{Object.entries(report.components).map(([key, value]) => (
<tr key={key} className="border-b border-line/60">
<td className="py-2 pr-4">{de.health.components[key] ?? key}</td>
<td className="py-2">
{/* Zustand als Text, nicht nur als Farbe. */}
<span className="font-condensed">{STATE_LABEL[value.status]}</span>
{value.detail ? (
<span className="ml-2 font-mono text-2xs text-text-dim">{value.detail}</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)
}
export default function Home() {
return (
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
<h1 className="font-condensed">{de.app.name}</h1>
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.app.description}</p>
<hr className="my-8 border-line" />
<h2 className="mb-4 font-condensed">{de.health.heading}</h2>
<Suspense
fallback={
<div className="max-w-(--container-measure)">
<SkeletonLine width="14rem" />
<SkeletonLine />
<SkeletonLine />
<SkeletonLine />
</div>
}
>
<SystemState />
</Suspense>
</main>
)
}
+24
View File
@@ -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 (
<span
aria-hidden
className={`block bg-raised ${className}`}
style={width === undefined ? undefined : { width }}
/>
)
}
export function SkeletonLine({ width = '100%' }: { width?: string | number }) {
return <Skeleton className="h-[0.9rem] my-2" width={width} />
}
+38
View File
@@ -0,0 +1,38 @@
import { de } from '@/lib/i18n/de'
const STAMP_COLOR: Record<number, string> = {
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 (
<span
className="inline-flex items-center gap-1.5 border px-1.5 py-0.5 font-condensed text-2xs"
style={{ borderColor: stampColor(level), color: stampColor(level) }}
>
<span aria-hidden className="font-mono">
{level}
</span>
{de.clearance.names[level] ?? de.common.unknown}
</span>
)
}
/** Die 3 px breite Klassifizierungskante links am Objekt. */
export function classificationEdgeStyle(level: number): React.CSSProperties {
return { borderLeft: `var(--spacing-edge) solid ${stampColor(level)}` }
}
+24
View File
@@ -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'/)
})
+21
View File
@@ -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',
},
},
]
+57
View File
@@ -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<Permission>
/** Aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'. */
isAdmin: boolean
}
export function can(p: EffectivePermissions, permission: Permission): boolean {
return p.isAdmin || p.permissions.has(permission)
}
+14
View File
@@ -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
}
+56
View File
@@ -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<typeof schema>
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
}
+77
View File
@@ -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<HealthReport> {
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 },
}
}
+24
View File
@@ -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<boolean> {
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))
})
}
+160
View File
@@ -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<number, string>,
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<string, string>,
},
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<string, string>,
},
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<string, string>,
},
} as const
export type Messages = typeof de
+51
View File
@@ -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' },
],
},
],
}
+6
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.
+51
View File
@@ -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)
+48
View File
@@ -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"
}
}
+32
View File
@@ -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' },
},
})
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
+35
View File
@@ -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\)/)
})
})
+33
View File
@@ -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')
})
})
+47
View File
@@ -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([])
})
})
+3
View File
@@ -0,0 +1,3 @@
import '@testing-library/dom'
process.env.TZ = 'Europe/Berlin'
+16
View File
@@ -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"]
}
+13
View File
@@ -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'],
},
})