feat: Oberflaechenbausteine, Anwendungsrahmen und Konventionen

Basisbausteine im shadcn/ui-Stil, aber mit drei bewussten Abweichungen:

- Bedienelemente sind 44px hoch statt 36px. Ich bediene die App oft am
  Handy im Heim oder auf dem Amt; kleiner ist dort nicht treffsicher.
- Auswahlfelder benutzen das native <select>. Android und iOS oeffnen
  dafuer ihre eigene Liste - schneller als jeder Nachbau, und mit
  Screenreader und Tastatur funktioniert es ohne Zutun.
- Eingabefelder haben mindestens 16px Schrift, sonst zoomt iOS-Safari
  beim Hineintippen das Formular heran.

<Feld> verdrahtet Beschriftung, Erklaerung und Fehler ueber
aria-describedby. Der Erklaerungstext ist kein Beiwerk: die Anwendung
benutzt Begriffe aus dem Betreuungsrecht, die niemand nachschlagen soll.

<Rechtshinweis> ist der stehende Vermerk, dass hier keine Rechtsberatung
stattfindet. Er steht an jeder Stelle mit Paragraphenbezug.

Der Anwendungsrahmen fuehrt drei Bedingungen zusammen, ohne die niemand
an Fachdaten kommt: angemeldet, zweiter Faktor eingerichtet, mindestens
eine betreute Person zugeordnet. Die Navigation zeigt Zaehler fuer
ueberfaellige Fristen und neuen Posteingang - die Frage "wo muss ich
hin?" wird schon im Menue beantwortet.

Das Farbschema wird per Inline-Skript mit CSP-Nonce gesetzt, bevor die
Seite gezeichnet wird - sonst blitzt beim Laden das helle Thema auf.

