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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:38:55 +02:00
co-authored by Claude Opus 5.5
parent 263143134f
commit b5b43f41f6
22 changed files with 1569 additions and 10 deletions
+43
View File
@@ -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<HinterlegenZustand> {
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<FormularZustand> {
try {
const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen");
await eigenenSchluesselEntfernen(benutzer, textfeld(formular, "id") ?? "");
revalidatePath("/meine-schluessel");
return {};
} catch (fehler) {
return fehlerZustand(fehler);
}
}
+68
View File
@@ -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<FormularZustand> {
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<FormularZustand> {
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<FormularZustand> {
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");
}
+103
View File
@@ -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<ReturnType<typeof personenListe>>[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<Awaited<ReturnType<typeof personDetail>>>;
/**
* 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 },
});
}
+2
View File
@@ -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(),
+195
View File
@@ -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<Benutzer, "id" | "name" | "email" | "emailBestaetigt">;
/**
* 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<void> {
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,
);
});
}
+139
View File
@@ -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<string | null> {
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;
});
}