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:
@@ -1,20 +1,41 @@
|
|||||||
# #013 Personen und Self-Service
|
# #013 Personen und Self-Service
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Personen verwalten; jeder angemeldete Benutzer kann eigene Public Keys hinterlegen, wirksam erst nach Zuweisung.
|
Personen verwalten; jeder angemeldete Benutzer kann eigene Public Keys hinterlegen, wirksam erst nach Zuweisung.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Personenliste/-detail
|
- [x] Personenliste/-detail
|
||||||
- [ ] Seite „Meine Schlüssel“
|
- [x] Seite „Meine Schlüssel“
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
|
- [x] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#012
|
#012
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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).
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -135,6 +135,9 @@ model Benutzer {
|
|||||||
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
|
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
|
||||||
oidcSub String? @unique
|
oidcSub String? @unique
|
||||||
email 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 des Notfall-Admins.
|
||||||
anmeldename String? @unique
|
anmeldename String? @unique
|
||||||
name String
|
name String
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
|||||||
pfad: "/schluessel?q=wartung",
|
pfad: "/schluessel?q=wartung",
|
||||||
folgen: 'main table a[href^="/schluessel/"]',
|
folgen: 'main table a[href^="/schluessel/"]',
|
||||||
},
|
},
|
||||||
|
{ name: "personen", pfad: "/personen" },
|
||||||
|
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
||||||
{ name: "hosts", pfad: "/hosts" },
|
{ name: "hosts", pfad: "/hosts" },
|
||||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex max-w-4xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Meine Schlüssel"
|
||||||
|
untertitel="Ihre Public Keys für die Anmeldung an Servern. Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte titel="Hinterlegt" untertitel={`${schluessel.length} Schlüssel`}>
|
||||||
|
{schluessel.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
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."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{schluessel.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="border-border flex flex-col gap-2 border-b py-4 first:pt-0 last:border-b-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-text text-[14.5px] font-medium">{s.name}</p>
|
||||||
|
<MonoKopieren
|
||||||
|
wert={s.fingerprint}
|
||||||
|
beschriftung="Fingerprint"
|
||||||
|
umbrechen
|
||||||
|
className="text-[12.5px]"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.verwendungen > 0 ? (
|
||||||
|
<Badge variante="ok">
|
||||||
|
{s.verwendungen === 1 ? "1 Host-Zuweisung" : `${s.verwendungen} Host-Zuweisungen`}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variante="messing">noch nicht zugewiesen</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-muted text-[13px]">seit {datum(s.angelegtAm)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.selbstHinterlegt && s.verwendungen === 0 && (
|
||||||
|
<KeyEntfernen schluesselId={s.id} name={s.name} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<KeyHinterlegen />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[
|
||||||
|
{ titel: "Personen", href: "/personen" },
|
||||||
|
{ titel: person.name, href: `/personen/${person.id}` },
|
||||||
|
{ titel: "Bearbeiten" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel="Person bearbeiten"
|
||||||
|
/>
|
||||||
|
<PersonFormular
|
||||||
|
vorgabe={{
|
||||||
|
id: person.id,
|
||||||
|
name: person.name,
|
||||||
|
email: person.email ?? "",
|
||||||
|
organisation: person.organisation ?? "",
|
||||||
|
notiz: person.notiz ?? "",
|
||||||
|
benutzerId: person.benutzerId ?? "",
|
||||||
|
}}
|
||||||
|
konten={konten}
|
||||||
|
abbrechenZiel={`/personen/${person.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Personen", href: "/personen" }, { titel: person.name }]} />}
|
||||||
|
titel={person.name}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
{archiviert && person.archiviertAm && (
|
||||||
|
<Badge variante="gefahr">archiviert am {datum(person.archiviertAm)}</Badge>
|
||||||
|
)}
|
||||||
|
{person.organisation && <Badge>{person.organisation}</Badge>}
|
||||||
|
{person.benutzer && <Badge variante="ok">Konto verknüpft</Badge>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button href={`/personen/${person.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte as="div">
|
||||||
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 min-[480px]:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Info titel="E-Mail">{person.email ?? <span className="text-muted">–</span>}</Info>
|
||||||
|
<Info titel="Konto">
|
||||||
|
{person.benutzer ? (
|
||||||
|
<>
|
||||||
|
{person.benutzer.name}
|
||||||
|
{person.benutzer.email && (
|
||||||
|
<span className="text-muted block text-[13px]">{person.benutzer.email}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">nicht verknüpft</span>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
|
<Info titel="Angelegt">{datum(person.angelegtAm)}</Info>
|
||||||
|
</dl>
|
||||||
|
{person.notiz && (
|
||||||
|
<p className="border-border text-muted mt-5 border-t pt-4 text-[14px] whitespace-pre-line">
|
||||||
|
{person.notiz}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Schlüssel"
|
||||||
|
untertitel={`${person.schluessel.length} Schlüssel dieser Person`}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button
|
||||||
|
href={`/schluessel/neu?person=${person.id}`}
|
||||||
|
klein
|
||||||
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
|
>
|
||||||
|
Public Key hinterlegen
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{person.schluessel.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
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."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{person.schluessel.map((s) => (
|
||||||
|
<li key={s.id} className="border-border border-b py-3 first:pt-0 last:border-b-0 last:pb-0">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="text-text hover:text-accent text-[14.5px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted block font-mono text-[12.5px]">
|
||||||
|
{fingerprintKurz(s.fingerprint)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||||
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
{s.verwendungen === 0
|
||||||
|
? "· noch keinem Host zugewiesen"
|
||||||
|
: `· ${s.verwendungen} ${s.verwendungen === 1 ? "Verwendung" : "Verwendungen"}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{verwalten && (
|
||||||
|
<Karte titel="Archivieren" untertitel="Person stilllegen, ohne die Historie zu verlieren">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-muted max-w-xl text-[14px]">
|
||||||
|
Archivierte Personen verschwinden aus Listen. Ihre Schlüssel bleiben bestehen und lassen sich
|
||||||
|
weiterhin widerrufen.
|
||||||
|
</p>
|
||||||
|
<PersonArchivieren personId={person.id} name={person.name} />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Personen", href: "/personen" }, { titel: "Neue Person" }]} />}
|
||||||
|
titel="Person anlegen"
|
||||||
|
untertitel="Mitarbeitende, Admins oder Dienstleister, denen Personen-Keys gehören."
|
||||||
|
/>
|
||||||
|
<PersonFormular vorgabe={LEERE_PERSON} konten={konten} abbrechenZiel="/personen" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,137 @@
|
|||||||
|
import { Plus, Search, Users } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
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 { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personenListe } from "@/server/queries/personen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Personen" };
|
export const metadata: Metadata = { title: "Personen" };
|
||||||
|
|
||||||
export default async function Personen() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
function einzeln(wert: string | string[] | undefined): string {
|
||||||
|
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Anzahl({ n }: { n: number }) {
|
||||||
|
return n === 0 ? <span className="text-muted">keine</span> : <>{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).
|
// 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 && (
|
||||||
|
<Button href="/personen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Person anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Personen"
|
||||||
|
untertitel={q ? `${personen.length} Treffer` : "Wem welche Schlüssel gehören"}
|
||||||
|
aktionen={anlegen}
|
||||||
|
/>
|
||||||
|
<form method="get" role="search" className="grid items-end gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Name, E-Mail oder Organisation"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||||
|
Suchen
|
||||||
|
</Button>
|
||||||
|
{q && (
|
||||||
|
<Button href="/personen" className="flex-1 sm:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{personen.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Users className="size-5" />}
|
||||||
|
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 ? <Button href="/personen">Suche zurücksetzen</Button> : anlegen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
<ul className="flex flex-col md:hidden">
|
||||||
|
{personen.map((p) => (
|
||||||
|
<li key={p.id} className="border-border border-b last:border-b-0">
|
||||||
|
<Link
|
||||||
|
href={`/personen/${p.id}`}
|
||||||
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-1 px-2 py-3 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-text text-[14.5px] font-medium">{p.name}</span>
|
||||||
|
{p.email && <span className="text-muted truncate text-[13px]">{p.email}</span>}
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
{p.organisation && <Badge>{p.organisation}</Badge>}
|
||||||
|
{p.benutzer && <Badge variante="ok">Konto verknüpft</Badge>}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
Aktive Schlüssel: <Anzahl n={p.aktiveSchluessel} />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Tabelle
|
||||||
|
className="hidden md:block"
|
||||||
|
beschriftung="Personen"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Name" },
|
||||||
|
{ titel: "E-Mail" },
|
||||||
|
{ titel: "Organisation" },
|
||||||
|
{ titel: "Konto" },
|
||||||
|
{ titel: "Aktive Schlüssel" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<TabellenZeile key={p.id}>
|
||||||
|
<TabellenZelle>
|
||||||
|
<Link
|
||||||
|
href={`/personen/${p.id}`}
|
||||||
|
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="text-muted">{p.email ?? "–"}</TabellenZelle>
|
||||||
|
<TabellenZelle>{p.organisation ?? <span className="text-muted">–</span>}</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
{p.benutzer ? p.benutzer.name : <span className="text-muted">–</span>}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Anzahl n={p.aktiveSchluessel} />
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,10 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
untertitel={
|
untertitel={
|
||||||
s.person ? (
|
s.person ? (
|
||||||
<>
|
<>
|
||||||
Person: <span className="text-text">{s.person.name}</span>
|
Person:{" "}
|
||||||
|
<Link href={`/personen/${s.person.id}`} className="text-accent hover:underline">
|
||||||
|
{s.person.name}
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
KeySquare,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Network,
|
Network,
|
||||||
@@ -20,6 +21,8 @@ export const HAUPTNAVIGATION: NavPunkt[] = [
|
|||||||
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
|
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
|
||||||
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
|
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
|
||||||
{ href: "/personen", titel: "Personen", icon: Users },
|
{ 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: "/zuweisungen", titel: "Zuweisungen", icon: Network },
|
||||||
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
|
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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<HTMLFormElement>(null);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(
|
||||||
|
eigenenKeyHinterlegenAktion as (v: FormularZustand, f: FormData) => Promise<HinterlegenZustand>,
|
||||||
|
);
|
||||||
|
const erfolg = (zustand as HinterlegenZustand).erfolg;
|
||||||
|
useEffect(() => {
|
||||||
|
if (erfolg) formular.current?.reset();
|
||||||
|
}, [erfolg]);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
titel="Public Key hinterlegen"
|
||||||
|
untertitel="Eine Zeile im OpenSSH-Format, z. B. aus ~/.ssh/id_ed25519.pub"
|
||||||
|
>
|
||||||
|
<form ref={formular} onSubmit={absenden} noValidate className="flex flex-col gap-4">
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
{erfolg && (
|
||||||
|
<p role="status" className="text-ok text-[14px] break-all">
|
||||||
|
{erfolg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Feld
|
||||||
|
beschriftung="Bezeichnung"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Laptop, Workstation …"
|
||||||
|
autoComplete="off"
|
||||||
|
fehler={f["name"]}
|
||||||
|
/>
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Public Key"
|
||||||
|
name="publicKey"
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
|
||||||
|
fehler={f["publicKey"]}
|
||||||
|
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
|
||||||
|
/>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist. Den Private Key
|
||||||
|
behalten Sie bei sich – sshkv fragt nie danach.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variante="primaer"
|
||||||
|
disabled={laeuft}
|
||||||
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
|
>
|
||||||
|
{laeuft ? "Speichere …" : "Hinterlegen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenen, noch nirgends zugewiesenen Key entfernen – mit Rueckfrage. */
|
||||||
|
export function KeyEntfernen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(eigenenKeyEntfernenAktion, {});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
klein
|
||||||
|
icon={<Trash2 className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
aria-label={`${name} entfernen`}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby={`entfernen-${schluesselId}`}
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="id" value={schluesselId} />
|
||||||
|
<h2 id={`entfernen-${schluesselId}`} className="text-[17px] font-semibold">
|
||||||
|
„{name}“ entfernen?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
Der Public Key verschwindet aus sshkv. Er ist noch keinem Host zugewiesen; auf keinem Server
|
||||||
|
ändert sich etwas.
|
||||||
|
</p>
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||||
|
{laeuft ? "Entferne …" : "Entfernen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||||
|
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Karte titel="Person">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={vorgabe.name}
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
autoComplete="off"
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="E-Mail (optional)"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
defaultValue={vorgabe.email}
|
||||||
|
maxLength={254}
|
||||||
|
autoComplete="off"
|
||||||
|
fehler={f["email"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Organisation (optional)"
|
||||||
|
name="organisation"
|
||||||
|
defaultValue={vorgabe.organisation}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="z. B. Dienstleister"
|
||||||
|
fehler={f["organisation"]}
|
||||||
|
/>
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Verknüpftes Konto (optional)"
|
||||||
|
name="benutzerId"
|
||||||
|
defaultValue={vorgabe.benutzerId}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
>
|
||||||
|
<option value="">keines</option>
|
||||||
|
{konten.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{k.email ? `${k.name} (${k.email})` : k.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
{f["benutzerId"] && <p className="text-danger text-[13px] sm:col-span-2">{f["benutzerId"]}</p>}
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Notiz (optional)"
|
||||||
|
name="notiz"
|
||||||
|
defaultValue={vorgabe.notiz}
|
||||||
|
maxLength={1000}
|
||||||
|
fehler={f["notiz"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Person anlegen"}
|
||||||
|
</Button>
|
||||||
|
<Button href={abbrechenZiel}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archivieren mit Rueckfrage, nativer `<dialog>` wie bei Hosts (#008). */
|
||||||
|
export function PersonArchivieren({ personId, name }: { personId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(personArchivierenAktion, {});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Archive className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
>
|
||||||
|
Person archivieren
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby="person-archivieren-titel"
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="id" value={personId} />
|
||||||
|
<h2 id="person-archivieren-titel" className="text-[17px] font-semibold">
|
||||||
|
{name} archivieren?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||||
|
{laeuft ? "Archiviere …" : "Archivieren"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof personEingabeSchema>;
|
||||||
|
export type PersonDaten = z.output<typeof personEingabeSchema>;
|
||||||
|
|
||||||
|
/** 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<typeof eigenerKeySchema>;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promi
|
|||||||
const daten = {
|
const daten = {
|
||||||
oidcSub: anspruecke.sub,
|
oidcSub: anspruecke.sub,
|
||||||
email: anspruecke.email,
|
email: anspruecke.email,
|
||||||
|
// Fuer die Verknuepfung im Self-Service (#013).
|
||||||
|
emailBestaetigt: anspruecke.emailBestaetigt,
|
||||||
name: anspruecke.name,
|
name: anspruecke.name,
|
||||||
rolle,
|
rolle,
|
||||||
letzteAnmeldungAm: new Date(),
|
letzteAnmeldungAm: new Date(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -72,3 +72,23 @@ test("Leser sieht Schlüssel, darf aber keine anlegen", async ({ page }) => {
|
|||||||
await page.goto("/schluessel/neu");
|
await page.goto("/schluessel/neu");
|
||||||
await expect(page).toHaveURL(/\/kein-zugriff/);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<unknown>): Promise<FachFehler> {
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user