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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
51984f17d4
commit
6b0cd331bb
@@ -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' },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
@@ -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)}` }
|
||||||
|
}
|
||||||
@@ -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'/)
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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\)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import '@testing-library/dom'
|
||||||
|
|
||||||
|
process.env.TZ = 'Europe/Berlin'
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user