diff --git a/src/app/(app)/bereiche/bereich-formular.tsx b/src/app/(app)/bereiche/bereich-formular.tsx new file mode 100644 index 0000000..978d696 --- /dev/null +++ b/src/app/(app)/bereiche/bereich-formular.tsx @@ -0,0 +1,235 @@ +'use client' + +import { useState, useTransition } from 'react' +import { Loader2, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { KAPAZITAET_FELDER, WOCHENTAGE } from '@/lib/bereich' +import { + bereichAendern, + bereichAnlegen, + bereichLoeschen, + type Rueckmeldung, +} from '@/server/aktionen/bereiche' + +type Werte = { + id: string + name: string + farbe: string + reihenfolge: number + aktiv: boolean + arbeitsfensterVon: string + arbeitsfensterBis: string +} & Record<(typeof KAPAZITAET_FELDER)[number], number> + +const LEER = { + name: '', + farbe: '#2563eb', + reihenfolge: 0, + aktiv: true, + arbeitsfensterVon: '08:00', + arbeitsfensterBis: '17:00', + kapazitaetMo: 0, + kapazitaetDi: 0, + kapazitaetMi: 0, + kapazitaetDo: 0, + kapazitaetFr: 0, + kapazitaetSa: 0, + kapazitaetSo: 0, +} satisfies Omit + +export function BereichFormular({ bereich }: { bereich?: Werte }) { + const [laeuft, starte] = useTransition() + const [felder, setFelder] = useState>({}) + const [offen, setOffen] = useState(!bereich) + const werte = bereich ?? LEER + + function verarbeite(antwort: Rueckmeldung, formular: HTMLFormElement) { + if (antwort.ok) { + setFelder({}) + toast.success(antwort.meldung) + if (!bereich) formular.reset() + return + } + setFelder(antwort.felder ?? {}) + toast.error(antwort.fehler) + } + + function absenden(ereignis: React.FormEvent) { + ereignis.preventDefault() + const formular = ereignis.currentTarget + const daten = new FormData(formular) + starte(async () => { + const antwort = bereich + ? await bereichAendern(bereich.id, daten) + : await bereichAnlegen(daten) + verarbeite(antwort, formular) + }) + } + + function loeschen() { + if (!bereich) return + const daten = new FormData() + daten.set('id', bereich.id) + starte(async () => { + const antwort = await bereichLoeschen(daten) + if (antwort.ok) toast.success(antwort.meldung) + else toast.error(antwort.fehler) + }) + } + + if (bereich && !offen) { + return ( + + ) + } + + return ( +
+
+
+ + + {felder.name && ( +

+ {felder.name} +

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + + {felder.arbeitsfensterBis && ( +

{felder.arbeitsfensterBis}

+ )} +
+
+ +
+ Tageskapazität in Minuten +
+ {KAPAZITAET_FELDER.map((feld, i) => ( +
+ + +
+ ))} +
+ {felder.kapazitaetMo && ( +

{felder.kapazitaetMo}

+ )} +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + + {bereich && ( + <> + + + + )} +
+
+ ) +} diff --git a/src/app/(app)/bereiche/page.tsx b/src/app/(app)/bereiche/page.tsx new file mode 100644 index 0000000..b3e602e --- /dev/null +++ b/src/app/(app)/bereiche/page.tsx @@ -0,0 +1,124 @@ +import type { Metadata } from 'next' +import { BereichFormular } from '@/app/(app)/bereiche/bereich-formular' +import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card' +import { KAPAZITAET_FELDER, WOCHENTAGE, wochenKapazitaet } from '@/lib/bereich' +import { istAdmin } from '@/lib/rollen' +import { alsDauer, alsUhrzeit } from '@/lib/zeit' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' + +export const metadata: Metadata = { title: 'Bereiche' } + +export default async function BereicheSeite() { + const benutzer = await verlangeAnmeldung('/bereiche') + const darfAendern = istAdmin(benutzer.rolle) + + const bereiche = await db.bereich.findMany({ + orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], + }) + + return ( +
+
+

Bereiche

+

+ Farbe, Arbeitsfenster und Tageskapazität je Lebensbereich. Die Kapazität + begrenzt später, wie viel an einem Tag eingeplant werden kann. +

+
+ + {bereiche.length === 0 ? ( + + + Noch keine Bereiche angelegt. + + + ) : ( +
    + {bereiche.map((bereich) => ( +
  • + + + + {bereich.name} + {!bereich.aktiv && ( + + stillgelegt + + )} + + + +
    +
    +
    Arbeitsfenster:
    +
    + {alsUhrzeit(bereich.arbeitsfensterVon)} bis{' '} + {alsUhrzeit(bereich.arbeitsfensterBis)} Uhr +
    +
    +
    +
    Woche gesamt:
    +
    {alsDauer(wochenKapazitaet(bereich))}
    +
    +
    + +
      + {KAPAZITAET_FELDER.map((feld, i) => ( +
    • + + {WOCHENTAGE[i]?.slice(0, 2)} + {' '} + {alsDauer(bereich[feld])} +
    • + ))} +
    + + {darfAendern && ( + + )} +
    +
    +
  • + ))} +
+ )} + + {darfAendern && ( + + + Neuen Bereich anlegen + + + + + + )} +
+ ) +} diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx new file mode 100644 index 0000000..a47c835 --- /dev/null +++ b/src/app/(app)/einstellungen/page.tsx @@ -0,0 +1,77 @@ +import type { Metadata } from 'next' +import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung' +import { + Card, + CardBeschreibung, + CardInhalt, + CardKopf, + CardTitel, +} from '@/components/ui/card' +import { ROLLEN_NAME } from '@/lib/rollen' +import { alsDatumUhrzeit } from '@/lib/zeit' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' + +export const metadata: Metadata = { title: 'Einstellungen' } + +export default async function EinstellungenSeite() { + const benutzer = await verlangeAnmeldung('/einstellungen') + + const passkeys = await db.passkey.findMany({ + where: { benutzerId: benutzer.id }, + orderBy: { erstelltAm: 'asc' }, + select: { + id: true, + bezeichnung: true, + erstelltAm: true, + zuletztBenutzt: true, + gesichert: true, + }, + }) + + return ( +
+
+

Einstellungen

+
+ + + + Konto + + +
+
Name
+
{benutzer.name ?? '—'}
+
E-Mail
+
{benutzer.email}
+
Rolle
+
{ROLLEN_NAME[benutzer.rolle]}
+
+
+
+ + + + Passkeys + + Mit einem Passkey meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN + an – ohne Passwort. Hinterlege am besten zwei, damit du bei Verlust eines + Geräts nicht ausgesperrt bist. + + + + ({ + id: p.id, + bezeichnung: p.bezeichnung, + erstelltAm: alsDatumUhrzeit(p.erstelltAm), + zuletztBenutzt: p.zuletztBenutzt ? alsDatumUhrzeit(p.zuletztBenutzt) : null, + gesichert: p.gesichert, + }))} + /> + + +
+ ) +} diff --git a/src/app/(app)/einstellungen/passkey-verwaltung.tsx b/src/app/(app)/einstellungen/passkey-verwaltung.tsx new file mode 100644 index 0000000..e7107fb --- /dev/null +++ b/src/app/(app)/einstellungen/passkey-verwaltung.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { startRegistration } from '@simplewebauthn/browser' +import { Fingerprint, Loader2, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { passkeyEntfernen } from '@/server/aktionen/passkeys' + +export type PasskeyAnzeige = { + id: string + bezeichnung: string + erstelltAm: string + zuletztBenutzt: string | null + gesichert: boolean +} + +export function PasskeyVerwaltung({ passkeys }: { passkeys: PasskeyAnzeige[] }) { + const router = useRouter() + const [bezeichnung, setBezeichnung] = useState('') + const [legtAn, setLegtAn] = useState(false) + const [laeuft, starte] = useTransition() + + async function anlegen(ereignis: React.FormEvent) { + ereignis.preventDefault() + const name = bezeichnung.trim() + if (!name) { + toast.error('Bitte einen Namen für das Gerät angeben.') + return + } + + setLegtAn(true) + try { + const antwortOptionen = await fetch('/api/passkey/registrierung') + if (!antwortOptionen.ok) { + toast.error('Die Anfrage wurde abgelehnt.') + return + } + + const optionen = (await antwortOptionen.json()) as Parameters< + typeof startRegistration + >[0]['optionsJSON'] + + const antwort = await startRegistration({ optionsJSON: optionen }) + + const gespeichert = await fetch('/api/passkey/registrierung', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bezeichnung: name, antwort }), + }) + + const ergebnis = (await gespeichert.json()) as { fehler?: string } + if (!gespeichert.ok) { + toast.error(ergebnis.fehler ?? 'Der Passkey konnte nicht gespeichert werden.') + return + } + + toast.success(`Passkey „${name}" angelegt.`) + setBezeichnung('') + router.refresh() + } catch (fehler) { + // Bricht der Benutzer den Systemdialog ab, ist das kein Fehler. + if (fehler instanceof Error && fehler.name === 'NotAllowedError') return + if (fehler instanceof Error && fehler.name === 'InvalidStateError') { + toast.error('Dieses Gerät ist hier schon hinterlegt.') + return + } + toast.error('Der Passkey konnte nicht angelegt werden.') + } finally { + setLegtAn(false) + } + } + + function entfernen(id: string) { + const daten = new FormData() + daten.set('id', id) + starte(async () => { + const antwort = await passkeyEntfernen(daten) + if (antwort.ok) { + toast.success(antwort.meldung) + router.refresh() + } else { + toast.error(antwort.fehler) + } + }) + } + + return ( +
+ {passkeys.length === 0 ? ( +

+ Noch kein Passkey hinterlegt. +

+ ) : ( +
    + {passkeys.map((passkey) => ( +
  • + +
    +

    {passkey.bezeichnung}

    +

    + Angelegt {passkey.erstelltAm} + {passkey.zuletztBenutzt + ? ` · zuletzt benutzt ${passkey.zuletztBenutzt}` + : ' · noch nicht benutzt'} + {passkey.gesichert ? ' · geräteübergreifend' : ' · nur dieses Gerät'} +

    +
    + +
  • + ))} +
+ )} + +
void anlegen(e)} className="flex flex-wrap items-end gap-3"> +
+ + setBezeichnung(e.target.value)} + placeholder="z. B. Diensthandy" + maxLength={60} + disabled={legtAn} + /> +
+ +
+
+ ) +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..4f4161f --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,36 @@ +import { Kopfleiste } from '@/components/kopfleiste' +import { INSTANZ_NAME } from '@/lib/instanz' +import { versionAnzeige } from '@/lib/version' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { aktuelleInstanz } from '@/server/instanz' + +/** + * Alles unterhalb von (app) setzt eine Anmeldung voraus. Der Riegel sitzt + * hier im Layout und zusaetzlich in jeder Server Action – nicht nur in der + * Oberflaeche. + */ +export default async function AngemeldetesLayout({ + children, +}: { + children: React.ReactNode +}) { + const benutzer = await verlangeAnmeldung() + const instanz = aktuelleInstanz() + + return ( +
+ + +
{children}
+ +
+ Planer {versionAnzeige()} · {INSTANZ_NAME[instanz]} +
+
+ ) +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx new file mode 100644 index 0000000..5d1b92a --- /dev/null +++ b/src/app/(app)/page.tsx @@ -0,0 +1,80 @@ +import Link from 'next/link' +import { + Card, + CardBeschreibung, + CardInhalt, + CardKopf, + CardTitel, +} from '@/components/ui/card' +import { wochenKapazitaet } from '@/lib/bereich' +import { alsDatum, alsDauer } from '@/lib/zeit' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' + +export default async function StartSeite() { + const benutzer = await verlangeAnmeldung('/') + const jetzt = new Date() + + const bereiche = await db.bereich.findMany({ + where: { aktiv: true }, + orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], + }) + + const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email + + return ( +
+
+

Guten Tag, {anrede}

+

{alsDatum(jetzt)}

+
+ +
+

Deine Bereiche

+ + {bereiche.length === 0 ? ( + + + Noch keine Bereiche angelegt.{' '} + + Jetzt einrichten + + + + ) : ( +
    + {bereiche.map((bereich) => ( +
  • + + + + {bereich.name} + + +

    + {alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant +

    +
    +
    +
  • + ))} +
+ )} +
+ + + + Was als Nächstes kommt + + Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die + Wochenplanung per Ziehen und Ablegen mit M4. + + + +
+ ) +} diff --git a/src/app/(app)/verwaltung/protokoll/page.tsx b/src/app/(app)/verwaltung/protokoll/page.tsx new file mode 100644 index 0000000..2f6d2f2 --- /dev/null +++ b/src/app/(app)/verwaltung/protokoll/page.tsx @@ -0,0 +1,91 @@ +import type { Metadata } from 'next' +import { Card, CardInhalt } from '@/components/ui/card' +import { alsDatumUhrzeit } from '@/lib/zeit' +import { verlangeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' + +export const metadata: Metadata = { title: 'Protokoll' } + +const SEITENGROESSE = 100 + +export default async function ProtokollSeite() { + // Zweiter Riegel: das Layout prueft die Anmeldung, hier zusaetzlich die Rolle. + await verlangeRolle('ADMIN', '/verwaltung/protokoll') + + const eintraege = await db.auditEintrag.findMany({ + orderBy: { zeitpunkt: 'desc' }, + take: SEITENGROESSE, + select: { + id: true, + zeitpunkt: true, + benutzerEmail: true, + aktion: true, + objektTyp: true, + objektId: true, + details: true, + instanz: true, + }, + }) + + return ( +
+
+

Protokoll

+

+ Die letzten {SEITENGROESSE} schreibenden Aktionen. Einträge lassen sich nicht + ändern und nicht löschen. +

+
+ + {eintraege.length === 0 ? ( + + + Noch nichts protokolliert. + + + ) : ( + + + + + + + + + + + + + {eintraege.map((eintrag) => ( + + + + + + + ))} + +
+ Zeitpunkt + + Wer + + Was + + Objekt +
+ {alsDatumUhrzeit(eintrag.zeitpunkt)} + {eintrag.benutzerEmail}{eintrag.aktion} + {eintrag.objektTyp} + {eintrag.details ? ( + + {JSON.stringify(eintrag.details)} + + ) : null} +
+
+
+ )} +
+ ) +} diff --git a/src/lib/audit.test.ts b/src/lib/audit.test.ts new file mode 100644 index 0000000..6dfbef1 --- /dev/null +++ b/src/lib/audit.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { ENTFERNT, auditDetails, detailsSaeubern } from './audit' + +describe('auditDetails', () => { + it('laesst fachliche Felder unangetastet', () => { + expect(auditDetails({ name: 'Gewerbe', farbe: '#2563eb', reihenfolge: 2 })).toEqual({ + name: 'Gewerbe', + farbe: '#2563eb', + reihenfolge: 2, + }) + }) + + it('gibt fuer nichts auch nichts zurueck', () => { + expect(auditDetails(undefined)).toBeUndefined() + }) + + it('entfernt Passwoerter und Tokens', () => { + expect( + auditDetails({ + email: 'a@b.de', + passwort: 'geheim123', + passwortHash: '$2b$12$...', + access_token: 'ey...', + }), + ).toEqual({ + email: 'a@b.de', + passwort: ENTFERNT, + passwortHash: ENTFERNT, + access_token: ENTFERNT, + }) + }) + + it('entfernt Termin-Betreffs und Mailinhalte', () => { + expect( + auditDetails({ betreff: 'Personalgespräch', body: 'Text', dauer: 30 }), + ).toEqual({ betreff: ENTFERNT, body: ENTFERNT, dauer: 30 }) + }) + + it('achtet nicht auf Gross- und Kleinschreibung', () => { + expect(auditDetails({ ClientSecret: 'x', REFRESH_TOKEN: 'y' })).toEqual({ + ClientSecret: ENTFERNT, + REFRESH_TOKEN: ENTFERNT, + }) + }) +}) + +describe('detailsSaeubern', () => { + it('greift auch in verschachtelten Objekten', () => { + expect(detailsSaeubern({ konto: { anbieter: 'GRAPH', id_token: 'ey...' } })).toEqual({ + konto: { anbieter: 'GRAPH', id_token: ENTFERNT }, + }) + }) + + it('greift in Listen', () => { + expect(detailsSaeubern([{ name: 'a', token: 'x' }])).toEqual([ + { name: 'a', token: ENTFERNT }, + ]) + }) + + it('schreibt Datumsangaben als ISO-Zeichenkette', () => { + expect(detailsSaeubern({ am: new Date('2026-09-10T12:00:00Z') })).toEqual({ + am: '2026-09-10T12:00:00.000Z', + }) + }) + + it('schneidet zu tiefe Verschachtelung ab, statt endlos abzusteigen', () => { + expect(detailsSaeubern({ a: { b: { c: { d: { e: { f: 1 } } } } } })).toEqual({ + a: { b: { c: { d: { e: ENTFERNT } } } }, + }) + }) + + it('kommt mit einfachen Werten und null zurecht', () => { + expect(detailsSaeubern('text')).toBe('text') + expect(detailsSaeubern(42)).toBe(42) + expect(detailsSaeubern(null)).toBeNull() + }) +}) diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..f1d4a5a --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,66 @@ +/** + * Aufbereitung der Audit-Details. Reine Logik, damit sie ohne Datenbank + * geprueft werden kann. + * + * In die Details gehoeren nur fachliche Felder – niemals Tokens, Passwoerter, + * Mailinhalte oder Termin-Betreffs. Was hier landet, steht spaeter im + * Klartext in der Datenbank und in jeder Sicherung. + */ + +/** Feldnamen, die auch versehentlich nie in einem Audit-Eintrag landen duerfen. */ +const VERBOTEN = [ + 'passwort', + 'password', + 'token', + 'secret', + 'geheim', + 'clientsecret', + 'betreff', + 'subject', + 'inhalt', + 'body', + 'nachricht', +] + +export const ENTFERNT = '[entfernt]' + +function istVerdaechtig(schluessel: string): boolean { + const klein = schluessel.toLowerCase() + return VERBOTEN.some((v) => klein.includes(v)) +} + +/** + * Zweiter Riegel gegen Unachtsamkeit: Felder mit verdaechtigem Namen werden + * ersetzt, statt gespeichert zu werden. Wirkt auch in verschachtelten + * Objekten und in Listen. + */ +export function detailsSaeubern(wert: unknown, tiefe = 0): unknown { + // Tief verschachtelte Strukturen gehoeren nicht in ein Audit-Detail; ab + // einer gewissen Tiefe wird abgeschnitten statt endlos abgestiegen. + if (tiefe > 4) return ENTFERNT + + if (Array.isArray(wert)) { + return wert.map((eintrag) => detailsSaeubern(eintrag, tiefe + 1)) + } + + if (wert !== null && typeof wert === 'object') { + if (wert instanceof Date) return wert.toISOString() + const sauber: Record = {} + for (const [schluessel, inhalt] of Object.entries(wert)) { + sauber[schluessel] = istVerdaechtig(schluessel) + ? ENTFERNT + : detailsSaeubern(inhalt, tiefe + 1) + } + return sauber + } + + return wert +} + +/** Bequemer Einstieg fuer den Normalfall: ein flaches Objekt. */ +export function auditDetails( + details: Record | undefined, +): Record | undefined { + if (!details) return undefined + return detailsSaeubern(details) as Record +} diff --git a/src/lib/bereich.test.ts b/src/lib/bereich.test.ts new file mode 100644 index 0000000..b6292a2 --- /dev/null +++ b/src/lib/bereich.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { + KAPAZITAET_FELDER, + bereichSchema, + farbeSchema, + kapazitaetAmTag, + wochenKapazitaet, +} from './bereich' + +const gueltig = { + name: 'Gewerbe', + farbe: '#2563EB', + reihenfolge: 1, + aktiv: true, + arbeitsfensterVon: '08:00', + arbeitsfensterBis: '17:00', + kapazitaetMo: 240, + kapazitaetDi: 240, + kapazitaetMi: 240, + kapazitaetDo: 240, + kapazitaetFr: 120, + kapazitaetSa: 0, + kapazitaetSo: 0, +} + +describe('farbeSchema', () => { + it('nimmt sechsstellige und dreistellige Hex-Farben', () => { + expect(farbeSchema.parse('#2563eb')).toBe('#2563eb') + expect(farbeSchema.parse('#abc')).toBe('#abc') + }) + + it('macht Grossbuchstaben klein, damit Vergleiche stimmen', () => { + expect(farbeSchema.parse('#2563EB')).toBe('#2563eb') + }) + + it('weist Farben ohne Raute und mit falscher Laenge ab', () => { + expect(farbeSchema.safeParse('2563eb').success).toBe(false) + expect(farbeSchema.safeParse('#12345').success).toBe(false) + expect(farbeSchema.safeParse('blau').success).toBe(false) + }) +}) + +describe('bereichSchema', () => { + it('nimmt einen vollstaendigen Bereich an und rechnet Uhrzeiten in Minuten um', () => { + const ergebnis = bereichSchema.parse(gueltig) + expect(ergebnis.arbeitsfensterVon).toBe(480) + expect(ergebnis.arbeitsfensterBis).toBe(1020) + expect(ergebnis.farbe).toBe('#2563eb') + }) + + it('verlangt einen Namen mit mindestens zwei Zeichen', () => { + const ergebnis = bereichSchema.safeParse({ ...gueltig, name: 'A' }) + expect(ergebnis.success).toBe(false) + }) + + it('weist ein Arbeitsfenster ab, das vor seinem Beginn endet', () => { + const ergebnis = bereichSchema.safeParse({ + ...gueltig, + arbeitsfensterVon: '17:00', + arbeitsfensterBis: '08:00', + }) + expect(ergebnis.success).toBe(false) + if (!ergebnis.success) { + expect(ergebnis.error.issues[0]?.message).toMatch(/nach seinem Beginn enden/) + } + }) + + it('weist ein Arbeitsfenster ohne Dauer ab', () => { + const ergebnis = bereichSchema.safeParse({ + ...gueltig, + arbeitsfensterVon: '08:00', + arbeitsfensterBis: '08:00', + }) + expect(ergebnis.success).toBe(false) + }) + + it('weist eine Kapazitaet ab, die laenger ist als das Arbeitsfenster', () => { + // Fenster 08:00–17:00 sind 540 Minuten. + const ergebnis = bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 600 }) + expect(ergebnis.success).toBe(false) + if (!ergebnis.success) { + expect(ergebnis.error.issues[0]?.message).toMatch(/Arbeitsfenster/) + } + }) + + it('laesst eine Kapazitaet genau in Fensterlaenge zu', () => { + expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 540 }).success).toBe(true) + }) + + it('weist negative und uebergrosse Kapazitaeten ab', () => { + expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: -1 }).success).toBe(false) + expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 1441 }).success).toBe( + false, + ) + }) + + it('weist eine unsinnige Uhrzeit ab', () => { + expect( + bereichSchema.safeParse({ ...gueltig, arbeitsfensterVon: '25:00' }).success, + ).toBe(false) + }) +}) + +describe('wochenKapazitaet', () => { + it('summiert alle sieben Tage', () => { + const bereich = bereichSchema.parse(gueltig) + expect(wochenKapazitaet(bereich)).toBe(1080) + }) + + it('ist null, wenn nichts eingeplant ist', () => { + const leer = Object.fromEntries(KAPAZITAET_FELDER.map((f) => [f, 0])) as Record< + (typeof KAPAZITAET_FELDER)[number], + number + > + expect(wochenKapazitaet(leer)).toBe(0) + }) +}) + +describe('kapazitaetAmTag', () => { + it('trifft den richtigen Wochentag, Montag ist null', () => { + const bereich = bereichSchema.parse(gueltig) + expect(kapazitaetAmTag(bereich, 0)).toBe(240) + expect(kapazitaetAmTag(bereich, 4)).toBe(120) + expect(kapazitaetAmTag(bereich, 6)).toBe(0) + }) + + it('gibt fuer einen Index ausserhalb der Woche null zurueck', () => { + const bereich = bereichSchema.parse(gueltig) + expect(kapazitaetAmTag(bereich, 7)).toBe(0) + expect(kapazitaetAmTag(bereich, -1)).toBe(0) + }) +}) diff --git a/src/lib/bereich.ts b/src/lib/bereich.ts new file mode 100644 index 0000000..cdc05b5 --- /dev/null +++ b/src/lib/bereich.ts @@ -0,0 +1,100 @@ +import { z } from 'zod' +import { ausUhrzeit } from '@/lib/zeit' + +/** + * Regeln fuer Bereiche. Reine Logik, damit Formular und Server Action + * dieselbe Antwort geben. + */ + +export const WOCHENTAGE = [ + 'Montag', + 'Dienstag', + 'Mittwoch', + 'Donnerstag', + 'Freitag', + 'Samstag', + 'Sonntag', +] as const + +export const KAPAZITAET_FELDER = [ + 'kapazitaetMo', + 'kapazitaetDi', + 'kapazitaetMi', + 'kapazitaetDo', + 'kapazitaetFr', + 'kapazitaetSa', + 'kapazitaetSo', +] as const + +export type KapazitaetFeld = (typeof KAPAZITAET_FELDER)[number] + +/** Hex-Farbe mit Raute, drei- oder sechsstellig. */ +export const farbeSchema = z + .string() + .trim() + .regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Bitte eine Farbe wie #2563eb angeben.') + .transform((f) => f.toLowerCase()) + +const uhrzeitSchema = z + .string() + .trim() + .refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 08:00 angeben.') + .transform((w) => ausUhrzeit(w) as number) + +/** Eine Tageskapazitaet: 0 bis 24 Stunden, in Minuten. */ +const kapazitaetSchema = z.coerce + .number() + .int('Bitte ganze Minuten angeben.') + .min(0, 'Nicht weniger als 0 Minuten.') + .max(1440, 'Nicht mehr als 1440 Minuten, also 24 Stunden.') + +export const bereichSchema = z + .object({ + name: z + .string() + .trim() + .min(2, 'Mindestens zwei Zeichen.') + .max(40, 'Höchstens 40 Zeichen.'), + farbe: farbeSchema, + reihenfolge: z.coerce.number().int().min(0).max(999).default(0), + aktiv: z.coerce.boolean().default(true), + arbeitsfensterVon: uhrzeitSchema, + arbeitsfensterBis: uhrzeitSchema, + kapazitaetMo: kapazitaetSchema, + kapazitaetDi: kapazitaetSchema, + kapazitaetMi: kapazitaetSchema, + kapazitaetDo: kapazitaetSchema, + kapazitaetFr: kapazitaetSchema, + kapazitaetSa: kapazitaetSchema, + kapazitaetSo: kapazitaetSchema, + }) + .refine((b) => b.arbeitsfensterVon < b.arbeitsfensterBis, { + path: ['arbeitsfensterBis'], + message: 'Das Arbeitsfenster muss nach seinem Beginn enden.', + }) + .refine( + (b) => + KAPAZITAET_FELDER.every( + (feld) => b[feld] <= b.arbeitsfensterBis - b.arbeitsfensterVon, + ), + { + path: ['kapazitaetMo'], + message: 'Keine Tageskapazität darf länger sein als das Arbeitsfenster.', + }, + ) + +export type BereichEingabe = z.infer + +/** Summe der Wochenkapazitaet in Minuten. */ +export function wochenKapazitaet(bereich: Record): number { + return KAPAZITAET_FELDER.reduce((summe, feld) => summe + bereich[feld], 0) +} + +/** Kapazitaet des Wochentags, 0 = Montag. */ +export function kapazitaetAmTag( + bereich: Record, + wochentagIndex: number, +): number { + const feld = KAPAZITAET_FELDER[wochentagIndex] + return feld ? bereich[feld] : 0 +} diff --git a/src/server/aktionen/bereiche.ts b/src/server/aktionen/bereiche.ts new file mode 100644 index 0000000..f26c9d8 --- /dev/null +++ b/src/server/aktionen/bereiche.ts @@ -0,0 +1,169 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { bereichSchema } from '@/lib/bereich' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' + +/** + * Server Actions fuer Bereiche. + * + * Jede prueft die Rolle selbst – die Seite tut es ebenfalls, aber eine Action + * ist ein eigener Einstiegspunkt und darf sich darauf nicht verlassen. Jede + * schreibende Aktion hinterlaesst einen Audit-Eintrag. + */ + +export type Rueckmeldung = + | { ok: true; meldung: string } + | { ok: false; fehler: string; felder?: Record } + +function feldFehler(fehler: z.ZodError): Record { + const felder: Record = {} + for (const problem of fehler.issues) { + const feld = problem.path.join('.') + felder[feld] ??= problem.message + } + return felder +} + +function alsFormularwerte(daten: FormData): Record { + const werte = Object.fromEntries(daten.entries()) + return { + ...werte, + // Ein nicht angehaktes Kaestchen taucht im FormData gar nicht auf. + aktiv: daten.get('aktiv') !== null, + } +} + +export async function bereichAnlegen(daten: FormData): Promise { + let akteur + try { + akteur = await pruefeRolle('ADMIN') + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const gelesen = bereichSchema.safeParse(alsFormularwerte(daten)) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const vorhanden = await db.bereich.findUnique({ + where: { name: gelesen.data.name }, + select: { id: true }, + }) + if (vorhanden) { + return { + ok: false, + fehler: 'Diesen Namen gibt es schon.', + felder: { name: 'Diesen Namen gibt es schon.' }, + } + } + + const bereich = await db.bereich.create({ + data: gelesen.data, + select: { id: true, name: true }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'bereich.angelegt', + objektTyp: 'Bereich', + objektId: bereich.id, + details: { name: bereich.name, farbe: gelesen.data.farbe }, + }) + + revalidatePath('/bereiche') + return { ok: true, meldung: `Bereich „${bereich.name}" angelegt.` } +} + +export async function bereichAendern(id: string, daten: FormData): Promise { + let akteur + try { + akteur = await pruefeRolle('ADMIN') + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const gelesen = bereichSchema.safeParse(alsFormularwerte(daten)) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const vorher = await db.bereich.findUnique({ + where: { id }, + select: { id: true, name: true }, + }) + if (!vorher) return { ok: false, fehler: 'Diesen Bereich gibt es nicht mehr.' } + + const namensdublette = await db.bereich.findFirst({ + where: { name: gelesen.data.name, id: { not: id } }, + select: { id: true }, + }) + if (namensdublette) { + return { + ok: false, + fehler: 'Diesen Namen gibt es schon.', + felder: { name: 'Diesen Namen gibt es schon.' }, + } + } + + await db.bereich.update({ where: { id }, data: gelesen.data }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'bereich.geaendert', + objektTyp: 'Bereich', + objektId: id, + details: { vorher: vorher.name, nachher: gelesen.data.name }, + }) + + revalidatePath('/bereiche') + return { ok: true, meldung: `Bereich „${gelesen.data.name}" gespeichert.` } +} + +const loeschEingabe = z.object({ id: z.string().min(1) }) + +export async function bereichLoeschen(daten: FormData): Promise { + let akteur + try { + akteur = await pruefeRolle('ADMIN') + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const gelesen = loeschEingabe.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const bereich = await db.bereich.findUnique({ + where: { id: gelesen.data.id }, + select: { id: true, name: true }, + }) + if (!bereich) return { ok: false, fehler: 'Diesen Bereich gibt es nicht mehr.' } + + await db.bereich.delete({ where: { id: bereich.id } }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'bereich.geloescht', + objektTyp: 'Bereich', + objektId: bereich.id, + details: { name: bereich.name }, + }) + + revalidatePath('/bereiche') + return { ok: true, meldung: `Bereich „${bereich.name}" gelöscht.` } +} diff --git a/src/server/aktionen/passkeys.ts b/src/server/aktionen/passkeys.ts new file mode 100644 index 0000000..1ede0b5 --- /dev/null +++ b/src/server/aktionen/passkeys.ts @@ -0,0 +1,48 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' + +export type Rueckmeldung = { ok: true; meldung: string } | { ok: false; fehler: string } + +const eingabe = z.object({ id: z.string().min(1) }) + +/** + * Entfernt einen Passkey. Jeder darf nur seine eigenen entfernen – deshalb + * geht die Benutzer-Kennung in die where-Bedingung und wird nicht nur + * nachträglich verglichen. + */ +export async function passkeyEntfernen(daten: FormData): Promise { + let akteur + try { + akteur = await pruefeRolle('BENUTZER') + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const gelesen = eingabe.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const passkey = await db.passkey.findFirst({ + where: { id: gelesen.data.id, benutzerId: akteur.id }, + select: { id: true, bezeichnung: true }, + }) + if (!passkey) return { ok: false, fehler: 'Diesen Passkey gibt es nicht mehr.' } + + await db.passkey.delete({ where: { id: passkey.id } }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'passkey.entfernt', + objektTyp: 'Passkey', + objektId: passkey.id, + details: { bezeichnung: passkey.bezeichnung }, + }) + + revalidatePath('/einstellungen') + return { ok: true, meldung: `Passkey „${passkey.bezeichnung}" entfernt.` } +} diff --git a/src/server/audit.ts b/src/server/audit.ts new file mode 100644 index 0000000..88bae52 --- /dev/null +++ b/src/server/audit.ts @@ -0,0 +1,44 @@ +import 'server-only' +import type { Prisma } from '@/generated/prisma/client' +import { auditDetails } from '@/lib/audit' +import { db } from '@/server/db' +import { aktuelleInstanz } from '@/server/instanz' + +/** + * Nachweis jeder schreibenden Aktion. Die Details werden vorher gesaeubert + * (siehe @/lib/audit), damit keine Geheimnisse in der Datenbank landen. + */ + +export type AuditAkteur = { + id: string | null + email: string +} + +export type AuditEingabe = { + akteur: AuditAkteur + /** Was geschah, in der Form "objekt.verb", z. B. "bereich.angelegt". */ + aktion: string + objektTyp: string + objektId?: string | null + details?: Record | undefined +} + +/** + * Schreibt einen Audit-Eintrag. Schlaegt das fehl, darf die eigentliche + * Aktion nicht als geglueckt gelten – deshalb wird der Fehler weitergereicht + * und nicht verschluckt. + */ +export async function auditSchreiben(eingabe: AuditEingabe): Promise { + const details = auditDetails(eingabe.details) + await db.auditEintrag.create({ + data: { + benutzerId: eingabe.akteur.id, + benutzerEmail: eingabe.akteur.email, + aktion: eingabe.aktion, + objektTyp: eingabe.objektTyp, + objektId: eingabe.objektId ?? null, + ...(details ? { details: details as Prisma.InputJsonObject } : {}), + instanz: aktuelleInstanz(), + }, + }) +}