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>
138 lines
5.4 KiB
TypeScript
138 lines
5.4 KiB
TypeScript
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>
|
||
);
|
||
}
|