Files
sshkeyvault/src/app/(app)/personen/page.tsx
T
florian.wildeandClaude Opus 5.5 b5b43f41f6 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>
2026-10-12 10:38:55 +02:00

138 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Plus, Search, Users } from "lucide-react";
import type { Metadata } from "next";
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" };
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).
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 (
<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>
);
}