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 && (
+
}>
+ Bearbeiten
+
+ )
+ }
+ />
+
+
+
+ {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 && (
+ }>
+ Person anlegen
+
+ );
+
return (
-
+
+
+
+
+ {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 ?
Suche zurücksetzen : 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 (
+
+
+
+ );
+}
+
+/** 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 (
+ <>
+ }
+ onClick={() => dialog.current?.showModal()}
+ aria-label={`${name} entfernen`}
+ >
+ Entfernen
+
+
+
+
+ >
+ );
+}
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 (
+
+ );
+}
+
+/** 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 (
+ <>
+ }
+ onClick={() => dialog.current?.showModal()}
+ >
+ Person archivieren
+
+
+
+
+ >
+ );
+}
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/);
+ });
+});