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:
@@ -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 { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Feld } from "@/components/ui/Feld";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { personenListe } from "@/server/queries/personen";
|
||||
|
||||
export const metadata: Metadata = { title: "Personen" };
|
||||
|
||||
export default async function Personen() {
|
||||
type Suchparameter = Promise<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).
|
||||
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 (
|
||||
<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={
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user