diff --git a/docs/KONVENTIONEN.md b/docs/KONVENTIONEN.md new file mode 100644 index 0000000..bc2116e --- /dev/null +++ b/docs/KONVENTIONEN.md @@ -0,0 +1,160 @@ +# Konventionen im Betreuungs-Cockpit + +Verbindlich für jeden, der Code in dieses Projekt schreibt. Wer davon +abweicht, erzeugt Reibung an der nächsten Schnittstelle. + +## Sprache + +- **Alles auf Deutsch**: Bezeichner, Kommentare, Commit-Nachrichten, + Oberflächentexte, Fehlermeldungen. Ausnahmen sind Framework-Vorgaben + (`page.tsx`, `layout.tsx`, `default`, `props`) und etablierte + Fachbegriffe ohne gute Übersetzung (`Token`, `Hash`, `Cookie`). +- Umlaute in **Oberflächentexten**: ja, korrekt geschrieben. + Umlaute in **Bezeichnern und Dateinamen**: nein, umschrieben + (`vermoegen`, `naechsterSchritt`, `zurueck`). +- Du-Ansprache. Keine Juristensprache ohne Erklärung — steht ein + Paragraph im Text, gehört eine Erklärung daneben. + +## Verzeichnisse + +``` +src/ + app/ + (auth)/ Anmeldung, Ersteinrichtung, Zweitfaktor + (app)/ Alle Fachseiten, hinter Anmeldung + 2FA + api/ + components/ + ui/ Basisbausteine (Button, Input, Card, …) + / Fachkomponenten je Modul + lib/ + crypto/ Feldverschlüsselung, Hashing, TOTP + auth/ Sitzung, Rollen, Rate-Limiting + validierung/ Zod-Schemata je Entität + / Fachlogik ohne React + server/ + actions/ Server Actions ("use server") +prisma/ +tests/ Vitest (Logik) +e2e/ Playwright (kritische Flows) +``` + +Ein Modul bringt mit: Seite(n) unter `app/(app)//`, Fachkomponenten +unter `components//`, Zod-Schema unter `lib/validierung/.ts`, +Server Actions unter `server/actions/.ts`, reine Logik unter +`lib//`. + +## Datenzugriff + +```ts +import { db, nichtGeloescht } from '@/lib/db' +``` + +- **Jede** Abfrage auf Fachdaten filtert `...nichtGeloescht` (Papierkorb). +- **Jede** Abfrage ist auf `betreuteId` eingegrenzt. +- Zugriff wird immer über `erfordereZugang(betreuteId, modul, art)` geprüft + — auch in Server Actions, nicht nur in der Seite. Nie nur im UI ausblenden. + +```ts +const { betreute, benutzer, darfSchreiben } = await erfordereZugang(id, 'FRISTEN', 'schreiben') +``` + +## Geldbeträge + +Immer `BigInt` in **Cent**, Feldname endet auf `Cent`. Anzeige über +`formatCent()`. Nie `number` für Beträge, nie `Decimal`, nie Fließkomma. +Auf dem Weg zu Client Components über `centZuZahl()`. + +## Datum + +- Fristen, Stichtage, Bescheiddaten: `@db.Date`, ohne Uhrzeit. +- Zeitstempel (`erstelltAm`, Protokolle): volles `DateTime`. +- Anzeige über `formatDatum()` / `formatDatumZeit()` aus `@/lib/utils`. +- Datumsrechnung immer über `date-fns`, nie mit `setDate` von Hand. + +## Verschlüsselte Felder + +Feldname endet auf `Verschluesselt`. Nie direkt schreiben: + +```ts +import { sealen, oeffnen } from '@/lib/crypto/feld' + +const daten = { ...rest, ...sealen('Vorgang', { beschreibung: eingabe.beschreibung }) } +const offen = oeffnen('Vorgang', datensatzAusDb) // offen.beschreibung ist Klartext +``` + +Neues verschlüsseltes Feld? Erst in `VERSCHLUESSELTE_FELDER` in +`src/lib/crypto/feld.ts` eintragen, sonst wirft `sealen()`. +Verschlüsselte Felder lassen sich **nicht** filtern oder sortieren. + +## Audit-Log + +Jede schreibende Aktion protokolliert: + +```ts +await protokollieren({ + aktion: 'GEAENDERT', + entitaet: 'Frist', + entitaetId: frist.id, + bezeichnung: frist.titel, + betreuteId, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + aenderung: aenderungErmitteln(vorher, nachher), +}) +``` + +## Server Actions + +- Immer `'use server'` oben, immer Zod-Validierung, immer Zugriffsprüfung. +- Rückgabe für Formulare: ein Zustandsobjekt + `{ fehler?: string; felder?: Record; werte?: … }`, + benutzt mit `useActionState`. Keine geworfenen Fehler ans Formular. +- Nach dem Schreiben `revalidatePath()` auf die betroffene Seite. +- Löschen heißt: `geloeschtAm` setzen. Endgültiges Löschen nur im + Papierkorb und nur nach `erfordereFrischeAnmeldung()`. + +## Oberfläche + +- Basisbausteine aus `@/components/ui/*`. Formularfelder immer in + `` einwickeln — das verdrahtet + Label, Erklärung und Fehler barrierefrei. +- Jede Seite beginnt mit ``. +- Leere Listen bekommen `` mit einem konkreten nächsten + Schritt, nie nur "keine Daten". +- Steht ein Paragraph auf der Seite, steht `` darunter. +- Mindestens 44 px Touchziele, Schrift nie unter 16 px in Eingabefeldern. +- Farbe ist nie die einzige Information — Ampeln immer mit Text. +- Tabellen scrollen im eigenen Rahmen, nie die Seite. +- Keine externen Fonts, keine CDNs, keine Tracker. Bilder als Data-URI + oder aus dem eigenen Objektspeicher. + +## Statusfarben (Ampel) + +| Lage | Variante | +|---|---| +| überfällig | `rot` | +| diese Woche fällig | `orange` | +| dieser Monat fällig | `gelb` | +| erledigt / im Plan | `gruen` | + +## Tests + +- Fachlogik (Fristberechnung, Beträge, Bewertungen): Vitest unter `tests/`. +- Kritische Abläufe: Playwright unter `e2e/`. +- Ein Test beschreibt auf Deutsch, was fachlich gelten soll — nicht, welche + Funktion aufgerufen wird. + +## Commits + +- Deutsch, Imperativ, erste Zeile ≤ 72 Zeichen. +- Körper erklärt **warum**, nicht was. +- Trailer am Ende, ohne Leerzeile dazwischen: + +``` +Etappe: 3 +Phase: Papier +``` + +Diese Trailer wertet `npm run protokoll` aus — daraus entsteht das +Bauprotokoll mit den Zeiten je Etappe. diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..14d66a4 --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,99 @@ +import { redirect } from 'next/navigation' +import { AppHuelle, SchnellerfassungKnopf } from '@/components/app-huelle' +import { BenutzerMenue } from '@/components/benutzer-menue' +import { BetreuteAuswahl } from '@/components/betreute-auswahl' +import { NAV_STRUKTUR, type NavEintrag } from '@/components/navigation' +import { aktiveBetreute, erfordereBenutzer, erfordereZweitenFaktor, meineBetreuten } from '@/lib/auth/sitzung' +import { darf } from '@/lib/auth/rollen' +import { ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen' +import { db, nichtGeloescht } from '@/lib/db' + +/** + * Rahmen aller Fachseiten. + * + * Hier laufen die drei Bedingungen zusammen, ohne die niemand an Daten + * kommt: angemeldet, zweiter Faktor eingerichtet, mindestens eine betreute + * Person zugeordnet. + */ +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const benutzer = await erfordereBenutzer() + await erfordereZweitenFaktor(benutzer) + + const betreute = await meineBetreuten(benutzer) + if (betreute.length === 0) { + // Erststart: es gibt noch keine Stammdaten. + redirect('/ersteinrichtung') + } + + const aktive = (await aktiveBetreute(benutzer))! + + // Zugang der aktiven Person bestimmt, welche Menuepunkte sichtbar sind. + const eintrag = await db.betreuungsZugang.findUnique({ + where: { userId_betreuteId: { userId: benutzer.id, betreuteId: aktive.id } }, + select: { rolle: true, freigegebeneModule: true }, + }) + const zugang = eintrag + ? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule } + : benutzer.rolle === 'ADMIN' + ? { rolle: 'ADMIN' as const, freigegebeneModule: [] } + : null + + // Zaehler fuer die Navigation: was ist ueberfaellig, was liegt neu im + // Posteingang? Beides beantwortet "wo muss ich hin?" schon im Menue. + const heute = new Date() + heute.setHours(0, 0, 0, 0) + + const [ueberfaellig, neuImPosteingang] = await Promise.all([ + db.frist.count({ + where: { betreuteId: aktive.id, status: 'OFFEN', datum: { lt: heute }, ...nichtGeloescht }, + }), + db.dokument.count({ + where: { betreuteId: aktive.id, posteingangStatus: 'NEU', ...nichtGeloescht }, + }), + ]) + + const navigation: NavEintrag[] = NAV_STRUKTUR.filter((e) => darf(zugang, e.modul, 'lesen')).map( + (e) => ({ + ...e, + zaehler: + e.href === '/fristen' ? ueberfaellig : e.href === '/dokumente' ? neuImPosteingang : undefined, + dringend: e.href === '/fristen', + }), + ) + + return ( + ({ + id: b.id, + vorname: b.vorname, + nachname: b.nachname, + geburtsdatum: b.geburtsdatum ? b.geburtsdatum.toISOString() : null, + }))} + /> + } + benutzerbereich={ + + } + schnellerfassung={ + + } + > + {children} + + ) +} diff --git a/src/app/(auth)/anmelden/formular.tsx b/src/app/(auth)/anmelden/formular.tsx new file mode 100644 index 0000000..03f6cb7 --- /dev/null +++ b/src/app/(auth)/anmelden/formular.tsx @@ -0,0 +1,84 @@ +'use client' + +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { AlertCircle, Loader2, LogIn } from 'lucide-react' +import { anmelden, type AnmeldeZustand } from '@/server/actions/anmelden' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Feld } from '@/components/feld' + +function Absendeknopf() { + const { pending } = useFormStatus() + return ( + + ) +} + +export function AnmeldeFormular({ weiter }: { weiter: string }) { + const [zustand, aktion] = useActionState(anmelden, {}) + + return ( +
+ + + {zustand.fehler && ( + + + )} + + + + + + + + + + + + + + + + ) +} diff --git a/src/app/(auth)/anmelden/page.tsx b/src/app/(auth)/anmelden/page.tsx new file mode 100644 index 0000000..1a94be9 --- /dev/null +++ b/src/app/(auth)/anmelden/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { ShieldCheck } from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { aktuellerBenutzer } from '@/lib/auth/sitzung' +import { db } from '@/lib/db' +import { AnmeldeFormular } from './formular' + +export const metadata: Metadata = { title: 'Anmelden' } + +/** Fachdaten dürfen nie aus einem Cache kommen. */ +export const dynamic = 'force-dynamic' + +export default async function AnmeldenSeite({ + searchParams, +}: { + searchParams: Promise<{ weiter?: string }> +}) { + // Wer schon angemeldet ist, soll nicht am Anmeldeformular hängen bleiben. + if (await aktuellerBenutzer()) redirect('/heute') + + // Gibt es überhaupt schon einen Zugang? Beim allerersten Start nicht. + const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } }) + if (anzahlBenutzer === 0) redirect('/ersteinrichtung') + + const { weiter = '/heute' } = await searchParams + + return ( + <> + + + Anmelden + + E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen. + + + + + + + +
+
+ + ) +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..9ac2c3b --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,38 @@ +import Link from 'next/link' + +/** + * Rahmen fuer Anmeldung, Ersteinrichtung und Zweitfaktor. + * Bewusst schmal und ruhig - hier gibt es genau eine Aufgabe. + */ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+ + Betreuungs-Cockpit +
+ {children} +
+
+ +
+
+ + Datenschutz + + + Daten bleiben in der EU + + Keine Rechtsberatung +
+
+
+ ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..4351ff7 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,65 @@ +import type { Metadata, Viewport } from 'next' +import { headers } from 'next/headers' +import './globals.css' + +export const metadata: Metadata = { + title: { + default: 'Betreuungs-Cockpit', + template: '%s · Betreuungs-Cockpit', + }, + description: + 'Arbeits- und Dokumentationswerkzeug für die ehrenamtliche rechtliche Betreuung: ' + + 'Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe.', + // Kein Indexieren - hier stehen Gesundheits- und Finanzdaten. + robots: { index: false, follow: false, nocache: true }, + applicationName: 'Betreuungs-Cockpit', + formatDetection: { telephone: false }, +} + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + // Zoomen bleibt erlaubt. Wer schlecht sieht, muss heranzoomen können. + maximumScale: 5, + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#0b1220' }, + ], +} + +/** + * Setzt das Farbschema, BEVOR die Seite gezeichnet wird. + * + * Ohne dieses Skript blitzt beim Laden kurz das helle Thema auf, auch wenn + * dunkel eingestellt ist. Es laeuft synchron im und traegt die + * Nonce der Content-Security-Policy - Inline-Skripte ohne Nonce werden + * vom Browser blockiert. + */ +const THEMA_SKRIPT = ` +(function () { + try { + var gespeichert = localStorage.getItem('bc-thema'); + var dunkel = + gespeichert === 'dunkel' || + ((!gespeichert || gespeichert === 'system') && + window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.classList.toggle('dark', dunkel); + document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light'; + } catch (e) { + /* localStorage kann gesperrt sein - dann bleibt es beim hellen Thema. */ + } +})(); +` + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const nonce = (await headers()).get('x-nonce') ?? undefined + + return ( + + +