From b5b43f41f6bf0a3f26ad8e3813e337e79df689e4 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 10:38:55 +0200 Subject: [PATCH] feat(personen): Personen und Self-Service Meine Schluessel (#013) Personen lassen sich anlegen, bearbeiten und archivieren; ihr Detail zeigt ihre Schluessel. Unter "Meine Schluessel" hinterlegt jede Rolle eigene Public Keys (Freigabe Frage 4), wirksam erst nach Zuweisung durch einen Operator oder Admin. Warum so: - Die eigene Person entsteht beim ersten Aufruf oder wird verknuepft: per benutzerId, sonst per E-Mail - aber nur, wenn der OIDC-Anbieter die Adresse bestaetigt hat. Dafuer merkt sich Benutzer jetzt emailBestaetigt (Migration); sonst koennte jemand mit selbst gewaehlter Adresse die Keys einer fremden Person sehen. - Die Self-Service-Services nehmen nur den angemeldeten Benutzer und leiten die Person selbst ab; fremde Schluessel-IDs gelten als nicht vorhanden. - Entfernen nur fuer eigene, selbst hinterlegte Keys ohne Verwendung, bedingt geloescht, damit eine gleichzeitige Zuweisung nicht per Cascade verschwindet. - Archivieren statt Loeschen: wer bis wann welchen Key hatte, bleibt nachvollziehbar. Co-Authored-By: Claude Opus 5.5 --- docs/issues/013-personen-selbstauskunft.md | 31 ++- .../migration.sql | 4 + prisma/schema.prisma | 3 + scripts/screenshots.ts | 2 + src/app/(app)/meine-schluessel/page.tsx | 78 +++++++ .../(app)/personen/[id]/bearbeiten/page.tsx | 44 ++++ src/app/(app)/personen/[id]/page.tsx | 153 +++++++++++++ src/app/(app)/personen/neu/page.tsx | 23 ++ src/app/(app)/personen/page.tsx | 132 ++++++++++- src/app/(app)/schluessel/[id]/page.tsx | 5 +- src/components/huelle/nav-punkte.ts | 3 + src/components/personen/MeineSchluessel.tsx | 115 ++++++++++ src/components/personen/PersonFormular.tsx | 151 +++++++++++++ src/lib/personen.ts | 52 +++++ src/server/actions/meine-schluessel.ts | 43 ++++ src/server/actions/personen.ts | 68 ++++++ src/server/queries/personen.ts | 103 +++++++++ src/server/services/anmeldung.ts | 2 + src/server/services/meine-schluessel.ts | 195 ++++++++++++++++ src/server/services/personen.ts | 139 ++++++++++++ tests/e2e/schluessel.spec.ts | 20 ++ tests/personen.test.ts | 213 ++++++++++++++++++ 22 files changed, 1569 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20261012103000_benutzer_email_bestaetigt/migration.sql create mode 100644 src/app/(app)/meine-schluessel/page.tsx create mode 100644 src/app/(app)/personen/[id]/bearbeiten/page.tsx create mode 100644 src/app/(app)/personen/[id]/page.tsx create mode 100644 src/app/(app)/personen/neu/page.tsx create mode 100644 src/components/personen/MeineSchluessel.tsx create mode 100644 src/components/personen/PersonFormular.tsx create mode 100644 src/lib/personen.ts create mode 100644 src/server/actions/meine-schluessel.ts create mode 100644 src/server/actions/personen.ts create mode 100644 src/server/queries/personen.ts create mode 100644 src/server/services/meine-schluessel.ts create mode 100644 src/server/services/personen.ts create mode 100644 tests/personen.test.ts diff --git a/docs/issues/013-personen-selbstauskunft.md b/docs/issues/013-personen-selbstauskunft.md index e814e0e..affbede 100644 --- a/docs/issues/013-personen-selbstauskunft.md +++ b/docs/issues/013-personen-selbstauskunft.md @@ -1,20 +1,41 @@ # #013 Personen und Self-Service -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.2.0 **Labels:** backend, ui -**Geschätzt:** 2 h **Tatsächlich:** – +**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min ## Ziel Personen verwalten; jeder angemeldete Benutzer kann eigene Public Keys hinterlegen, wirksam erst nach Zuweisung. ## Aufgaben -- [ ] Personenliste/-detail -- [ ] Seite „Meine Schlüssel“ +- [x] Personenliste/-detail +- [x] Seite „Meine Schlüssel“ ## Akzeptanzkriterien -- [ ] Leser kann nur eigene Keys hinterlegen, nicht zuweisen +- [x] Leser kann nur eigene Keys hinterlegen, nicht zuweisen ## Abhängigkeiten #012 ## Notizen / Entscheidungen +- **Personen pflegt, wer Schlüssel verwaltet** (Recht `schluesselVerwalten`, Admin und Operator) – + ein eigenes Recht wäre nur ein Alias. Lesen dürfen alle Rollen. +- **Archivieren statt Löschen:** die Schlüssel bleiben der Person zugeordnet (wer hatte bis wann + welchen Key?), die Kontoverknüpfung entfällt, damit der Self-Service nicht bei einer + archivierten Person landet. Die Notiz steht nicht im Protokoll, nur dass sie sich änderte. +- **Self-Service `/meine-schluessel`** (Recht `eigeneSchluesselHinterlegen`, alle Rollen, + Navigation „Meine Schlüssel“): beim ersten Aufruf wird die eigene Person angelegt oder + verknüpft – per `benutzerId`, sonst per E-Mail, aber **nur bei vom Anbieter bestätigter + Adresse** und genau einer freien, nicht archivierten Person. Dafür neue Spalte + `Benutzer.emailBestaetigt` (Migration `20261012103000_benutzer_email_bestaetigt`), gesetzt bei + jeder OIDC-Anmeldung. Ohne sie könnte jemand mit selbst gewählter Adresse die Keys einer fremden + Person sehen. +- Hinterlegte Keys: Kategorie `PERSON`, `selbstHinterlegt = true`, Protokoll + `schluessel.selbst_hinterlegt`. Hinweis in der Oberfläche: „Wirksam erst, wenn ein Operator + oder Admin den Schlüssel einem Host zuweist.“ +- **Entfernen** nur eigene, selbst hinterlegte Keys ohne jede Verwendung (auch keine entfernte – + der Verlauf am Key bleibt). Das Löschen ist bedingt (`deleteMany` mit „keine Verwendungen“), + damit eine gleichzeitige Zuweisung nicht per Cascade verschwindet. Fremde IDs gelten als „nicht + vorhanden“. Tests: `tests/personen.test.ts`. +- Die Services nehmen den angemeldeten Benutzer entgegen und leiten die Person selbst ab; die + Actions reichen keine `personId` von außen durch (struktureller Test). diff --git a/prisma/migrations/20261012103000_benutzer_email_bestaetigt/migration.sql b/prisma/migrations/20261012103000_benutzer_email_bestaetigt/migration.sql new file mode 100644 index 0000000..aa9ea4e --- /dev/null +++ b/prisma/migrations/20261012103000_benutzer_email_bestaetigt/migration.sql @@ -0,0 +1,4 @@ +-- Self-Service „Meine Schluessel“ (#013): ein Konto wird per E-Mail nur dann mit einer +-- vorhandenen Person verknuepft, wenn der OIDC-Anbieter die Adresse bestaetigt hat. Sonst +-- koennte jemand mit selbst gewaehlter Adresse die Schluessel einer fremden Person sehen. +ALTER TABLE "Benutzer" ADD COLUMN "emailBestaetigt" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4147bbd..6b67b8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -135,6 +135,9 @@ model Benutzer { /// `sub` des OIDC-Anbieters; leer beim Notfall-Admin. oidcSub String? @unique email String? @unique + /// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der + /// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013). + emailBestaetigt Boolean @default(false) /// Anmeldename des Notfall-Admins. anmeldename String? @unique name String diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index d66b332..e7edef6 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -35,6 +35,8 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri pfad: "/schluessel?q=wartung", folgen: 'main table a[href^="/schluessel/"]', }, + { name: "personen", pfad: "/personen" }, + { name: "meine-schluessel", pfad: "/meine-schluessel" }, { name: "hosts", pfad: "/hosts" }, { name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' }, ]; diff --git a/src/app/(app)/meine-schluessel/page.tsx b/src/app/(app)/meine-schluessel/page.tsx new file mode 100644 index 0000000..a46fd77 --- /dev/null +++ b/src/app/(app)/meine-schluessel/page.tsx @@ -0,0 +1,78 @@ +import { KeyRound } from "lucide-react"; +import type { Metadata } from "next"; +import { KeyEntfernen, KeyHinterlegen } from "@/components/personen/MeineSchluessel"; +import { StatusBadge, TypBadge } from "@/components/schluessel/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { MonoKopieren } from "@/components/ui/MonoKopieren"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { datum } from "@/lib/format"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { eigeneSchluessel } from "@/server/services/meine-schluessel"; + +export const metadata: Metadata = { title: "Meine Schlüssel" }; + +/** + * Self-Service (#013): eigene Public Keys hinterlegen und – solange nirgends zugewiesen – + * wieder entfernen. Beim ersten Aufruf entsteht die eigene Person (oder wird verknuepft). + */ +export default async function MeineSchluesselSeite() { + const { benutzer } = await sitzungErforderlich("eigeneSchluesselHinterlegen", "/meine-schluessel"); + const { schluessel } = await eigeneSchluessel(benutzer); + + return ( +
+ + + + {schluessel.length === 0 ? ( + } + titel="Noch keine Schlüssel" + text="Hinterlegen Sie unten Ihren Public Key. Danach kann ein Operator oder Admin ihn den Servern zuweisen, auf die Sie Zugriff brauchen." + /> + ) : ( +
    + {schluessel.map((s) => ( +
  • +
    +

    {s.name}

    + + + + + {s.verwendungen > 0 ? ( + + {s.verwendungen === 1 ? "1 Host-Zuweisung" : `${s.verwendungen} Host-Zuweisungen`} + + ) : ( + noch nicht zugewiesen + )} + seit {datum(s.angelegtAm)} + +
    + {s.selbstHinterlegt && s.verwendungen === 0 && ( + + )} +
  • + ))} +
+ )} +
+ + +
+ ); +} diff --git a/src/app/(app)/personen/[id]/bearbeiten/page.tsx b/src/app/(app)/personen/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..3c5d094 --- /dev/null +++ b/src/app/(app)/personen/[id]/bearbeiten/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PersonFormular } from "@/components/personen/PersonFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { kontenAuswahl, personDetail } from "@/server/queries/personen"; + +export const metadata: Metadata = { title: "Person bearbeiten" }; + +export default async function PersonBearbeiten({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await sitzungErforderlich("schluesselVerwalten", `/personen/${id}/bearbeiten`); + const [person, konten] = await Promise.all([personDetail(id), kontenAuswahl(id)]); + if (!person || person.archiviertAm) notFound(); + return ( +
+ + } + titel="Person bearbeiten" + /> + +
+ ); +} diff --git a/src/app/(app)/personen/[id]/page.tsx b/src/app/(app)/personen/[id]/page.tsx new file mode 100644 index 0000000..bc6a8d8 --- /dev/null +++ b/src/app/(app)/personen/[id]/page.tsx @@ -0,0 +1,153 @@ +import { KeyRound, Pencil, Plus } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; +import { PersonArchivieren } from "@/components/personen/PersonFormular"; +import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Button } from "@/components/ui/Button"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { datum } from "@/lib/format"; +import { fingerprintKurz } from "@/lib/schluessel"; +import { darf } from "@/server/auth/rechte"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { personDetail } from "@/server/queries/personen"; + +export const metadata: Metadata = { title: "Person" }; + +function Info({ titel, children }: { titel: string; children: ReactNode }) { + return ( +
+
{titel}
+
{children}
+
+ ); +} + +/** Persondetail (#013) mit ihren Schlüsseln. */ +export default async function PersonSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("lesen", `/personen/${id}`); + const person = await personDetail(id); + if (!person) notFound(); + const archiviert = person.archiviertAm !== null; + const verwalten = darf(benutzer.rolle, "schluesselVerwalten") && !archiviert; + const jetzt = new Date(); + + return ( +
+ } + titel={person.name} + badges={ + <> + {archiviert && person.archiviertAm && ( + archiviert am {datum(person.archiviertAm)} + )} + {person.organisation && {person.organisation}} + {person.benutzer && Konto verknüpft} + + } + aktionen={ + verwalten && ( + + ) + } + /> + + +
+ {person.email ?? –} + + {person.benutzer ? ( + <> + {person.benutzer.name} + {person.benutzer.email && ( + {person.benutzer.email} + )} + + ) : ( + nicht verknüpft + )} + + {datum(person.angelegtAm)} +
+ {person.notiz && ( +

+ {person.notiz} +

+ )} +
+ + } + > + Public Key hinterlegen + + ) + } + > + {person.schluessel.length === 0 ? ( + } + titel="Noch keine Schlüssel" + text="Die Person kann ihre Public Keys auch selbst unter „Meine Schlüssel“ hinterlegen, sobald ihr Konto verknüpft ist." + /> + ) : ( +
    + {person.schluessel.map((s) => ( +
  • + + {s.name} + + + {fingerprintKurz(s.fingerprint)} + + + + + + {s.selbstHinterlegt && selbst hinterlegt} + + + {s.verwendungen === 0 + ? "· noch keinem Host zugewiesen" + : `· ${s.verwendungen} ${s.verwendungen === 1 ? "Verwendung" : "Verwendungen"}`} + + +
  • + ))} +
+ )} +
+ + {verwalten && ( + +
+

+ Archivierte Personen verschwinden aus Listen. Ihre Schlüssel bleiben bestehen und lassen sich + weiterhin widerrufen. +

+ +
+
+ )} +
+ ); +} diff --git a/src/app/(app)/personen/neu/page.tsx b/src/app/(app)/personen/neu/page.tsx new file mode 100644 index 0000000..f337aa8 --- /dev/null +++ b/src/app/(app)/personen/neu/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { LEERE_PERSON, PersonFormular } from "@/components/personen/PersonFormular"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { kontenAuswahl } from "@/server/queries/personen"; + +export const metadata: Metadata = { title: "Person anlegen" }; + +export default async function PersonNeu() { + await sitzungErforderlich("schluesselVerwalten", "/personen/neu"); + const konten = await kontenAuswahl(); + return ( +
+ } + titel="Person anlegen" + untertitel="Mitarbeitende, Admins oder Dienstleister, denen Personen-Keys gehören." + /> + +
+ ); +} diff --git a/src/app/(app)/personen/page.tsx b/src/app/(app)/personen/page.tsx index 15f9afd..0296f9c 100644 --- a/src/app/(app)/personen/page.tsx +++ b/src/app/(app)/personen/page.tsx @@ -1,13 +1,137 @@ +import { Plus, Search, Users } from "lucide-react"; import type { Metadata } from "next"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import Link from "next/link"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Feld } from "@/components/ui/Feld"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { personenListe } from "@/server/queries/personen"; export const metadata: Metadata = { title: "Personen" }; -export default async function Personen() { +type Suchparameter = Promise>; + +function einzeln(wert: string | string[] | undefined): string { + return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? ""; +} + +function Anzahl({ n }: { n: number }) { + return n === 0 ? keine : <>{n}; +} + +/** Personenliste (#013): wem welche Schlüssel gehören. */ +export default async function Personen({ searchParams }: { searchParams: Suchparameter }) { // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); + const { benutzer } = await sitzungErforderlich("lesen", "/personen"); + const q = einzeln((await searchParams)["q"]); + const personen = await personenListe({ q }); + const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); + const anlegen = verwalten && ( + + ); + return ( - +
+ +
+ +
+ + {q && ( + + )} +
+ + + {personen.length === 0 ? ( + } + titel={q ? "Keine Treffer" : "Noch keine Personen"} + text={ + q + ? "Keine Person passt zur Suche." + : "Personen entstehen hier oder von selbst, wenn jemand unter „Meine Schlüssel“ einen Key hinterlegt." + } + aktion={q ? : anlegen} + /> + ) : ( + +
    + {personen.map((p) => ( +
  • + + {p.name} + {p.email && {p.email}} + + {p.organisation && {p.organisation}} + {p.benutzer && Konto verknüpft} + + + Aktive Schlüssel: + + +
  • + ))} +
+ + {personen.map((p) => ( + + + + {p.name} + + + {p.email ?? "–"} + {p.organisation ?? –} + + {p.benutzer ? p.benutzer.name : –} + + + + + + ))} + +
+ )} +
); } diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx index 2f8fecb..9b95ac7 100644 --- a/src/app/(app)/schluessel/[id]/page.tsx +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -170,7 +170,10 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id untertitel={ s.person ? ( <> - Person: {s.person.name} + Person:{" "} + + {s.person.name} + ) : undefined } diff --git a/src/components/huelle/nav-punkte.ts b/src/components/huelle/nav-punkte.ts index 0537ba3..c331639 100644 --- a/src/components/huelle/nav-punkte.ts +++ b/src/components/huelle/nav-punkte.ts @@ -1,6 +1,7 @@ import { Boxes, KeyRound, + KeySquare, LayoutDashboard, LayoutTemplate, Network, @@ -20,6 +21,8 @@ export const HAUPTNAVIGATION: NavPunkt[] = [ { href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes }, { href: "/schluessel", titel: "Schlüssel", icon: KeyRound }, { href: "/personen", titel: "Personen", icon: Users }, + // Self-Service fuer jede Rolle (#013): eigene Public Keys hinterlegen. + { href: "/meine-schluessel", titel: "Meine Schlüssel", icon: KeySquare }, { href: "/zuweisungen", titel: "Zuweisungen", icon: Network }, { href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate }, ]; diff --git a/src/components/personen/MeineSchluessel.tsx b/src/components/personen/MeineSchluessel.tsx new file mode 100644 index 0000000..a8267de --- /dev/null +++ b/src/components/personen/MeineSchluessel.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; +import { useActionState, useEffect, useRef } from "react"; +import { useFormularAktion } from "@/components/hosts/useFormularAktion"; +import { Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui"; +import type { FormularZustand } from "@/server/actions/formular"; +import { + eigenenKeyEntfernenAktion, + eigenenKeyHinterlegenAktion, + type HinterlegenZustand, +} from "@/server/actions/meine-schluessel"; + +/** Eigenen Public Key hinterlegen (#013). Nach Erfolg leert sich das Formular. */ +export function KeyHinterlegen() { + const formular = useRef(null); + const { zustand, absenden, laeuft } = useFormularAktion( + eigenenKeyHinterlegenAktion as (v: FormularZustand, f: FormData) => Promise, + ); + const erfolg = (zustand as HinterlegenZustand).erfolg; + useEffect(() => { + if (erfolg) formular.current?.reset(); + }, [erfolg]); + const f = zustand.feldfehler ?? {}; + return ( + +
+ + {erfolg && ( +

+ {erfolg} +

+ )} + + +

+ Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist. Den Private Key + behalten Sie bei sich – sshkv fragt nie danach. +

+
+ +
+ +
+ ); +} + +/** Eigenen, noch nirgends zugewiesenen Key entfernen – mit Rueckfrage. */ +export function KeyEntfernen({ schluesselId, name }: { schluesselId: string; name: string }) { + const dialog = useRef(null); + const [zustand, aktion, laeuft] = useActionState(eigenenKeyEntfernenAktion, {}); + return ( + <> + + +
+ +

+ „{name}“ entfernen? +

+

+ Der Public Key verschwindet aus sshkv. Er ist noch keinem Host zugewiesen; auf keinem Server + ändert sich etwas. +

+ +
+ + +
+ +
+ + ); +} diff --git a/src/components/personen/PersonFormular.tsx b/src/components/personen/PersonFormular.tsx new file mode 100644 index 0000000..fe1cc1a --- /dev/null +++ b/src/components/personen/PersonFormular.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { Archive } from "lucide-react"; +import { useActionState, useRef } from "react"; +import { useFormularAktion } from "@/components/hosts/useFormularAktion"; +import { Auswahlliste, Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui"; +import type { FormularZustand } from "@/server/actions/formular"; +import { + personAnlegenAktion, + personArchivierenAktion, + personBearbeitenAktion, +} from "@/server/actions/personen"; + +export type PersonFormularVorgabe = { + id?: string; + name: string; + email: string; + organisation: string; + notiz: string; + benutzerId: string; +}; + +export const LEERE_PERSON: PersonFormularVorgabe = { + name: "", + email: "", + organisation: "", + notiz: "", + benutzerId: "", +}; + +/** Formular fuer neue und bestehende Personen (#013). Regeln prueft der Server. */ +export function PersonFormular({ + vorgabe, + konten, + abbrechenZiel, +}: { + vorgabe: PersonFormularVorgabe; + konten: { id: string; name: string; email: string | null }[]; + abbrechenZiel: string; +}) { + const bearbeiten = Boolean(vorgabe.id); + const { zustand, absenden, laeuft } = useFormularAktion( + bearbeiten ? personBearbeitenAktion : personAnlegenAktion, + ); + const f = zustand.feldfehler ?? {}; + return ( +
+ {vorgabe.id && } + + +
+ + + + + + {konten.map((k) => ( + + ))} + + {f["benutzerId"] &&

{f["benutzerId"]}

} + +
+
+
+ + +
+ + ); +} + +/** Archivieren mit Rueckfrage, nativer `` wie bei Hosts (#008). */ +export function PersonArchivieren({ personId, name }: { personId: string; name: string }) { + const dialog = useRef(null); + const [zustand, aktion, laeuft] = useActionState(personArchivierenAktion, {}); + return ( + <> + + +
+ +

+ {name} archivieren? +

+

+ Die Person verschwindet aus Listen und Auswahlfeldern, die Verknüpfung zum Konto entfällt. Ihre + Schlüssel bleiben zugeordnet und unverändert – widerrufen ist ein eigener Schritt. +

+ +
+ + +
+ +
+ + ); +} diff --git a/src/lib/personen.ts b/src/lib/personen.ts new file mode 100644 index 0000000..ed5458a --- /dev/null +++ b/src/lib/personen.ts @@ -0,0 +1,52 @@ +import { z } from "@/lib/validierung"; + +/** + * Eingaben fuer Personen und den Self-Service (#013) – framework-frei, damit Service, + * Actions und Tests dieselben Regeln teilen. + */ + +const optionalerText = (max: number) => + z + .string() + .trim() + .max(max, `Höchstens ${max} Zeichen.`) + .nullish() + .transform((wert) => (wert ? wert : null)); + +export const personEingabeSchema = z.object({ + name: z.string().trim().min(1, "Bitte einen Namen angeben.").max(120, "Höchstens 120 Zeichen."), + email: z + .string() + .trim() + .toLowerCase() + .max(254) + .nullish() + .transform((wert) => (wert ? wert : null)) + .refine( + (wert) => wert === null || z.email().safeParse(wert).success, + "Bitte eine gültige E-Mail-Adresse.", + ), + organisation: optionalerText(120), + notiz: optionalerText(1000), + benutzerId: z + .string() + .trim() + .max(64) + .nullish() + .transform((wert) => (wert ? wert : null)), +}); + +export type PersonEingabe = z.input; +export type PersonDaten = z.output; + +/** Eigener Public Key im Self-Service: Zweck und die Zeile, sonst nichts. */ +export const eigenerKeySchema = z.object({ + name: z.string().trim().min(1, "Bitte eine Bezeichnung angeben.").max(120, "Höchstens 120 Zeichen."), + publicKey: z + .string() + .trim() + .min(1, "Bitte den Public Key einfügen.") + .max(16 * 1024, "Der Text ist zu lang für einen Public Key."), +}); + +export type EigenerKeyEingabe = z.input; diff --git a/src/server/actions/meine-schluessel.ts b/src/server/actions/meine-schluessel.ts new file mode 100644 index 0000000..170a332 --- /dev/null +++ b/src/server/actions/meine-schluessel.ts @@ -0,0 +1,43 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular"; +import { rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { eigenenPublicKeyHinterlegen, eigenenSchluesselEntfernen } from "@/server/services/meine-schluessel"; + +/** + * Self-Service (#013). Recht `eigeneSchluesselHinterlegen` hat jede Rolle; der Service + * arbeitet ausschliesslich mit der Person des angemeldeten Kontos. + */ +export type HinterlegenZustand = FormularZustand & { readonly erfolg?: string }; + +export async function eigenenKeyHinterlegenAktion( + _vorher: HinterlegenZustand, + formular: FormData, +): Promise { + try { + const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen"); + const neu = await eigenenPublicKeyHinterlegen(benutzer, { + name: textfeld(formular, "name") ?? "", + publicKey: textfeld(formular, "publicKey") ?? "", + }); + revalidatePath("/meine-schluessel"); + return { erfolg: `Hinterlegt: ${neu.fingerprint}` }; + } catch (fehler) { + return fehlerZustand(fehler); + } +} + +export async function eigenenKeyEntfernenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + try { + const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen"); + await eigenenSchluesselEntfernen(benutzer, textfeld(formular, "id") ?? ""); + revalidatePath("/meine-schluessel"); + return {}; + } catch (fehler) { + return fehlerZustand(fehler); + } +} diff --git a/src/server/actions/personen.ts b/src/server/actions/personen.ts new file mode 100644 index 0000000..5db83cc --- /dev/null +++ b/src/server/actions/personen.ts @@ -0,0 +1,68 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import type { PersonEingabe } from "@/lib/personen"; +import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular"; +import { rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen"; + +/** + * Formular-Aktionen fuer Personen (#013). Personen pflegt, wer Schluessel verwaltet (Admin, + * Operator) – eine Person ist die Klammer um Personen-Keys. + */ + +function personFelder(formular: FormData): PersonEingabe { + return { + name: textfeld(formular, "name") ?? "", + email: textfeld(formular, "email"), + organisation: textfeld(formular, "organisation"), + notiz: textfeld(formular, "notiz"), + benutzerId: textfeld(formular, "benutzerId"), + }; +} + +export async function personAnlegenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + let id: string; + try { + const { benutzer } = await rechtErforderlich("schluesselVerwalten"); + ({ id } = await personAnlegen(personFelder(formular), akteurVon(benutzer))); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/personen"); + redirect(`/personen/${id}`); +} + +export async function personBearbeitenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const id = textfeld(formular, "id") ?? ""; + try { + const { benutzer } = await rechtErforderlich("schluesselVerwalten"); + await personBearbeiten(id, personFelder(formular), akteurVon(benutzer)); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/personen", "layout"); + redirect(`/personen/${id}`); +} + +export async function personArchivierenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const id = textfeld(formular, "id") ?? ""; + try { + const { benutzer } = await rechtErforderlich("schluesselVerwalten"); + await personArchivieren(id, akteurVon(benutzer)); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/personen", "layout"); + redirect("/personen"); +} diff --git a/src/server/queries/personen.ts b/src/server/queries/personen.ts new file mode 100644 index 0000000..d4ddcd1 --- /dev/null +++ b/src/server/queries/personen.ts @@ -0,0 +1,103 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; + +/** + * Lesemodelle fuer Personen (#013). Jede angemeldete Rolle darf sie sehen; Schluessel nur mit + * festem `select` ohne Chiffrat-Felder. + */ + +export type PersonFilter = { readonly q?: string; readonly archivierte?: boolean }; + +export async function personenListe(filter: PersonFilter = {}) { + const q = filter.q?.trim().slice(0, 100); + const where: Prisma.PersonWhereInput = { + ...(filter.archivierte ? {} : { archiviertAm: null }), + ...(q + ? { + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { email: { contains: q, mode: "insensitive" } }, + { organisation: { contains: q, mode: "insensitive" } }, + ], + } + : {}), + }; + const personen = await prisma.person.findMany({ + where, + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + email: true, + organisation: true, + archiviertAm: true, + benutzer: { select: { id: true, name: true } }, + _count: { select: { schluessel: { where: { status: "AKTIV" } } } }, + }, + }); + return personen.map(({ _count, ...rest }) => ({ ...rest, aktiveSchluessel: _count.schluessel })); +} + +export type PersonListenEintrag = Awaited>[number]; + +export async function personDetail(id: string) { + const person = await prisma.person.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + organisation: true, + notiz: true, + archiviertAm: true, + angelegtAm: true, + benutzerId: true, + benutzer: { select: { id: true, name: true, email: true, rolle: true } }, + schluessel: { + orderBy: [{ status: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + typ: true, + kategorie: true, + status: true, + publicKey: true, + fingerprint: true, + ablaufAm: true, + selbstHinterlegt: true, + _count: { + select: { + publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } }, + privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } }, + }, + }, + }, + }, + }, + }); + if (!person) return null; + return { + ...person, + schluessel: person.schluessel.map(({ _count, ...rest }) => ({ + ...rest, + verwendungen: _count.publicVerwendungen + _count.privateVerwendungen, + })), + }; +} + +export type PersonDetail = NonNullable>>; + +/** + * Konten, die sich mit einer Person verknuepfen lassen: noch keiner Person zugeordnet, oder + * der Person `personId` selbst. Der Notfall-Admin ist kein Mensch mit eigenen Keys. + */ +export async function kontenAuswahl(personId?: string) { + return prisma.benutzer.findMany({ + where: { + istNotfall: false, + OR: [{ person: null }, ...(personId ? [{ person: { id: personId } }] : [])], + }, + orderBy: { name: "asc" }, + select: { id: true, name: true, email: true }, + }); +} diff --git a/src/server/services/anmeldung.ts b/src/server/services/anmeldung.ts index 946f7bf..3b2a768 100644 --- a/src/server/services/anmeldung.ts +++ b/src/server/services/anmeldung.ts @@ -73,6 +73,8 @@ export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promi const daten = { oidcSub: anspruecke.sub, email: anspruecke.email, + // Fuer die Verknuepfung im Self-Service (#013). + emailBestaetigt: anspruecke.emailBestaetigt, name: anspruecke.name, rolle, letzteAnmeldungAm: new Date(), diff --git a/src/server/services/meine-schluessel.ts b/src/server/services/meine-schluessel.ts new file mode 100644 index 0000000..c2b1c0b --- /dev/null +++ b/src/server/services/meine-schluessel.ts @@ -0,0 +1,195 @@ +import type { Benutzer } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { feldfehlerAus } from "@/lib/hosts"; +import { eigenerKeySchema, type EigenerKeyEingabe } from "@/lib/personen"; +import { FachFehler } from "@/server/services/fachfehler"; +import { istEindeutigkeitsFehler } from "@/server/services/hosts"; +import { protokollieren } from "@/server/services/protokoll"; +import { publicKeyAnlegen } from "@/server/services/schluessel"; + +/** + * Self-Service „Meine Schluessel“ (#013, Freigabe Frage 4): jeder angemeldete Benutzer + * hinterlegt eigene Public Keys. Wirksam werden sie erst, wenn ein Operator oder Admin sie + * einem Host zuweist – hier wird nichts ausgerollt. + * + * Jede Funktion nimmt den angemeldeten Benutzer und arbeitet nur mit **seiner** Person; eine + * Schluessel-ID von aussen wird immer gegen sie geprueft. + */ +type Konto = Pick; + +/** + * Liefert die Person des Kontos und legt sie beim ersten Aufruf an. Verknuepft per + * `benutzerId`, sonst per E-Mail – aber nur, wenn der Anbieter die Adresse bestaetigt hat und + * genau eine freie, nicht archivierte Person dazu passt. + */ +export async function eigenePersonSicherstellen(konto: Konto): Promise<{ id: string; name: string }> { + const vorhanden = await prisma.person.findUnique({ + where: { benutzerId: konto.id }, + select: { id: true, name: true }, + }); + if (vorhanden) return vorhanden; + + const akteur = { id: konto.id, name: konto.name }; + try { + if (konto.email && konto.emailBestaetigt) { + const passend = await prisma.person.findMany({ + where: { + email: { equals: konto.email, mode: "insensitive" }, + benutzerId: null, + archiviertAm: null, + }, + select: { id: true }, + take: 2, + }); + // Bedingt: hat inzwischen jemand anderes die Person verknuepft, aendert sich nichts, + // und das Konto bekommt unten eine eigene Person. + const verknuepft = + passend.length === 1 + ? await prisma.person.updateMany({ + where: { id: passend[0]!.id, benutzerId: null }, + data: { benutzerId: konto.id }, + }) + : { count: 0 }; + if (verknuepft.count === 1) { + const person = await prisma.person.findUniqueOrThrow({ + where: { benutzerId: konto.id }, + select: { id: true, name: true }, + }); + await protokollieren({ + akteur, + aktion: "person.verknuepft", + ergebnis: "ERFOLG", + details: { person: person.name, ueber: "email" }, + }); + return person; + } + } + const person = await prisma.person.create({ + data: { name: konto.name, email: konto.email, benutzerId: konto.id }, + select: { id: true, name: true }, + }); + await protokollieren({ + akteur, + aktion: "person.angelegt", + ergebnis: "ERFOLG", + details: { name: konto.name, ueber: "selbstauskunft" }, + }); + return person; + } catch (fehler) { + // Zwei gleichzeitige erste Aufrufe: der zweite findet die Person des ersten. + if (istEindeutigkeitsFehler(fehler)) { + return prisma.person.findUniqueOrThrow({ + where: { benutzerId: konto.id }, + select: { id: true, name: true }, + }); + } + throw fehler; + } +} + +/** Die eigenen Schluessel – mit genau den Feldern, die die Seite zeigt. */ +export async function eigeneSchluessel(konto: Konto) { + const person = await eigenePersonSicherstellen(konto); + const schluessel = await prisma.schluessel.findMany({ + where: { personId: person.id }, + orderBy: { angelegtAm: "desc" }, + select: { + id: true, + name: true, + typ: true, + status: true, + publicKey: true, + fingerprint: true, + kommentar: true, + selbstHinterlegt: true, + angelegtAm: true, + _count: { + select: { + publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } }, + privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } }, + }, + }, + }, + }); + return { + person, + schluessel: schluessel.map(({ _count, ...rest }) => ({ + ...rest, + verwendungen: _count.publicVerwendungen + _count.privateVerwendungen, + })), + }; +} + +export async function eigenenPublicKeyHinterlegen(konto: Konto, eingabe: EigenerKeyEingabe) { + const ergebnis = eigenerKeySchema.safeParse(eingabe); + if (!ergebnis.success) { + throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error)); + } + const person = await eigenePersonSicherstellen(konto); + return publicKeyAnlegen( + { + name: ergebnis.data.name, + slug: null, + kategorie: "PERSON", + publicKey: ergebnis.data.publicKey, + kommentar: null, + tags: [], + ablaufAm: null, + personId: person.id, + vorlageId: null, + selbstHinterlegt: true, + }, + { id: konto.id, name: konto.name }, + "schluessel.selbst_hinterlegt", + ); +} + +/** + * Entfernt einen eigenen, selbst hinterlegten Key, solange er nirgends zugewiesen ist – auch + * keine entfernte Verwendung, damit der Verlauf am Key nicht verloren geht. Fremde Keys sind + * fuer diese Funktion „nicht vorhanden“, damit sie nichts ueber fremde IDs verraet. + */ +export async function eigenenSchluesselEntfernen(konto: Konto, schluesselId: string): Promise { + const person = await eigenePersonSicherstellen(konto); + const schluessel = await prisma.schluessel.findFirst({ + where: { id: schluesselId, personId: person.id }, + select: { + id: true, + name: true, + fingerprint: true, + selbstHinterlegt: true, + _count: { select: { publicVerwendungen: true, privateVerwendungen: true } }, + }, + }); + if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es unter Ihren Schlüsseln nicht."); + if (!schluessel.selbstHinterlegt) { + throw new FachFehler( + "Diesen Schlüssel hat ein Operator oder Admin angelegt – bitte dort entfernen lassen.", + ); + } + if (schluessel._count.publicVerwendungen + schluessel._count.privateVerwendungen > 0) { + throw new FachFehler( + "Der Schlüssel ist schon einem Host zugewiesen. Bitte einen Operator oder Admin bitten.", + ); + } + await prisma.$transaction(async (tx) => { + // Bedingt geloescht: weist ein Operator den Key gerade zu, darf das Loeschen die neue + // Verwendung nicht per Cascade mitnehmen. + const geloescht = await tx.schluessel.deleteMany({ + where: { id: schluessel.id, publicVerwendungen: { none: {} }, privateVerwendungen: { none: {} } }, + }); + if (geloescht.count !== 1) { + throw new FachFehler("Der Schlüssel wurde gerade zugewiesen und bleibt deshalb bestehen."); + } + await protokollieren( + { + akteur: { id: konto.id, name: konto.name }, + aktion: "schluessel.selbst_entfernt", + ergebnis: "ERFOLG", + fingerprint: schluessel.fingerprint, + details: { name: schluessel.name }, + }, + tx, + ); + }); +} diff --git a/src/server/services/personen.ts b/src/server/services/personen.ts new file mode 100644 index 0000000..283b4d1 --- /dev/null +++ b/src/server/services/personen.ts @@ -0,0 +1,139 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { feldfehlerAus } from "@/lib/hosts"; +import { personEingabeSchema, type PersonDaten, type PersonEingabe } from "@/lib/personen"; +import { FachFehler } from "@/server/services/fachfehler"; +import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts"; +import { protokollieren } from "@/server/services/protokoll"; + +/** + * Personen anlegen, bearbeiten, archivieren (#013). + * + * Archivieren statt Loeschen: an einer Person haengen Schluessel und Protokoll-Bezuege. Die + * Schluessel bleiben der archivierten Person zugeordnet – wer bis wann welchen Key hatte, soll + * nachvollziehbar bleiben. Widerrufen ist ein eigener Schritt (#024). + */ + +function pruefen(eingabe: PersonEingabe): PersonDaten { + const ergebnis = personEingabeSchema.safeParse(eingabe); + if (!ergebnis.success) { + throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error)); + } + return ergebnis.data; +} + +function benutzerVergeben(): FachFehler { + return new FachFehler("Dieses Konto ist schon mit einer anderen Person verknüpft.", { + benutzerId: "Schon mit einer anderen Person verknüpft.", + }); +} + +/** Das gewaehlte Konto muss existieren und frei sein (oder schon zu dieser Person gehoeren). */ +async function benutzerPruefen( + tx: Prisma.TransactionClient, + benutzerId: string | null, + personId?: string, +): Promise { + if (!benutzerId) return null; + const benutzer = await tx.benutzer.findUnique({ + where: { id: benutzerId }, + select: { name: true, person: { select: { id: true } } }, + }); + if (!benutzer) + throw new FachFehler("Das gewählte Konto gibt es nicht.", { benutzerId: "Bitte neu wählen." }); + if (benutzer.person && benutzer.person.id !== personId) throw benutzerVergeben(); + return benutzer.name; +} + +export async function personAnlegen(eingabe: PersonEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + try { + return await prisma.$transaction(async (tx) => { + const konto = await benutzerPruefen(tx, daten.benutzerId); + const person = await tx.person.create({ data: daten, select: { id: true, name: true } }); + await protokollieren( + { + akteur, + aktion: "person.angelegt", + ergebnis: "ERFOLG", + details: { name: daten.name, email: daten.email, organisation: daten.organisation, konto }, + }, + tx, + ); + return person; + }); + } catch (fehler) { + if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben(); + throw fehler; + } +} + +export async function personBearbeiten(id: string, eingabe: PersonEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + const alt = await prisma.person.findUnique({ + where: { id }, + select: { + name: true, + email: true, + organisation: true, + notiz: true, + benutzerId: true, + archiviertAm: true, + }, + }); + if (!alt) throw new FachFehler("Diese Person gibt es nicht."); + if (alt.archiviertAm) throw new FachFehler("Archivierte Personen lassen sich nicht bearbeiten."); + + const geaendert = Object.fromEntries( + (Object.keys(daten) as (keyof PersonDaten)[]) + .filter((feld) => daten[feld] !== alt[feld]) + // Die Notiz kann Persoenliches enthalten – im Protokoll steht nur, dass sie sich aenderte. + .map((feld) => [feld, feld === "notiz" ? "geändert" : { vorher: alt[feld], nachher: daten[feld] }]), + ); + try { + return await prisma.$transaction(async (tx) => { + await benutzerPruefen(tx, daten.benutzerId, id); + const person = await tx.person.update({ where: { id }, data: daten, select: { id: true, name: true } }); + if (Object.keys(geaendert).length > 0) { + await protokollieren( + { akteur, aktion: "person.geaendert", ergebnis: "ERFOLG", details: geaendert }, + tx, + ); + } + return person; + }); + } catch (fehler) { + if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben(); + throw fehler; + } +} + +/** + * Archiviert eine Person. Die Verknuepfung zum Konto faellt weg, damit der Self-Service beim + * naechsten Aufruf nicht wieder bei einer archivierten Person landet. + */ +export async function personArchivieren(id: string, akteur: Akteur) { + const person = await prisma.person.findUnique({ + where: { id }, + select: { name: true, archiviertAm: true, _count: { select: { schluessel: true } } }, + }); + if (!person) throw new FachFehler("Diese Person gibt es nicht."); + if (person.archiviertAm) throw new FachFehler("Diese Person ist bereits archiviert."); + return prisma.$transaction(async (tx) => { + const archiviert = await tx.person.update({ + where: { id }, + data: { archiviertAm: new Date(), benutzerId: null }, + select: { id: true }, + }); + await protokollieren( + { + akteur, + aktion: "person.archiviert", + ergebnis: "ERFOLG", + details: { name: person.name, schluessel: person._count.schluessel }, + }, + tx, + ); + return archiviert; + }); +} diff --git a/tests/e2e/schluessel.spec.ts b/tests/e2e/schluessel.spec.ts index a5958ca..6277f86 100644 --- a/tests/e2e/schluessel.spec.ts +++ b/tests/e2e/schluessel.spec.ts @@ -72,3 +72,23 @@ test("Leser sieht Schlüssel, darf aber keine anlegen", async ({ page }) => { await page.goto("/schluessel/neu"); await expect(page).toHaveURL(/\/kein-zugriff/); }); + +test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async ({ page }) => { + const name = `${eindeutig()} Laptop`; + const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "reader@laptop" }); + await oidcAnmelden(page, "reader"); + await page.getByRole("link", { name: "Meine Schlüssel" }).first().click(); + await expect(page.getByRole("heading", { name: "Meine Schlüssel" })).toBeVisible(); + await expect(page.getByText(/Wirksam erst, wenn ein Operator oder Admin/).first()).toBeVisible(); + + await page.getByLabel("Bezeichnung").fill(name); + await page.getByLabel("Public Key").fill(paar.publicKeyZeile); + await page.getByRole("button", { name: "Hinterlegen" }).click(); + await expect(page.getByText(`Hinterlegt: ${paar.fingerprint}`)).toBeVisible(); + const eintrag = page.locator("li", { hasText: name }); + await expect(eintrag.getByText("noch nicht zugewiesen")).toBeVisible(); + + await eintrag.getByRole("button", { name: `${name} entfernen` }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Entfernen", exact: true }).click(); + await expect(page.locator("li", { hasText: name })).toHaveCount(0); +}); diff --git a/tests/personen.test.ts b/tests/personen.test.ts new file mode 100644 index 0000000..f6e765f --- /dev/null +++ b/tests/personen.test.ts @@ -0,0 +1,213 @@ +import { readFileSync } from "node:fs"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import type { Benutzer } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { darf } from "@/server/auth/rechte"; +import { kontenAuswahl, personDetail, personenListe } from "@/server/queries/personen"; +import { FachFehler } from "@/server/services/fachfehler"; +import { + eigenePersonSicherstellen, + eigenenPublicKeyHinterlegen, + eigenenSchluesselEntfernen, + eigeneSchluessel, +} from "@/server/services/meine-schluessel"; +import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen"; +import { publicKeyImportieren } from "@/server/services/schluessel"; +import { datenbankLeeren } from "./hilfen/db"; +import { warteschlangeStoppen } from "./hilfen/vorgang"; +import { ed25519Blob, zeile } from "./ssh/hilfen"; + +/** Personen und Self-Service „Meine Schluessel“ (#013). */ + +let operator: Benutzer; +let leser: Benutzer; +let andererLeser: Benutzer; + +beforeEach(async () => { + await datenbankLeeren(); + operator = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } }); + leser = await prisma.benutzer.create({ + data: { name: "Lea Leser", rolle: "LESER", email: "lea@example.org", emailBestaetigt: true }, + }); + andererLeser = await prisma.benutzer.create({ + data: { name: "Max Mitleser", rolle: "LESER", email: "max@example.org", emailBestaetigt: true }, + }); +}); + +afterAll(async () => { + await warteschlangeStoppen(); +}); + +async function fachfehlerVon(versuch: Promise): Promise { + const fehler = await versuch.then( + () => undefined, + (f: unknown) => f, + ); + expect(fehler).toBeInstanceOf(FachFehler); + return fehler as FachFehler; +} + +const akteur = () => ({ id: operator.id, name: operator.name }); +const neueZeile = (kommentar = "test") => zeile("ssh-ed25519", ed25519Blob(), kommentar); + +describe("Personen verwalten", () => { + it("legt an, bearbeitet, archiviert und protokolliert", async () => { + const person = await personAnlegen( + { name: "Anna Berger", email: "A.Berger@Example.org", organisation: "Netzwerk GmbH" }, + akteur(), + ); + const detail = await personDetail(person.id); + expect(detail).toMatchObject({ email: "a.berger@example.org", organisation: "Netzwerk GmbH" }); + + await personBearbeiten( + person.id, + { name: "Anna Berger", email: "a.berger@example.org", notiz: "privat" }, + akteur(), + ); + const geaendert = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "person.geaendert" } }); + // Die Notiz steht nicht im Protokoll, nur dass sie sich aenderte. + expect(JSON.stringify(geaendert.details)).not.toContain("privat"); + expect(geaendert.details).toMatchObject({ notiz: "geändert", organisation: { vorher: "Netzwerk GmbH" } }); + + await personArchivieren(person.id, akteur()); + expect((await personenListe()).map((p) => p.name)).toEqual([]); + expect((await personenListe({ archivierte: true })).map((p) => p.name)).toEqual(["Anna Berger"]); + await fachfehlerVon(personBearbeiten(person.id, { name: "X" }, akteur())); + expect(await prisma.protokoll.count({ where: { aktion: { startsWith: "person." } } })).toBe(3); + }); + + it("prueft E-Mail und verknuepft ein Konto nur einmal", async () => { + const fehler = await fachfehlerVon(personAnlegen({ name: "X", email: "keine-mail" }, akteur())); + expect(fehler.feldfehler["email"]).toBeDefined(); + + await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur()); + const doppelt = await fachfehlerVon(personAnlegen({ name: "Lea 2", benutzerId: leser.id }, akteur())); + expect(doppelt.feldfehler["benutzerId"]).toBeDefined(); + expect((await kontenAuswahl()).map((k) => k.name)).toEqual(["Max Mitleser", "Olga Operator"]); + }); + + it("loest beim Archivieren die Kontoverknuepfung", async () => { + const person = await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur()); + await personArchivieren(person.id, akteur()); + expect((await personDetail(person.id))?.benutzerId).toBeNull(); + // Der Self-Service landet danach bei einer neuen Person, nicht bei der archivierten. + expect((await eigenePersonSicherstellen(leser)).id).not.toBe(person.id); + }); +}); + +describe("Eigene Person", () => { + it("wird beim ersten Aufruf angelegt und danach wiederverwendet", async () => { + const [a, b] = await Promise.all([eigenePersonSicherstellen(leser), eigenePersonSicherstellen(leser)]); + expect(a.id).toBe(b.id); + expect(await prisma.person.count()).toBe(1); + expect((await personDetail(a.id))?.benutzerId).toBe(leser.id); + }); + + it("verknuepft per bestaetigter E-Mail mit einer vorhandenen Person", async () => { + const person = await personAnlegen({ name: "Lea Leser (IT)", email: "LEA@example.org" }, akteur()); + expect((await eigenePersonSicherstellen(leser)).id).toBe(person.id); + expect(await prisma.protokoll.count({ where: { aktion: "person.verknuepft" } })).toBe(1); + }); + + it("verknuepft nicht ueber eine unbestaetigte Adresse", async () => { + const person = await personAnlegen({ name: "Chefin", email: "chefin@example.org" }, akteur()); + const angreifer = await prisma.benutzer.create({ + data: { name: "Mallory", rolle: "LESER", email: "chefin@example.org", emailBestaetigt: false }, + }); + expect((await eigenePersonSicherstellen(angreifer)).id).not.toBe(person.id); + expect((await personDetail(person.id))?.benutzerId).toBeNull(); + }); +}); + +describe("Meine Schluessel", () => { + it("hinterlegt eigene Public Keys als Personen-Keys", async () => { + const neu = await eigenenPublicKeyHinterlegen(leser, { + name: "Laptop", + publicKey: neueZeile("lea@laptop"), + }); + const schluessel = await prisma.schluessel.findUniqueOrThrow({ + where: { id: neu.id }, + select: { + kategorie: true, + selbstHinterlegt: true, + personId: true, + erstelltVonId: true, + masterKeyVersion: true, + }, + }); + const person = await eigenePersonSicherstellen(leser); + expect(schluessel).toEqual({ + kategorie: "PERSON", + selbstHinterlegt: true, + personId: person.id, + erstelltVonId: leser.id, + masterKeyVersion: null, + }); + const eintrag = await prisma.protokoll.findFirstOrThrow({ + where: { aktion: "schluessel.selbst_hinterlegt" }, + }); + expect(eintrag.akteurId).toBe(leser.id); + }); + + it("zeigt nur eigene Keys und laesst fremde nicht entfernen", async () => { + const eigener = await eigenenPublicKeyHinterlegen(leser, { name: "Meiner", publicKey: neueZeile() }); + const fremder = await eigenenPublicKeyHinterlegen(andererLeser, { + name: "Fremd", + publicKey: neueZeile(), + }); + + expect((await eigeneSchluessel(leser)).schluessel.map((s) => s.name)).toEqual(["Meiner"]); + expect((await eigeneSchluessel(andererLeser)).schluessel.map((s) => s.name)).toEqual(["Fremd"]); + + const fehler = await fachfehlerVon(eigenenSchluesselEntfernen(leser, fremder.id)); + expect(fehler.message).toMatch(/unter Ihren Schlüsseln nicht/); + expect(await prisma.schluessel.count({ where: { id: fremder.id } })).toBe(1); + + await eigenenSchluesselEntfernen(leser, eigener.id); + expect(await prisma.schluessel.count({ where: { id: eigener.id } })).toBe(0); + expect(await prisma.protokoll.count({ where: { aktion: "schluessel.selbst_entfernt" } })).toBe(1); + }); + + it("entfernt keine zugewiesenen und keine von Operatoren angelegten Keys", async () => { + const zugewiesen = await eigenenPublicKeyHinterlegen(leser, { + name: "Zugewiesen", + publicKey: neueZeile(), + }); + const host = await prisma.host.create({ data: { name: "srv-test01", adresse: "10.0.0.1" } }); + await prisma.publicVerwendung.create({ + data: { schluesselId: zugewiesen.id, hostId: host.id, zielbenutzer: "lea" }, + }); + await fachfehlerVon(eigenenSchluesselEntfernen(leser, zugewiesen.id)); + + const person = await eigenePersonSicherstellen(leser); + const vomOperator = await publicKeyImportieren( + { name: "Vom Operator", kategorie: "PERSON", publicKey: neueZeile(), personId: person.id }, + akteur(), + ); + await fachfehlerVon(eigenenSchluesselEntfernen(leser, vomOperator.id)); + expect(await prisma.schluessel.count()).toBe(2); + }); + + it("meldet Duplikate auch im Self-Service", async () => { + const z = neueZeile(); + await eigenenPublicKeyHinterlegen(leser, { name: "Einmal", publicKey: z }); + const fehler = await fachfehlerVon( + eigenenPublicKeyHinterlegen(andererLeser, { name: "Nochmal", publicKey: z }), + ); + expect(fehler.message).toMatch(/bereits verwaltet/); + }); +}); + +describe("Rechte (Freigabe Frage 4)", () => { + it("Leser darf eigene Keys hinterlegen, aber weder verwalten noch zuweisen", () => { + expect(darf("LESER", "eigeneSchluesselHinterlegen")).toBe(true); + expect(darf("LESER", "schluesselVerwalten")).toBe(false); + expect(darf("LESER", "publicKeyAusrollen")).toBe(false); + }); + + it("die Self-Service-Aktionen verlangen das Self-Service-Recht und reichen nur den eigenen Benutzer weiter", () => { + const text = readFileSync("src/server/actions/meine-schluessel.ts", "utf8"); + expect(text.match(/rechtErforderlich\("eigeneSchluesselHinterlegen"\)/g)).toHaveLength(2); + expect(text).not.toMatch(/personId/); + }); +});