docs/KONVENTIONEN.md haelt fest, was in allen weiteren Etappen gilt:
Verzeichnisaufbau, Cent-Betraege, Umgang mit verschluesselten Feldern,
Zugriffspruefung, Audit-Pflicht, Ampelfarben, Commit-Trailer.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:54:56 +02:00
parent bd1c7b801f
commit dcd3a8ce71
30 changed files with 2151 additions and 12 deletions
+160
View File
@@ -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, …)
<bereich>/ Fachkomponenten je Modul
lib/
crypto/ Feldverschlüsselung, Hashing, TOTP
auth/ Sitzung, Rollen, Rate-Limiting
validierung/ Zod-Schemata je Entität
<bereich>/ 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)/<modul>/`, Fachkomponenten
unter `components/<modul>/`, Zod-Schema unter `lib/validierung/<modul>.ts`,
Server Actions unter `server/actions/<modul>.ts`, reine Logik unter
`lib/<modul>/`.
## 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<string, string>; 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
`<Feld id beschriftung erklaerung fehler>` einwickeln — das verdrahtet
Label, Erklärung und Fehler barrierefrei.
- Jede Seite beginnt mit `<SeitenKopf titel beschreibung pfad aktionen />`.
- Leere Listen bekommen `<Leerzustand>` mit einem konkreten nächsten
Schritt, nie nur "keine Daten".
- Steht ein Paragraph auf der Seite, steht `<Rechtshinweis>` 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.
+99
View File
@@ -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 (
<AppHuelle
navigation={navigation}
kopfzeile={
<BetreuteAuswahl
aktiveId={aktive.id}
betreute={betreute.map((b) => ({
id: b.id,
vorname: b.vorname,
nachname: b.nachname,
geburtsdatum: b.geburtsdatum ? b.geburtsdatum.toISOString() : null,
}))}
/>
}
benutzerbereich={
<BenutzerMenue
name={benutzer.name}
email={benutzer.email}
rolle={ROLLEN_BESCHRIFTUNG[benutzer.rolle]}
/>
}
schnellerfassung={
<SchnellerfassungKnopf
eintraege={[
{ titel: 'Beleg fotografieren', href: '/dokumente/neu?quelle=kamera' },
{ titel: 'Notiz / Kontakt festhalten', href: '/kontakte/protokoll/neu' },
{ titel: 'Frist anlegen', href: '/fristen/neu' },
{ titel: 'Buchung erfassen', href: '/finanzen/buchungen/neu' },
]}
/>
}
>
{children}
</AppHuelle>
)
}
+84
View File
@@ -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 (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Wird geprüft …
</>
) : (
<>
<LogIn aria-hidden="true" />
Anmelden
</>
)}
</Button>
)
}
export function AnmeldeFormular({ weiter }: { weiter: string }) {
const [zustand, aktion] = useActionState<AnmeldeZustand, FormData>(anmelden, {})
return (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="weiter" value={weiter} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="email" beschriftung="E-Mail-Adresse" fehler={zustand.felder?.email} pflicht>
<Input
name="email"
type="email"
autoComplete="username"
inputMode="email"
autoCapitalize="none"
spellCheck={false}
defaultValue={zustand.email}
required
autoFocus
/>
</Feld>
<Feld id="passwort" beschriftung="Passwort" fehler={zustand.felder?.passwort} pflicht>
<Input name="passwort" type="password" autoComplete="current-password" required />
</Feld>
<Feld
id="code"
beschriftung="Code aus der Authenticator-App"
erklaerung="Sechs Ziffern. Alternativ ein Wiederherstellungscode im Format XXXX-XXXX-XXXX."
pflicht
>
<Input
name="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
// Kein maxLength: Wiederherstellungscodes sind laenger.
placeholder="123456"
className="font-mono tracking-widest"
required
/>
</Feld>
<Absendeknopf />
</form>
)
}
+51
View File
@@ -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 (
<>
<Card>
<CardHeader>
<CardTitle>Anmelden</CardTitle>
<CardDescription>
E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen.
</CardDescription>
</CardHeader>
<CardContent>
<AnmeldeFormular weiter={weiter} />
</CardContent>
</Card>
<div className="flex items-start gap-2.5 rounded-md border bg-muted/50 p-3 text-sm text-muted-foreground">
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>
Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist
deshalb Pflicht und lässt sich nicht abschalten.
</p>
</div>
</>
)
}
+38
View File
@@ -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 (
<div className="flex min-h-dvh flex-col">
<main className="flex flex-1 items-center justify-center px-4 py-10">
<div className="w-full max-w-md space-y-6">
<div className="flex items-center justify-center gap-3">
<span
aria-hidden="true"
className="grid size-10 place-items-center rounded-lg bg-primary font-semibold text-primary-foreground"
>
BC
</span>
<span className="text-xl font-semibold">Betreuungs-Cockpit</span>
</div>
{children}
</div>
</main>
<footer className="border-t px-4 py-4">
<div className="mx-auto flex max-w-md flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<Link href="/datenschutz" className="hover:underline">
Datenschutz
</Link>
<span aria-hidden="true">·</span>
<span>Daten bleiben in der EU</span>
<span aria-hidden="true">·</span>
<span>Keine Rechtsberatung</span>
</div>
</footer>
</div>
)
}
+65
View File
@@ -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 <head> 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 (
<html lang="de" suppressHydrationWarning>
<head>
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: THEMA_SKRIPT }} />
</head>
<body className="min-h-dvh bg-background text-foreground">{children}</body>
</html>
)
}
+14 -9
View File
@@ -1,4 +1,4 @@
import NextAuth from 'next-auth'
import NextAuth, { CredentialsSignin } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import type { Rolle } from '@prisma/client'
import { db } from '@/lib/db'
@@ -25,18 +25,23 @@ import { protokollieren } from '@/lib/audit'
* Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout.
*/
export class AnmeldeFehler extends Error {
constructor(
public readonly grund:
export type AnmeldeGrund =
| 'daten-falsch'
| 'gesperrt'
| 'konto-inaktiv'
| 'zweiter-faktor-fehlt'
| 'code-falsch',
public readonly nochSekunden?: number,
) {
| 'code-falsch'
/**
* Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code".
* Deshalb erbt der Fehler von CredentialsSignin und kodiert eine etwaige
* Restsperrzeit in den Code hinein: "gesperrt:120".
*/
export class AnmeldeFehler extends CredentialsSignin {
code: string
constructor(grund: AnmeldeGrund, nochSekunden?: number) {
super(grund)
this.name = 'AnmeldeFehler'
this.code = nochSekunden ? `${grund}:${nochSekunden}` : grund
}
}
+176
View File
@@ -0,0 +1,176 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Menu, Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Navigation, type NavEintrag } from '@/components/navigation'
import { cn } from '@/lib/utils'
/**
* Rahmen der Anwendung.
*
* Am Schreibtisch: feste Seitenleiste links.
* Am Handy: Kopfzeile mit Menuknopf, die Navigation faehrt als Schublade
* ueber die Seite. Zusaetzlich ein fester Knopf unten rechts fuer die
* Schnellerfassung - Beleg, Notiz, Frist in maximal zwei Klicks.
*/
export function AppHuelle({
navigation,
kopfzeile,
schnellerfassung,
benutzerbereich,
children,
}: {
navigation: NavEintrag[]
kopfzeile: React.ReactNode
schnellerfassung?: React.ReactNode
benutzerbereich: React.ReactNode
children: React.ReactNode
}) {
const [menueOffen, setMenueOffen] = React.useState(false)
const pfad = usePathname()
// Beim Seitenwechsel die Schublade schliessen.
React.useEffect(() => {
setMenueOffen(false)
}, [pfad])
// Escape schliesst die Schublade.
React.useEffect(() => {
if (!menueOffen) return
const beiTaste = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenueOffen(false)
}
document.addEventListener('keydown', beiTaste)
return () => document.removeEventListener('keydown', beiTaste)
}, [menueOffen])
return (
<div className="flex min-h-dvh flex-col lg:flex-row">
<a href="#inhalt" className="skip-link no-print">
Zum Inhalt springen
</a>
{/* --- Seitenleiste am Schreibtisch --------------------------------- */}
<aside className="no-print hidden w-72 shrink-0 border-r bg-card lg:flex lg:flex-col">
<div className="flex h-16 items-center border-b px-5">
<Link href="/heute" className="flex items-center gap-2 font-semibold">
<span
aria-hidden="true"
className="grid size-8 place-items-center rounded-md bg-primary text-sm text-primary-foreground"
>
BC
</span>
<span>Betreuungs-Cockpit</span>
</Link>
</div>
<div className="border-b p-4">{kopfzeile}</div>
<div className="flex-1 overflow-y-auto p-3">
<Navigation eintraege={navigation} />
</div>
<div className="border-t p-3">{benutzerbereich}</div>
</aside>
{/* --- Kopfzeile am Handy ------------------------------------------- */}
<header className="no-print sticky top-0 z-30 flex h-14 items-center gap-2 border-b bg-card/95 px-3 backdrop-blur lg:hidden">
<Button
variant="ghost"
size="icon"
onClick={() => setMenueOffen(true)}
aria-label="Menü öffnen"
aria-expanded={menueOffen}
aria-controls="hauptmenue"
>
<Menu className="size-5" />
</Button>
<Link href="/heute" className="flex-1 truncate font-semibold">
Betreuungs-Cockpit
</Link>
{benutzerbereich}
</header>
{/* --- Schublade am Handy ------------------------------------------- */}
{menueOffen && (
<div className="no-print fixed inset-0 z-50 lg:hidden">
<button
type="button"
className="absolute inset-0 bg-black/50"
onClick={() => setMenueOffen(false)}
aria-label="Menü schließen"
/>
<div
id="hauptmenue"
className="absolute inset-y-0 left-0 flex w-80 max-w-[85vw] flex-col bg-card shadow-xl"
>
<div className="flex h-14 items-center justify-between border-b px-4">
<span className="font-semibold">Menü</span>
<Button
variant="ghost"
size="icon"
onClick={() => setMenueOffen(false)}
aria-label="Menü schließen"
>
<X className="size-5" />
</Button>
</div>
<div className="border-b p-4">{kopfzeile}</div>
<div className="flex-1 overflow-y-auto p-3">
<Navigation eintraege={navigation} beimKlick={() => setMenueOffen(false)} />
</div>
</div>
</div>
)}
{/* --- Inhalt -------------------------------------------------------- */}
<main id="inhalt" className="min-w-0 flex-1 focus:outline-none" tabIndex={-1}>
<div className={cn('mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8')}>
{children}
</div>
</main>
{/* --- Schnellerfassung --------------------------------------------- */}
{schnellerfassung}
</div>
)
}
/**
* Fester Knopf unten rechts. Am Handy immer erreichbar, ohne nach oben
* scrollen zu muessen.
*/
export function SchnellerfassungKnopf({ eintraege }: { eintraege: { titel: string; href: string }[] }) {
const [offen, setOffen] = React.useState(false)
return (
<div className="no-print fixed bottom-5 right-5 z-40 flex flex-col items-end gap-2">
{offen && (
<div className="flex flex-col items-end gap-2">
{eintraege.map((eintrag) => (
<Link
key={eintrag.href}
href={eintrag.href}
onClick={() => setOffen(false)}
className="rounded-full border bg-card px-4 py-2.5 text-sm font-medium shadow-lg hover:bg-accent"
>
{eintrag.titel}
</Link>
))}
</div>
)}
<Button
size="icon"
className="size-14 rounded-full shadow-lg"
onClick={() => setOffen((o) => !o)}
aria-expanded={offen}
aria-label={offen ? 'Schnellerfassung schließen' : 'Schnell erfassen'}
>
<Plus className={cn('size-6 transition-transform', offen && 'rotate-45')} />
</Button>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { signOut } from 'next-auth/react'
import { LogOut, Monitor, Moon, Settings, ShieldCheck, Sun, User as UserIcon } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { initialen } from '@/lib/utils'
type Thema = 'hell' | 'dunkel' | 'system'
/**
* Benutzermenue mit Themenwahl und Abmelden.
*
* Das Thema wird im localStorage gemerkt und sofort auf <html> gesetzt -
* dieselbe Logik wie im Startskript im Layout, damit es beim naechsten
* Laden nicht flackert.
*/
export function BenutzerMenue({
name,
email,
rolle,
}: {
name: string
email: string
rolle: string
}) {
const [thema, setThema] = React.useState<Thema>('system')
React.useEffect(() => {
const gespeichert = (localStorage.getItem('bc-thema') as Thema | null) ?? 'system'
setThema(gespeichert)
}, [])
function themaSetzen(neu: Thema) {
setThema(neu)
try {
localStorage.setItem('bc-thema', neu)
} catch {
/* Speicher gesperrt - dann gilt die Wahl nur fuer diese Sitzung. */
}
const dunkel =
neu === 'dunkel' ||
(neu === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dunkel)
document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light'
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-3 px-2">
<span
aria-hidden="true"
className="grid size-8 shrink-0 place-items-center rounded-full bg-secondary text-xs font-semibold text-secondary-foreground"
>
{initialen(name)}
</span>
<span className="min-w-0 flex-1 text-left lg:block">
<span className="block truncate text-sm font-medium">{name}</span>
<span className="block truncate text-xs text-muted-foreground">{rolle}</span>
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>
<div className="truncate font-medium">{name}</div>
<div className="truncate text-xs font-normal text-muted-foreground">{email}</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/konto">
<UserIcon className="size-4" />
Mein Konto
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/konto/zwei-faktor">
<ShieldCheck className="size-4" />
Zweiter Faktor
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/einstellungen">
<Settings className="size-4" />
Einstellungen
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Darstellung
</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => themaSetzen('hell')}>
<Sun className="size-4" />
Hell
{thema === 'hell' && <span className="ml-auto text-xs">✓</span>}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => themaSetzen('dunkel')}>
<Moon className="size-4" />
Dunkel
{thema === 'dunkel' && <span className="ml-auto text-xs">✓</span>}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => themaSetzen('system')}>
<Monitor className="size-4" />
Wie das System
{thema === 'system' && <span className="ml-auto text-xs">✓</span>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void signOut({ callbackUrl: '/anmelden' })}>
<LogOut className="size-4" />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import { useTransition } from 'react'
import { UserRound } from 'lucide-react'
import { Select } from '@/components/ui/select'
import { betreuteWechseln } from '@/server/actions/betreute-wechseln'
import { formatDatum } from '@/lib/utils'
/**
* Anzeige und Wechsel der aktiven betreuten Person.
*
* Bei nur einer betreuten Person - dem Regelfall - wird kein Auswahlfeld
* gezeigt, sondern nur der Name. Ein Auswahlfeld mit genau einem Eintrag
* ist eine Bedienhuerde ohne Nutzen.
*/
export function BetreuteAuswahl({
betreute,
aktiveId,
}: {
betreute: { id: string; vorname: string; nachname: string; geburtsdatum: string | null }[]
aktiveId: string
}) {
const [laeuft, starten] = useTransition()
const aktive = betreute.find((b) => b.id === aktiveId)
if (betreute.length <= 1) {
return (
<div className="flex items-start gap-2.5">
<UserRound className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0">
<div className="truncate text-sm font-semibold">
{aktive ? `${aktive.vorname} ${aktive.nachname}` : 'Keine betreute Person'}
</div>
{aktive?.geburtsdatum && (
<div className="text-xs text-muted-foreground">
geb. {formatDatum(aktive.geburtsdatum)}
</div>
)}
</div>
</div>
)
}
return (
<div className="space-y-1.5">
<label htmlFor="betreute-auswahl" className="text-xs font-medium text-muted-foreground">
Betreute Person
</label>
<Select
id="betreute-auswahl"
value={aktiveId}
disabled={laeuft}
onChange={(e) => {
const id = e.currentTarget.value
starten(() => void betreuteWechseln(id))
}}
>
{betreute.map((b) => (
<option key={b.id} value={b.id}>
{b.nachname}, {b.vorname}
</option>
))}
</Select>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import * as React from 'react'
import { AlertCircle } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
/**
* Ein Formularfeld mit Beschriftung, Erklaerung und Fehlermeldung.
*
* Der Erklaerungstext ist kein Beiwerk: Diese Anwendung benutzt an vielen
* Stellen Begriffe aus dem Betreuungs- und Sozialrecht. Wer sie nicht
* kennt, soll sie hier erklaert bekommen, statt nachschlagen zu muessen.
*
* Beschriftung, Erklaerung und Fehler sind ueber aria-describedby mit dem
* Eingabefeld verbunden - Screenreader lesen sie mit vor.
*/
export function Feld({
id,
beschriftung,
erklaerung,
fehler,
pflicht,
className,
children,
}: {
id: string
beschriftung: string
erklaerung?: React.ReactNode
fehler?: string | string[] | null
pflicht?: boolean
className?: string
children: React.ReactNode
}) {
const fehlertext = Array.isArray(fehler) ? fehler[0] : fehler
const erklaerungId = erklaerung ? `${id}-erklaerung` : undefined
const fehlerId = fehlertext ? `${id}-fehler` : undefined
const beschriftungVon = [erklaerungId, fehlerId].filter(Boolean).join(' ') || undefined
return (
<div className={cn('space-y-1.5', className)}>
<Label htmlFor={id}>
{beschriftung}
{pflicht && (
<span className="ml-1 text-destructive" aria-label="Pflichtfeld">
*
</span>
)}
</Label>
{React.isValidElement(children)
? React.cloneElement(children as React.ReactElement<Record<string, unknown>>, {
id,
'aria-describedby': beschriftungVon,
'aria-invalid': fehlertext ? true : undefined,
'aria-required': pflicht || undefined,
})
: children}
{erklaerung && (
<p id={erklaerungId} className="text-sm text-muted-foreground">
{erklaerung}
</p>
)}
{fehlertext && (
<p id={fehlerId} className="flex items-start gap-1.5 text-sm font-medium text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{fehlertext}
</p>
)}
</div>
)
}
/** Gruppiert zusammengehoerige Felder mit einer Ueberschrift. */
export function Feldgruppe({
titel,
beschreibung,
children,
className,
}: {
titel: string
beschreibung?: React.ReactNode
children: React.ReactNode
className?: string
}) {
return (
<fieldset className={cn('space-y-4', className)}>
<legend className="sr-only">{titel}</legend>
<div>
<h2 className="text-base font-semibold">{titel}</h2>
{beschreibung && <p className="mt-0.5 text-sm text-muted-foreground">{beschreibung}</p>}
</div>
<div className="grid gap-4 sm:grid-cols-2">{children}</div>
</fieldset>
)
}
+106
View File
@@ -0,0 +1,106 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
Banknote,
Building2,
CalendarClock,
FileText,
FolderKanban,
HeartPulse,
Home,
Landmark,
ScrollText,
Shield,
Users,
} from 'lucide-react'
import type { Modul } from '@prisma/client'
import { cn } from '@/lib/utils'
/**
* Hauptnavigation.
*
* Reihenfolge nach Haeufigkeit im Alltag, nicht nach fachlicher Systematik:
* Heute steht oben, weil ich morgens dort anfange. Berichte stehen unten,
* weil ich sie zweimal im Jahr brauche.
*/
export type NavEintrag = {
titel: string
href: string
icon: React.ComponentType<{ className?: string }>
modul: Modul
/** Zaehler, der als kleine Blase erscheint (z.B. offene Fristen). */
zaehler?: number
/** Faerbt die Blase rot statt neutral. */
dringend?: boolean
}
export const NAV_STRUKTUR: Omit<NavEintrag, 'zaehler' | 'dringend'>[] = [
{ titel: 'Heute', href: '/heute', icon: Home, modul: 'STAMMDATEN' },
{ titel: 'Fristen', href: '/fristen', icon: CalendarClock, modul: 'FRISTEN' },
{ titel: 'Vorgänge', href: '/vorgaenge', icon: FolderKanban, modul: 'VORGAENGE' },
{ titel: 'Dokumente', href: '/dokumente', icon: FileText, modul: 'DOKUMENTE' },
{ titel: 'Vermögen & Kasse', href: '/finanzen', icon: Banknote, modul: 'FINANZEN' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe', icon: Landmark, modul: 'SOZIALHILFE' },
{ titel: 'Immobilie', href: '/immobilie', icon: Building2, modul: 'IMMOBILIE' },
{ titel: 'Verträge', href: '/vertraege', icon: Shield, modul: 'VERTRAEGE' },
{ titel: 'Heim & Pflege', href: '/pflege', icon: HeartPulse, modul: 'GESUNDHEIT' },
{ titel: 'Kontakte', href: '/kontakte', icon: Users, modul: 'KONTAKTE' },
{ titel: 'Berichte', href: '/berichte', icon: ScrollText, modul: 'BERICHTE' },
]
export function Navigation({
eintraege,
beimKlick,
className,
}: {
eintraege: NavEintrag[]
beimKlick?: () => void
className?: string
}) {
const pfad = usePathname()
return (
<nav aria-label="Hauptnavigation" className={cn('space-y-0.5', className)}>
{eintraege.map((eintrag) => {
// "/fristen/neu" soll "Fristen" ebenfalls hervorheben.
const aktiv = pfad === eintrag.href || pfad.startsWith(`${eintrag.href}/`)
const Icon = eintrag.icon
return (
<Link
key={eintrag.href}
href={eintrag.href}
onClick={beimKlick}
aria-current={aktiv ? 'page' : undefined}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
aktiv
? 'bg-primary text-primary-foreground'
: 'text-foreground/80 hover:bg-accent hover:text-accent-foreground',
)}
>
<Icon className="size-5 shrink-0" />
<span className="flex-1 truncate">{eintrag.titel}</span>
{eintrag.zaehler !== undefined && eintrag.zaehler > 0 && (
<span
className={cn(
'min-w-6 rounded-full px-1.5 py-0.5 text-center text-xs font-semibold tabular-nums',
eintrag.dringend
? 'bg-ampel-rot text-white'
: aktiv
? 'bg-primary-foreground/20 text-primary-foreground'
: 'bg-muted text-muted-foreground',
)}
>
{eintrag.zaehler > 99 ? '99+' : eintrag.zaehler}
</span>
)}
</Link>
)
})}
</nav>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
/**
* Der stehende Hinweis, dass diese Anwendung keine Rechtsberatung ist.
*
* Er steht an JEDER Stelle, an der ein Paragraph genannt wird. Das ist
* keine Formalie: Die App rechnet Fristen aus und schlaegt Einordnungen
* vor, aber sie kennt den Einzelfall nicht. Wer sich darauf verlaesst,
* statt beim Rechtspfleger nachzufragen, kann Geld und Anspruch verlieren.
*/
export function Rechtshinweis({
rechtsgrundlage,
className,
kurz = false,
}: {
rechtsgrundlage?: string
className?: string
kurz?: boolean
}) {
if (kurz) {
return (
<p className={cn('text-xs text-muted-foreground', className)}>
{rechtsgrundlage && <span className="font-medium">{rechtsgrundlage} · </span>}
Keine Rechtsberatung. Im Zweifel bei Gericht, Bezirk oder Anwalt nachfragen.
</p>
)
}
return (
<div
className={cn(
'flex items-start gap-2.5 rounded-md border border-border bg-muted/50 p-3',
className,
)}
>
<Scale className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
{rechtsgrundlage && (
<>
<span className="font-medium text-foreground">{rechtsgrundlage}</span>{' '}
</>
)}
Dieser Hinweis dient der eigenen Orientierung und ersetzt{' '}
<strong className="font-medium">keine Rechtsberatung</strong>. Er ersetzt insbesondere nicht
die Rückfrage beim Betreuungsgericht, beim Sozialhilfeträger oder bei einem Anwalt.
</p>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import * as React from 'react'
import Link from 'next/link'
import { ChevronRight, Inbox } from 'lucide-react'
import { cn } from '@/lib/utils'
/**
* Kopfzeile einer Seite: Ueberschrift, Erklaerung, Aktionen.
*
* Die Erklaerung beantwortet moeglichst die Frage "was muss ich hier als
* Naechstes tun?" - nicht "was steht hier".
*/
export function SeitenKopf({
titel,
beschreibung,
aktionen,
pfad,
className,
}: {
titel: string
beschreibung?: React.ReactNode
aktionen?: React.ReactNode
pfad?: { titel: string; href?: string }[]
className?: string
}) {
return (
<div className={cn('space-y-3', className)}>
{pfad && pfad.length > 0 && (
<nav aria-label="Pfad">
<ol className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
{pfad.map((eintrag, i) => (
<li key={`${eintrag.titel}-${i}`} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="size-3.5" aria-hidden="true" />}
{eintrag.href ? (
<Link href={eintrag.href} className="hover:text-foreground hover:underline">
{eintrag.titel}
</Link>
) : (
<span aria-current="page">{eintrag.titel}</span>
)}
</li>
))}
</ol>
</nav>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{titel}</h1>
{beschreibung && (
<div className="max-w-2xl text-muted-foreground">{beschreibung}</div>
)}
</div>
{aktionen && <div className="flex shrink-0 flex-wrap gap-2">{aktionen}</div>}
</div>
</div>
)
}
/**
* Leerer Zustand. Zeigt nie nur "keine Daten", sondern immer den naechsten
* moeglichen Schritt - eine leere Liste soll nicht ratlos machen.
*/
export function Leerzustand({
titel,
beschreibung,
aktion,
icon: Icon = Inbox,
className,
}: {
titel: string
beschreibung?: React.ReactNode
aktion?: React.ReactNode
icon?: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
className?: string
}) {
return (
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border border-dashed px-6 py-14 text-center',
className,
)}
>
<Icon className="mb-3 size-8 text-muted-foreground" aria-hidden={true} />
<h3 className="text-base font-semibold">{titel}</h3>
{beschreibung && (
<p className="mt-1 max-w-md text-sm text-muted-foreground">{beschreibung}</p>
)}
{aktion && <div className="mt-5">{aktion}</div>}
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVarianten = cva(
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 ' +
'[&>svg]:size-5 [&>svg~*]:pl-8',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
hinweis: 'border-primary/30 bg-primary/5 text-foreground [&>svg]:text-primary',
warnung:
'border-ampel-orange/40 bg-ampel-orange/10 text-foreground [&>svg]:text-ampel-orange',
fehler: 'border-destructive/50 bg-destructive/10 text-foreground [&>svg]:text-destructive',
erfolg: 'border-ampel-gruen/40 bg-ampel-gruen/10 text-foreground [&>svg]:text-ampel-gruen',
},
},
defaultVariants: { variant: 'default' },
},
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVarianten>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVarianten({ variant }), className)} {...props} />
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<HTMLHeadingElement, React.ComponentProps<'h5'>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn('mb-1 font-semibold leading-tight', className)} {...props} />
),
)
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
),
)
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }
+37
View File
@@ -0,0 +1,37 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
/**
* Statuskennzeichen. Die Ampelvarianten benutzen Farbe UND Text - Farbe
* allein waere fuer farbfehlsichtige Nutzer keine Information.
*/
const badgeVarianten = cva(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium ' +
'transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
rot: 'border-ampel-rot/30 bg-ampel-rot/10 text-ampel-rot',
orange: 'border-ampel-orange/30 bg-ampel-orange/10 text-ampel-orange',
gelb: 'border-ampel-gelb/30 bg-ampel-gelb/10 text-ampel-gelb',
gruen: 'border-ampel-gruen/30 bg-ampel-gruen/10 text-ampel-gruen',
},
},
defaultVariants: { variant: 'default' },
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVarianten> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVarianten({ variant }), className)} {...props} />
}
export { Badge, badgeVarianten }
+59
View File
@@ -0,0 +1,59 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
/**
* Schaltflaeche.
*
* Die Groesse "default" ist bewusst hoeher als bei shadcn/ui im Original:
* 2.75rem entspricht 44px und damit der Mindestgroesse fuer Touchziele.
* Ich bediene die App oft am Handy im Heim oder auf dem Amt.
*/
const buttonVarianten = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ' +
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ' +
'focus-visible:ring-offset-2 focus-visible:ring-offset-background ' +
'disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-11 px-4 py-2',
sm: 'h-9 rounded-md px-3 text-sm',
lg: 'h-12 rounded-md px-8 text-base',
icon: 'h-11 w-11',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVarianten> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Komponente = asChild ? Slot : 'button'
return (
<Komponente
className={cn(buttonVarianten({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVarianten }
+54
View File
@@ -0,0 +1,54 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
),
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col gap-1.5 p-5', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLHeadingElement, React.ComponentProps<'h3'>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-lg font-semibold leading-tight tracking-tight', className)}
{...props}
/>
),
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLParagraphElement, React.ComponentProps<'p'>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center gap-2 p-5 pt-0', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+26
View File
@@ -0,0 +1,26 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/**
* Natives Kontrollkaestchen. Groesser als der Browserstandard, damit es
* am Handy zuverlaessig getroffen wird.
*/
const Checkbox = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, ...props }, ref) => (
<input
type="checkbox"
ref={ref}
className={cn(
'size-5 shrink-0 cursor-pointer rounded border-input accent-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
)
Checkbox.displayName = 'Checkbox'
export { Checkbox }
+83
View File
@@ -0,0 +1,83 @@
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-2 text-sm outline-none',
'transition-colors focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/**
* Texteingabe. Schriftgroesse mindestens 16px (text-base), sonst zoomt
* iOS-Safari beim Hineintippen das ganze Formular heran.
*/
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
'flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-base',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
'aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
className,
)}
{...props}
/>
),
)
Input.displayName = 'Input'
export { Input }
+22
View File
@@ -0,0 +1,22 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+38
View File
@@ -0,0 +1,38 @@
import * as React from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
/**
* Bewusst das native <select> statt einer nachgebauten Liste.
*
* Am Handy oeffnet iOS und Android dafuer das eingebaute Rad bzw. die
* Systemliste - schneller zu bedienen als jede nachgebaute Variante, und
* mit Screenreadern und Tastatur funktioniert es ohne Zutun.
*/
const Select = React.forwardRef<HTMLSelectElement, React.ComponentProps<'select'>>(
({ className, children, ...props }, ref) => (
<div className="relative">
<select
ref={ref}
className={cn(
'flex h-11 w-full appearance-none rounded-md border border-input bg-background',
'px-3 py-2 pr-9 text-base',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
'aria-[invalid=true]:border-destructive',
className,
)}
{...props}
>
{children}
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 opacity-60"
/>
</div>
),
)
Select.displayName = 'Select'
export { Select }
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
...props
}: React.ComponentProps<'div'> & { orientation?: 'horizontal' | 'vertical' }) {
return (
<div
role="separator"
aria-orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
{...props}
/>
)
}
export { Separator }
+64
View File
@@ -0,0 +1,64 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/**
* Tabelle. Der Rahmen scrollt waagerecht - am Handy darf niemals die ganze
* Seite seitlich wandern, nur die Tabelle selbst.
*/
const Table = React.forwardRef<HTMLTableElement, React.ComponentProps<'table'>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-x-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
)
Table.displayName = 'Table'
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.ComponentProps<'thead'>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
),
)
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<HTMLTableSectionElement, React.ComponentProps<'tbody'>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
),
)
TableBody.displayName = 'TableBody'
const TableRow = React.forwardRef<HTMLTableRowElement, React.ComponentProps<'tr'>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn('border-b transition-colors hover:bg-muted/50', className)}
{...props}
/>
),
)
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<HTMLTableCellElement, React.ComponentProps<'th'>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-11 px-3 text-left align-middle text-xs font-semibold uppercase tracking-wide',
'text-muted-foreground',
className,
)}
{...props}
/>
),
)
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<HTMLTableCellElement, React.ComponentProps<'td'>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn('px-3 py-3 align-middle', className)} {...props} />
),
)
TableCell.displayName = 'TableCell'
export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell }
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-base',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
'aria-[invalid=true]:border-destructive',
className,
)}
{...props}
/>
),
)
Textarea.displayName = 'Textarea'
export { Textarea }
+173
View File
@@ -0,0 +1,173 @@
import { z } from 'zod'
/**
* Eingabepruefung fuer die Stammdaten der betreuten Person.
*
* Die Pruefung laeuft IMMER serverseitig - das Formular im Browser ist nur
* Bequemlichkeit. Alle Meldungen sind auf Deutsch und sagen, was zu tun
* ist, nicht was falsch war.
*/
/** Leere Zeichenketten aus einem Formular sollen zu null werden, nicht zu "". */
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
export const AUFGABENKREISE = [
'VERMOEGENSSORGE',
'GESUNDHEITSSORGE',
'AUFENTHALTSBESTIMMUNG',
'WOHNUNGSANGELEGENHEITEN',
'BEHOERDENANGELEGENHEITEN',
'POST_UND_FERNMELDEVERKEHR',
'RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG',
'ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN',
'SONSTIGES',
] as const
export const AUFGABENKREIS_BESCHRIFTUNG: Record<(typeof AUFGABENKREISE)[number], string> = {
VERMOEGENSSORGE: 'Vermögenssorge',
GESUNDHEITSSORGE: 'Gesundheitssorge',
AUFENTHALTSBESTIMMUNG: 'Aufenthaltsbestimmung',
WOHNUNGSANGELEGENHEITEN: 'Wohnungsangelegenheiten',
BEHOERDENANGELEGENHEITEN: 'Behördenangelegenheiten',
POST_UND_FERNMELDEVERKEHR: 'Post- und Fernmeldeverkehr',
RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG: 'Rechts-, Antrags- und Behördenvertretung',
ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN:
'Entscheidung über freiheitsentziehende Maßnahmen',
SONSTIGES: 'Sonstiges',
}
export const BetreuteSchema = z.object({
// --- Person ---------------------------------------------------------------
anrede: optionalerText(20),
vorname: z.string().trim().min(1, 'Bitte den Vornamen angeben.').max(100),
nachname: z.string().trim().min(1, 'Bitte den Nachnamen angeben.').max(100),
geburtsname: optionalerText(100),
geburtsdatum: pflichtDatum.refine((datum) => datum <= new Date(), {
message: 'Das Geburtsdatum kann nicht in der Zukunft liegen.',
}),
geburtsort: optionalerText(100),
// --- Heim -----------------------------------------------------------------
heimName: optionalerText(150),
heimStrasse: optionalerText(150),
heimPlz: optionalerText(10),
heimOrt: optionalerText(100),
heimZimmer: optionalerText(50),
heimTelefon: optionalerText(50),
heimEinzugAm: optionalesDatum,
fruehereStrasse: optionalerText(150),
frueherePlz: optionalerText(10),
fruehererOrt: optionalerText(100),
// --- Gesundheit (verschluesselt gespeichert) ------------------------------
krankenkasse: optionalerText(150),
krankenversichertennummer: optionalerText(50),
pflegekasse: optionalerText(150),
pflegegrad: z.coerce
.number()
.int()
.min(1, 'Der Pflegegrad liegt zwischen 1 und 5.')
.max(5, 'Der Pflegegrad liegt zwischen 1 und 5.')
.nullable()
.catch(null),
pflegegradSeit: optionalesDatum,
pflegegradBescheidVom: optionalesDatum,
schwerbehinderungGdB: z.coerce
.number()
.int()
.min(20, 'Ein Grad der Behinderung wird ab 20 festgestellt.')
.max(100, 'Der Grad der Behinderung geht bis 100.')
.nullable()
.catch(null),
schwerbehinderungMerkzeichen: optionalerText(50),
diagnosen: optionalerText(4000),
ermittelterWille: optionalerText(4000),
patientenverfuegungVorhanden: z.coerce.boolean().default(false),
vorsorgevollmachtVorhanden: z.coerce.boolean().default(false),
// --- Betreuungsgericht ----------------------------------------------------
betreuungsgericht: optionalerText(150),
aktenzeichenGericht: optionalerText(80),
bestellungAm: optionalesDatum,
bestellungBis: optionalesDatum,
aufgabenkreise: z.array(z.enum(AUFGABENKREISE)).default([]),
aufgabenkreiseFreitext: optionalerText(500),
befreiterBetreuer: z.coerce.boolean().default(true),
ehrenamtlich: z.coerce.boolean().default(true),
// --- Sozialleistungstraeger ----------------------------------------------
aktenzeichenBezirk: optionalerText(80),
aktenzeichenSozialamt: optionalerText(80),
rentenversicherungsnummer: optionalerText(30),
steuerId: optionalerText(20),
notizen: optionalerText(4000),
})
export type BetreuteEingabe = z.infer<typeof BetreuteSchema>
/**
* Diese Felder wandern verschluesselt in die Datenbank. Der Name ohne
* Suffix ist der Klartext im Formular, mit Suffix der Umschlag.
*/
export const BETREUTE_GEHEIME_FELDER = [
'krankenversichertennummer',
'diagnosen',
'ermittelterWille',
'rentenversicherungsnummer',
'steuerId',
'notizen',
] as const
/**
* Liest ein FormData-Objekt in die Struktur, die das Schema erwartet.
* Kontrollkaestchen liefern "on" oder gar nichts - beides wird hier
* in einen echten Wahrheitswert uebersetzt.
*/
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (name === 'aufgabenkreise') continue
roh[name] = wert
}
roh.aufgabenkreise = formular.getAll('aufgabenkreise').map(String)
roh.patientenverfuegungVorhanden = formular.get('patientenverfuegungVorhanden') === 'on'
roh.vorsorgevollmachtVorhanden = formular.get('vorsorgevollmachtVorhanden') === 'on'
roh.befreiterBetreuer = formular.get('befreiterBetreuer') === 'on'
roh.ehrenamtlich = formular.get('ehrenamtlich') === 'on'
// Leere Zahlenfelder duerfen nicht als "" ins Schema.
for (const feld of ['pflegegrad', 'schwerbehinderungGdB']) {
if (roh[feld] === '') roh[feld] = null
}
return roh
}
+117
View File
@@ -0,0 +1,117 @@
'use server'
import { AuthError } from 'next-auth'
import { z } from 'zod'
import { signIn } from '@/auth'
/**
* Server Action fuer das Anmeldeformular.
*
* Die Fehlermeldung ist absichtlich unspezifisch: Ob die E-Mail unbekannt
* ist oder das Passwort falsch, geht niemanden etwas an, der davorsitzt.
* Nur die Sperre wird konkret benannt - sonst probiert man ratlos weiter.
*/
const AnmeldeSchema = z.object({
email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse eingeben.'),
passwort: z.string().min(1, 'Bitte das Passwort eingeben.'),
code: z.string().trim().default(''),
weiter: z.string().default('/heute'),
})
export type AnmeldeZustand = {
fehler?: string
felder?: { email?: string; passwort?: string }
/** Vorbelegung, damit die E-Mail nach einem Fehlversuch stehen bleibt. */
email?: string
}
/**
* Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft.
* Bewusst ueber das digest-Feld statt ueber einen Import aus next/dist -
* interne Pfade aendern sich zwischen Nebenversionen.
*/
function istWeiterleitung(fehler: unknown): boolean {
return (
typeof fehler === 'object' &&
fehler !== null &&
'digest' in fehler &&
typeof (fehler as { digest?: unknown }).digest === 'string' &&
(fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT')
)
}
/** Verhindert offene Weiterleitungen auf fremde Seiten. */
function sicheresZiel(ziel: string): string {
if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute'
return ziel
}
function fehlertext(code: string): string {
const [grund, wert] = code.split(':')
switch (grund) {
case 'gesperrt': {
const sekunden = Number.parseInt(wert ?? '0', 10)
const minuten = Math.ceil(sekunden / 60)
return (
`Zu viele Fehlversuche. Aus Sicherheitsgründen ist die Anmeldung für ` +
`${minuten > 1 ? `etwa ${minuten} Minuten` : 'kurze Zeit'} gesperrt.`
)
}
case 'konto-inaktiv':
return 'Dieser Zugang ist deaktiviert. Bitte wende dich an den Administrator.'
case 'code-falsch':
return (
'Der Code stimmt nicht. Prüfe, ob die Uhrzeit deines Geräts stimmt — ' +
'TOTP-Codes hängen an der Uhr. Alternativ kannst du einen Wiederherstellungscode eingeben.'
)
default:
return 'E-Mail-Adresse, Passwort oder Code stimmen nicht.'
}
}
export async function anmelden(
_vorher: AnmeldeZustand,
formular: FormData,
): Promise<AnmeldeZustand> {
const gelesen = AnmeldeSchema.safeParse({
email: formular.get('email'),
passwort: formular.get('passwort'),
code: formular.get('code'),
weiter: formular.get('weiter') ?? '/heute',
})
if (!gelesen.success) {
const felder = gelesen.error.flatten().fieldErrors
return {
fehler: 'Bitte prüfe deine Eingaben.',
felder: { email: felder.email?.[0], passwort: felder.passwort?.[0] },
email: String(formular.get('email') ?? ''),
}
}
const { email, passwort, code, weiter } = gelesen.data
try {
await signIn('zugangsdaten', {
email,
passwort,
code,
redirectTo: sicheresZiel(weiter),
})
} catch (fehler) {
// Bei Erfolg wirft signIn eine Weiterleitung - die muss durch.
if (istWeiterleitung(fehler)) throw fehler
if (fehler instanceof AuthError) {
const code = 'code' in fehler && typeof fehler.code === 'string' ? fehler.code : 'daten-falsch'
return { fehler: fehlertext(code), email }
}
throw fehler
}
// Wird nicht erreicht - signIn leitet weiter.
return {}
}
+32
View File
@@ -0,0 +1,32 @@
'use server'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { erfordereBenutzer, meineBetreuten, COOKIE_AKTIVE_BETREUTE } from '@/lib/auth/sitzung'
/**
* Wechselt die aktive betreute Person.
*
* Die Auswahl wird nur gesetzt, wenn der Benutzer auf diese Person auch
* Zugriff hat - sonst liesse sich ueber ein manipuliertes Cookie fremde
* Daten anzeigen.
*/
export async function betreuteWechseln(betreuteId: string): Promise<void> {
const benutzer = await erfordereBenutzer()
const erlaubt = await meineBetreuten(benutzer)
if (!erlaubt.some((b) => b.id === betreuteId)) {
throw new Error('Kein Zugriff auf diese betreute Person.')
}
const keks = await cookies()
keks.set(COOKIE_AKTIVE_BETREUTE, betreuteId, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 365,
})
revalidatePath('/', 'layout')
}
+101
View File
@@ -0,0 +1,101 @@
'use server'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { db } from '@/lib/db'
import { passwortHashen } from '@/lib/crypto/hash'
import { protokollieren } from '@/lib/audit'
/**
* Anlegen des allerersten Zugangs.
*
* Diese Aktion ist nur so lange erreichbar, wie es noch keinen Benutzer
* gibt. Danach sperrt sie sich selbst - sonst koennte sich jemand von
* aussen einen Administratorzugang anlegen.
*/
const PASSWORT_MINDESTLAENGE = 12
const ZugangSchema = z
.object({
name: z.string().trim().min(2, 'Bitte deinen Namen angeben.').max(120),
email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse angeben.'),
passwort: z
.string()
.min(
PASSWORT_MINDESTLAENGE,
`Bitte mindestens ${PASSWORT_MINDESTLAENGE} Zeichen. Eine Folge aus vier zufälligen ` +
`Wörtern ist leichter zu merken und sicherer als ein kurzes Kunstwort.`,
)
.max(200, 'Höchstens 200 Zeichen.'),
passwortWiederholung: z.string(),
})
.refine((daten) => daten.passwort === daten.passwortWiederholung, {
message: 'Die beiden Passwörter stimmen nicht überein.',
path: ['passwortWiederholung'],
})
export type ZugangZustand = {
fehler?: string
felder?: Partial<Record<'name' | 'email' | 'passwort' | 'passwortWiederholung', string>>
werte?: { name?: string; email?: string }
}
export async function erstenZugangAnlegen(
_vorher: ZugangZustand,
formular: FormData,
): Promise<ZugangZustand> {
const vorhandene = await db.user.count()
if (vorhandene > 0) {
return { fehler: 'Es gibt bereits einen Zugang. Bitte melde dich an.' }
}
const gelesen = ZugangSchema.safeParse({
name: formular.get('name'),
email: formular.get('email'),
passwort: formular.get('passwort'),
passwortWiederholung: formular.get('passwortWiederholung'),
})
if (!gelesen.success) {
const felder = gelesen.error.flatten().fieldErrors
return {
fehler: 'Bitte prüfe deine Eingaben.',
felder: {
name: felder.name?.[0],
email: felder.email?.[0],
passwort: felder.passwort?.[0],
passwortWiederholung: felder.passwortWiederholung?.[0],
},
werte: {
name: String(formular.get('name') ?? ''),
email: String(formular.get('email') ?? ''),
},
}
}
const { name, email, passwort } = gelesen.data
const benutzer = await db.user.create({
data: {
name,
email,
passwortHash: await passwortHashen(passwort),
// Der erste Zugang verwaltet auch die Anwendung.
rolle: 'ADMIN',
},
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'User',
entitaetId: benutzer.id,
bezeichnung: `${name} (erster Zugang)`,
userId: benutzer.id,
userEmail: email,
userName: name,
})
// Weiter zur Anmeldung: der zweite Faktor wird direkt danach eingerichtet.
redirect('/anmelden?neu=1')
}