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
+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