Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)
# Conflicts: # src/components/vorgang/useVorgang.ts # src/worker/handler.ts
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>
|
||||
);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export default async function Uebersicht() {
|
||||
return (
|
||||
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
||||
<Link
|
||||
href="/schluessel/beispiel"
|
||||
href="/schluessel"
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Platzhalter fuer „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#012).
|
||||
* Inhalt folgt mit #017 (Private Key auf einen Host legen); bis dahin bewusst leer.
|
||||
*/
|
||||
export function PrivateAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
|
||||
void props;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Eye, Trash2, TriangleAlert } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
|
||||
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
|
||||
import {
|
||||
abholungAnfordernAktion,
|
||||
privateKeyAnzeigenAktion,
|
||||
privateKeyLoeschenAktion,
|
||||
type PrivateKeyAntwort,
|
||||
} from "@/server/actions/privatekey";
|
||||
|
||||
/**
|
||||
* Private Key anzeigen, herunterladen und loeschen (#014). Alles verlangt frische
|
||||
* Bestaetigung; fehlt sie, geht es nach /bestaetigen und von dort mit `?privatekey=1` zurueck,
|
||||
* damit der Dialog gleich wieder offen ist.
|
||||
*
|
||||
* Der Key lebt nur im Zustand dieses Dialogs und wird beim Schliessen verworfen.
|
||||
*/
|
||||
const DIALOG_KLASSEN =
|
||||
"border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-2xl border p-5 backdrop:bg-black/70 sm:p-6";
|
||||
|
||||
function useBestaetigungUmleitung() {
|
||||
const router = useRouter();
|
||||
const pfad = usePathname();
|
||||
return (antwort: PrivateKeyAntwort): boolean => {
|
||||
if (!antwort.bestaetigen) return false;
|
||||
router.push(`/bestaetigen?ziel=${encodeURIComponent(`${pfad}?privatekey=1`)}`);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function PrivateKeyAnzeigen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const download = useRef<HTMLFormElement>(null);
|
||||
const suche = useSearchParams();
|
||||
const umleiten = useBestaetigungUmleitung();
|
||||
const [zweck, setZweck] = useState<"anzeigen" | "herunterladen">();
|
||||
const [vorgangId, setVorgangId] = useState<string>();
|
||||
const [privateKey, setPrivateKey] = useState<string>();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [startet, starten] = useTransition();
|
||||
const { stand, abfrageFehler } = useVorgang(vorgangId);
|
||||
const abgeholt = useRef<string | undefined>(undefined);
|
||||
|
||||
// Zurueck von /bestaetigen: Dialog gleich oeffnen.
|
||||
useEffect(() => {
|
||||
if (suche.get("privatekey") === "1") dialog.current?.showModal();
|
||||
}, [suche]);
|
||||
|
||||
// Bereit? Dann genau einmal abholen – als Anzeige oder als Download-Formular.
|
||||
useEffect(() => {
|
||||
if (!vorgangId || stand?.status !== "ERLEDIGT" || abgeholt.current === vorgangId) return;
|
||||
abgeholt.current = vorgangId;
|
||||
if (zweck === "herunterladen") {
|
||||
download.current?.submit();
|
||||
return;
|
||||
}
|
||||
void privateKeyAnzeigenAktion(vorgangId).then((antwort) => {
|
||||
if (umleiten(antwort)) return;
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else setPrivateKey(antwort.privateKey);
|
||||
});
|
||||
}, [vorgangId, stand?.status, zweck, umleiten]);
|
||||
|
||||
function anfordern(art: "anzeigen" | "herunterladen") {
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
setPrivateKey(undefined);
|
||||
setZweck(art);
|
||||
const antwort = await abholungAnfordernAktion(schluesselId, art);
|
||||
if (umleiten(antwort)) return;
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else setVorgangId(antwort.vorgangId);
|
||||
});
|
||||
}
|
||||
|
||||
function schliessen() {
|
||||
// Nichts vom Key bleibt im Zustand stehen.
|
||||
setPrivateKey(undefined);
|
||||
setVorgangId(undefined);
|
||||
setFehler(undefined);
|
||||
dialog.current?.close();
|
||||
}
|
||||
|
||||
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
|
||||
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
|
||||
const downloadGestartet =
|
||||
zweck === "herunterladen" && vorgangId !== undefined && stand?.status === "ERLEDIGT";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dialog.current?.showModal()}
|
||||
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
||||
>
|
||||
anzeigen (Re-Auth)
|
||||
</button>
|
||||
<dialog ref={dialog} aria-labelledby="privatekey-titel" className={DIALOG_KLASSEN} onClose={schliessen}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 id="privatekey-titel" className="text-[17px] font-semibold">
|
||||
Private Key von „{name}“
|
||||
</h2>
|
||||
{privateKey ? (
|
||||
<>
|
||||
<MonoBlock wert={privateKey} beschriftung="Private Key" hoehe="22rem" />
|
||||
<p className="text-accent flex items-start gap-2 text-[14px]">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
Wird nicht erneut angezeigt. Kopieren Sie ihn jetzt an einen sicheren Ort.
|
||||
</p>
|
||||
</>
|
||||
) : downloadGestartet ? (
|
||||
<p role="status" className="text-ok text-[14.5px]">
|
||||
Download gestartet. Der Private Key wird nicht erneut angeboten.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted text-[14.5px]">
|
||||
Der Private Key wird im Worker entschlüsselt und genau einmal ausgeliefert. Jede Anzeige und
|
||||
jeder Download steht im Audit-Log.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<Eye className="size-4" />}
|
||||
disabled={laeuft}
|
||||
onClick={() => anfordern("anzeigen")}
|
||||
>
|
||||
{laeuft && zweck === "anzeigen" ? "Entschlüssele …" : "Anzeigen"}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Download className="size-4" />}
|
||||
disabled={laeuft}
|
||||
onClick={() => anfordern("herunterladen")}
|
||||
>
|
||||
{laeuft && zweck === "herunterladen" ? "Entschlüssele …" : "Herunterladen"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={schliessen}>Schließen</Button>
|
||||
</div>
|
||||
</div>
|
||||
{vorgangId && <form ref={download} method="post" action={`/api/private-key/${vorgangId}`} hidden />}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** „Private Key aus SSHKeyVault loeschen“ (#014) – endgueltig, mit Rueckfrage. */
|
||||
export function PrivateKeyLoeschen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const router = useRouter();
|
||||
const umleiten = useBestaetigungUmleitung();
|
||||
const [verstanden, setVerstanden] = useState(false);
|
||||
const [vorgangId, setVorgangId] = useState<string>();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [startet, starten] = useTransition();
|
||||
const { stand, abfrageFehler } = useVorgang(vorgangId);
|
||||
|
||||
useEffect(() => {
|
||||
if (stand?.status === "ERLEDIGT") {
|
||||
dialog.current?.close();
|
||||
router.refresh();
|
||||
}
|
||||
}, [stand?.status, router]);
|
||||
|
||||
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
|
||||
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<Trash2 className="size-4" />}
|
||||
onClick={() => dialog.current?.showModal()}
|
||||
>
|
||||
Private Key löschen
|
||||
</Button>
|
||||
<dialog ref={dialog} aria-labelledby="privatekey-loeschen-titel" className={DIALOG_KLASSEN}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 id="privatekey-loeschen-titel" className="text-[17px] font-semibold">
|
||||
Private Key von „{name}“ aus SSHKeyVault löschen?
|
||||
</h2>
|
||||
<p className="text-muted text-[14.5px]">
|
||||
sshkv verwirft den verschlüsselten Private Key endgültig. Der Public Key und alle Zuweisungen
|
||||
bleiben; auf den Hosts ändert sich nichts. Danach kann sshkv den Private Key weder anzeigen noch
|
||||
auf weitere Hosts ausrollen.
|
||||
</p>
|
||||
<Ankreuzfeld
|
||||
checked={verstanden}
|
||||
onChange={(e) => setVerstanden(e.target.checked)}
|
||||
beschriftung="Ich habe verstanden, dass sich das nicht rückgängig machen lässt."
|
||||
/>
|
||||
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
|
||||
<div className="flex flex-wrap justify-end gap-2.5">
|
||||
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||
<Button
|
||||
variante="gefahr"
|
||||
disabled={!verstanden || laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
const antwort = await privateKeyLoeschenAktion(schluesselId);
|
||||
if (umleiten(antwort)) return;
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else setVorgangId(antwort.vorgangId);
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuft ? "Lösche …" : "Endgültig löschen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Platzhalter fuer „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#012).
|
||||
* Inhalt folgt mit #016 (Zuweisung, Dry-Run, Ausrollen); bis dahin bewusst leer.
|
||||
*/
|
||||
export function PublicAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
|
||||
void props;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SchluesselBearbeiten } from "@/components/schluessel/SchluesselBearbeiten";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { personenAuswahl, schluesselDetail, vorlagenAuswahl } from "@/server/queries/schluessel";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel bearbeiten" };
|
||||
|
||||
export default async function SchluesselBearbeitenSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/bearbeiten`);
|
||||
const [s, personen, vorlagen] = await Promise.all([
|
||||
schluesselDetail(id),
|
||||
personenAuswahl(),
|
||||
vorlagenAuswahl(),
|
||||
]);
|
||||
if (!s) notFound();
|
||||
|
||||
// Eine inzwischen archivierte Person oder Vorlage bleibt waehlbar, solange sie zugeordnet
|
||||
// ist – sonst loeschte schon das blosse Speichern die Zuordnung.
|
||||
const personListe = personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }));
|
||||
if (s.person && !personListe.some((p) => p.id === s.person?.id))
|
||||
personListe.push({ ...s.person, zusatz: null });
|
||||
const vorlagenListe = vorlagen.map((v) => ({ id: v.id, name: v.name }));
|
||||
if (s.vorlage && !vorlagenListe.some((v) => v.id === s.vorlage?.id)) vorlagenListe.push(s.vorlage);
|
||||
|
||||
return (
|
||||
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={
|
||||
<Breadcrumb
|
||||
pfad={[
|
||||
{ titel: "Schlüssel", href: "/schluessel" },
|
||||
{ titel: s.name, href: `/schluessel/${s.id}` },
|
||||
{ titel: "Bearbeiten" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
titel="Schlüssel bearbeiten"
|
||||
untertitel={
|
||||
<>
|
||||
Kurzname <span className="text-text font-mono">{s.slug}</span> und Kommentar bleiben unverändert.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SchluesselBearbeiten
|
||||
werte={{
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
tags: s.tags,
|
||||
ablaufAm: s.ablaufAm ? s.ablaufAm.toISOString().slice(0, 10) : "",
|
||||
personId: s.personId ?? "",
|
||||
vorlageId: s.vorlageId ?? "",
|
||||
status: s.status,
|
||||
statusAenderbar:
|
||||
s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN"),
|
||||
}}
|
||||
personen={personListe}
|
||||
vorlagen={vorlagenListe}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Download, Lock, LockOpen, Pencil } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
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 { InnereBox, Karte } from "@/components/ui/Karte";
|
||||
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
|
||||
import { datum, datumZeit } from "@/lib/format";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel" };
|
||||
|
||||
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||||
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||||
<dd className="min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Soll-Zustand einer Verwendung als Statuspunkt – Form und Text tragen die Information mit. */
|
||||
const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
|
||||
GEPLANT: { status: "unbekannt", text: "geplant" },
|
||||
AUSGEROLLT: { status: "synchron", text: "ausgerollt" },
|
||||
ABWEICHUNG: { status: "drift", text: "Abweichung" },
|
||||
ENTFERNEN: { status: "unbekannt", text: "wird entfernt" },
|
||||
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
|
||||
};
|
||||
|
||||
type Eintrag = { id: string; ziel: string; href?: string; pfad: string; zusatz?: string; zustand: string };
|
||||
|
||||
function AusrollKarte({
|
||||
titel,
|
||||
untertitel,
|
||||
eintraege,
|
||||
leer,
|
||||
aktion,
|
||||
}: {
|
||||
titel: string;
|
||||
untertitel: string;
|
||||
eintraege: Eintrag[];
|
||||
leer: string;
|
||||
aktion: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Karte titel={titel} untertitel={untertitel} aktionen={aktion}>
|
||||
{eintraege.length === 0 ? (
|
||||
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-6 text-center text-[14px]">
|
||||
{leer}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{eintraege.map((e) => {
|
||||
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
|
||||
return (
|
||||
<li key={e.id}>
|
||||
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-text font-mono text-[13.5px] font-medium break-all">
|
||||
{e.href ? (
|
||||
<Link href={e.href} className="hover:text-accent transition-colors">
|
||||
{e.ziel}
|
||||
</Link>
|
||||
) : (
|
||||
e.ziel
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
||||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||
</div>
|
||||
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
|
||||
</InnereBox>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
|
||||
function privateEintraege(s: SchluesselDetail): Eintrag[] {
|
||||
return s.privateVerwendungen.map((v) => {
|
||||
const config =
|
||||
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||||
return {
|
||||
id: v.id,
|
||||
ziel: `${v.zielbenutzer}@${v.host.name}`,
|
||||
href: `/hosts/${v.host.id}`,
|
||||
pfad: v.pfad,
|
||||
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
|
||||
zustand: v.zustand,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function publicEintraege(s: SchluesselDetail): Eintrag[] {
|
||||
return s.publicVerwendungen.map((v) => ({
|
||||
id: v.id,
|
||||
ziel: v.host
|
||||
? `${v.zielbenutzer}@${v.host.name}`
|
||||
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
|
||||
...(v.host
|
||||
? { href: `/hosts/${v.host.id}` }
|
||||
: v.hostgruppe
|
||||
? { href: `/hostgruppen/${v.hostgruppe.id}` }
|
||||
: {}),
|
||||
pfad: "~/.ssh/authorized_keys",
|
||||
...(v.optionen ? { zusatz: v.optionen } : {}),
|
||||
zustand: v.zustand,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Was mit dem Private Key ist – ohne ihn je zu lesen (masterKeyVersion verraet es). */
|
||||
function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNode }) {
|
||||
if (s.privatGespeichert) {
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||||
<Lock className="text-ok size-3.5" aria-hidden />
|
||||
verschlüsselt gespeichert
|
||||
{s.passphraseGeschuetzt && <span className="text-muted">· mit Passphrase</span>}
|
||||
{anzeigen}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (s.privatGeloeschtAm) {
|
||||
return (
|
||||
<span className="text-muted inline-flex items-center gap-1.5 text-[14px]">
|
||||
<LockOpen className="size-3.5" aria-hidden />
|
||||
gelöscht am <time dateTime={s.privatGeloeschtAm.toISOString()}>{datum(s.privatGeloeschtAm)}</time>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-muted text-[14px]">
|
||||
nicht gespeichert{s.kategorie === "PERSON" ? " (Personen-Key)" : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
|
||||
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
|
||||
const s = await schluesselDetail(id);
|
||||
if (!s) notFound();
|
||||
|
||||
const aktiv = s.status === "AKTIV";
|
||||
const personenKey = s.kategorie === "PERSON";
|
||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
|
||||
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
|
||||
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
|
||||
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
|
||||
const jetzt = new Date();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.name }]} />}
|
||||
titel={s.name}
|
||||
untertitel={
|
||||
s.person ? (
|
||||
<>
|
||||
Person:{" "}
|
||||
<Link href={`/personen/${s.person.id}`} className="text-accent hover:underline">
|
||||
{s.person.name}
|
||||
</Link>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
badges={
|
||||
<>
|
||||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||
<KategorieBadge kategorie={s.kategorie} />
|
||||
<StatusBadge status={s.status} />
|
||||
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
|
||||
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
|
||||
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} lang />
|
||||
{s.tags.map((t) => (
|
||||
<Badge key={t} mono className="text-muted">
|
||||
#{t}
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
verwalten && (
|
||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Karte as="div" innen="ohne">
|
||||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||
<InfoFeld titel="Fingerprint">
|
||||
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||||
</InfoFeld>
|
||||
<InfoFeld titel="Erstellt">
|
||||
<time dateTime={s.angelegtAm.toISOString()} className="font-mono text-[13px]">
|
||||
{datumZeit(s.angelegtAm)}
|
||||
</time>
|
||||
<span className="text-muted block text-[13px]">
|
||||
{s.erstelltVon ? `von ${s.erstelltVon.name}` : "von sshkv"}
|
||||
</span>
|
||||
</InfoFeld>
|
||||
<InfoFeld titel="Private Key">
|
||||
<PrivatStatus
|
||||
s={s}
|
||||
anzeigen={
|
||||
darfAnzeigen && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<PrivateKeyAnzeigen schluesselId={s.id} name={s.name} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</InfoFeld>
|
||||
</dl>
|
||||
</Karte>
|
||||
|
||||
{/* Personen-Keys haben nie einen gespeicherten Private Key (DB-CHECK) – die Karte dafuer
|
||||
waere immer leer. */}
|
||||
<div className={personenKey ? "grid gap-6" : "grid gap-6 lg:grid-cols-2"}>
|
||||
{!personenKey && (
|
||||
<AusrollKarte
|
||||
titel="Private Key ausgerollt auf"
|
||||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||
eintraege={privateEintraege(s)}
|
||||
leer={
|
||||
s.privatGespeichert
|
||||
? "Der Private Key liegt noch auf keinem Host."
|
||||
: "Ohne gespeicherten Private Key kann sshkv ihn nicht ausrollen."
|
||||
}
|
||||
aktion={
|
||||
<PrivateAusrollenAktion
|
||||
schluesselId={s.id}
|
||||
darf={aktiv && s.privatGespeichert && darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AusrollKarte
|
||||
titel="Public Key ausgerollt auf"
|
||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||
eintraege={publicEintraege(s)}
|
||||
leer={
|
||||
personenKey
|
||||
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||||
: "Noch keinem Konto zugewiesen."
|
||||
}
|
||||
aktion={
|
||||
<PublicAusrollenAktion
|
||||
schluesselId={s.id}
|
||||
darf={aktiv && darf(benutzer.rolle, "publicKeyAusrollen")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Karte
|
||||
titel="Public Key"
|
||||
untertitel={
|
||||
<>
|
||||
Kommentar: <span className="font-mono">{s.kommentar || "–"}</span>
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
<a
|
||||
href={`/api/schluessel/${s.id}/public-key`}
|
||||
download={`${s.slug}.pub`}
|
||||
className="border-border-strong bg-surface text-text hover:bg-raised rounded-box inline-flex min-h-11 items-center gap-2 border px-3 text-[13px] font-medium transition-colors"
|
||||
>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{s.slug}.pub
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<MonoBlock wert={s.publicKey} beschriftung="Public Key" umbrechen />
|
||||
</Karte>
|
||||
|
||||
{darfLoeschen && (
|
||||
<Karte titel="Private Key löschen" untertitel="Nur noch auf den Hosts, nicht mehr in SSHKeyVault">
|
||||
<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]">
|
||||
Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann
|
||||
ihn niemand mehr aus SSHKeyVault holen – auch kein Admin.
|
||||
</p>
|
||||
<PrivateKeyLoeschen schluesselId={s.id} name={s.name} />
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
beispielSchluessel as s,
|
||||
dryRunDatei,
|
||||
dryRunDiff,
|
||||
privatAusgerollt,
|
||||
publicAusgerollt,
|
||||
zusatzaktionen,
|
||||
type Ausrollung,
|
||||
} from "@/beispiel/schluessel";
|
||||
import { BEISPIEL_JETZT } from "@/beispiel/uebersicht";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DiffBox } from "@/components/ui/DiffBox";
|
||||
import { InnereBox, Karte } from "@/components/ui/Karte";
|
||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { datum, datumZeit, tageBis } from "@/lib/format";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
|
||||
|
||||
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
|
||||
export default async function SchluesselBeispiel() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
|
||||
titel={s.zweck}
|
||||
badges={
|
||||
<>
|
||||
<Badge mono>{s.typ}</Badge>
|
||||
<Badge>{s.kategorie}</Badge>
|
||||
<Badge>Vorlage: {s.vorlage}</Badge>
|
||||
<Badge variante="messing">
|
||||
<span>
|
||||
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
|
||||
</span>
|
||||
</Badge>
|
||||
<Badge variante="neutral" className="text-muted">
|
||||
Beispieldaten
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
<>
|
||||
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
|
||||
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
|
||||
<Button variante="gefahr" icon={<Ban className="size-4" />}>
|
||||
Widerrufen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Karte as="div" innen="ohne">
|
||||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||
<InfoFeld titel="Fingerprint">
|
||||
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||||
</InfoFeld>
|
||||
<InfoFeld titel="Erstellt">
|
||||
<time dateTime={s.erstellt} className="font-mono text-[13px]">
|
||||
{datumZeit(s.erstellt)}
|
||||
</time>
|
||||
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
|
||||
</InfoFeld>
|
||||
<InfoFeld titel="Private Key">
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||||
<Lock className="text-ok size-3.5" aria-hidden />
|
||||
verschlüsselt gespeichert ·
|
||||
<button
|
||||
type="button"
|
||||
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
||||
>
|
||||
anzeigen (Re-Auth)
|
||||
</button>
|
||||
</span>
|
||||
</InfoFeld>
|
||||
</dl>
|
||||
</Karte>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<AusrollKarte
|
||||
titel="Private Key ausgerollt auf"
|
||||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||
eintraege={privatAusgerollt}
|
||||
/>
|
||||
<AusrollKarte
|
||||
titel="Public Key ausgerollt auf"
|
||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||
eintraege={publicAusgerollt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Karte
|
||||
titel="Dry-Run-Vorschau"
|
||||
untertitel={
|
||||
<>
|
||||
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
|
||||
<fieldset className="mt-5">
|
||||
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
|
||||
<ul className="flex flex-col">
|
||||
{zusatzaktionen.map((z) => (
|
||||
<li key={z.id}>
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={z.id}
|
||||
defaultChecked={z.an}
|
||||
className="accent-accent size-[18px] shrink-0"
|
||||
/>
|
||||
<span>{z.text}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</fieldset>
|
||||
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
|
||||
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
|
||||
Änderung anwenden
|
||||
</Button>
|
||||
<Button>Verwerfen</Button>
|
||||
</div>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||||
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||||
<dd className="min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AusrollKarte({
|
||||
titel,
|
||||
untertitel,
|
||||
eintraege,
|
||||
}: {
|
||||
titel: string;
|
||||
untertitel: string;
|
||||
eintraege: Ausrollung[];
|
||||
}) {
|
||||
return (
|
||||
<Karte
|
||||
titel={titel}
|
||||
untertitel={untertitel}
|
||||
aktionen={
|
||||
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||
Ausrollen
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{eintraege.map((e) => (
|
||||
<li key={`${e.ziel}${e.pfad}`}>
|
||||
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-text font-mono text-[13.5px] font-medium break-all">{e.ziel}</p>
|
||||
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
||||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||
</div>
|
||||
<Statuspunkt status={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
|
||||
</InnereBox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SchluesselNeu } from "@/components/schluessel/SchluesselNeu";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { personenAuswahl, vorlagenAuswahl } from "@/server/queries/schluessel";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel anlegen" };
|
||||
|
||||
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default async function SchluesselNeuSeite({ searchParams }: { searchParams: Suchparameter }) {
|
||||
await sitzungErforderlich("schluesselVerwalten", "/schluessel/neu");
|
||||
const person = (await searchParams)["person"];
|
||||
const [personen, vorlagen] = await Promise.all([personenAuswahl(), vorlagenAuswahl()]);
|
||||
const vorgabePersonId =
|
||||
typeof person === "string" && personen.some((p) => p.id === person) ? person : undefined;
|
||||
return (
|
||||
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={
|
||||
<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Neuer Schlüssel" }]} />
|
||||
}
|
||||
titel="Schlüssel anlegen"
|
||||
untertitel="Neu erzeugen oder einen vorhandenen Schlüssel übernehmen."
|
||||
/>
|
||||
<SchluesselNeu
|
||||
personen={personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }))}
|
||||
vorlagen={vorlagen.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
zusatz: v.standardLaufzeitTage ? `${v.standardLaufzeitTage} Tage` : null,
|
||||
}))}
|
||||
{...(vorgabePersonId ? { vorgabePersonId } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,223 @@
|
||||
import { KeyRound, Lock, Plus, Search } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Auswahlliste, 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 { datum } from "@/lib/format";
|
||||
import { ablaufZustand, fingerprintKurz, KATEGORIE_TEXT, STATUS_TEXT } from "@/lib/schluessel";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { schluesselListe, type SchluesselListenEintrag } from "@/server/queries/schluessel";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel" };
|
||||
|
||||
export default async function Schluessel() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
function einzeln(wert: string | string[] | undefined): string {
|
||||
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
|
||||
}
|
||||
|
||||
function Verwendungen({ anzahl }: { anzahl: number }) {
|
||||
if (anzahl === 0) return <span className="text-muted">keine</span>;
|
||||
return <>{anzahl}</>;
|
||||
}
|
||||
|
||||
/** In der Tabelle: Badge, wenn es drängt; sonst nur das Datum. */
|
||||
function Ablauf({ ablaufAm, jetzt }: { ablaufAm: Date | null; jetzt: Date }) {
|
||||
if (!ablaufAm) return <span className="text-muted">–</span>;
|
||||
if (ablaufZustand(ablaufAm, jetzt).art === "spaeter") {
|
||||
return (
|
||||
<time dateTime={ablaufAm.toISOString()} className="text-muted">
|
||||
{datum(ablaufAm)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
return <AblaufBadge ablaufAm={ablaufAm} jetzt={jetzt} />;
|
||||
}
|
||||
|
||||
function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
|
||||
if (!s.privatGespeichert) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Platzhalterseite
|
||||
<span title="Private Key verschlüsselt gespeichert" className="text-muted inline-flex">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">Private Key gespeichert</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
|
||||
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
const { benutzer } = await sitzungErforderlich("lesen", "/schluessel");
|
||||
const parameter = await searchParams;
|
||||
const q = einzeln(parameter["q"]);
|
||||
const kategorie = einzeln(parameter["kategorie"]);
|
||||
const status = einzeln(parameter["status"]);
|
||||
const liste = await schluesselListe({ q, kategorie, status });
|
||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
const gefiltert = q !== "" || kategorie !== "" || status !== "";
|
||||
const jetzt = new Date();
|
||||
|
||||
const anlegen = verwalten && (
|
||||
<Button href="/schluessel/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||
Schlüssel anlegen
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Schlüssel"
|
||||
untertitel="SSH-Schlüssel erzeugen, importieren und ausrollen"
|
||||
phase={2}
|
||||
issue="#012"
|
||||
untertitel={
|
||||
gefiltert
|
||||
? `${liste.length} Treffer`
|
||||
: `${liste.length} ${liste.length === 1 ? "verwalteter Schlüssel" : "verwaltete Schlüssel"}`
|
||||
}
|
||||
aktionen={anlegen}
|
||||
/>
|
||||
<p className="text-muted text-[14px]">
|
||||
Prototyp ansehen:{" "}
|
||||
<Link href="/schluessel/beispiel" className="text-accent font-medium hover:underline">
|
||||
Schlüsseldetail mit Beispieldaten
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<form
|
||||
method="get"
|
||||
role="search"
|
||||
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_11rem_auto]"
|
||||
>
|
||||
<Feld
|
||||
beschriftung="Suche"
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={q}
|
||||
placeholder="Zweck, Fingerprint, Tag, Person"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="sm:col-span-2 lg:col-span-1"
|
||||
/>
|
||||
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={kategorie}>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
|
||||
<option key={wert} value={wert}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Status" name="status" defaultValue={status}>
|
||||
<option value="">Alle</option>
|
||||
{Object.entries(STATUS_TEXT).map(([wert, text]) => (
|
||||
<option key={wert} value={wert}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<div className="flex gap-2.5 sm:col-span-2 lg:col-span-1">
|
||||
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 lg:flex-none">
|
||||
Filtern
|
||||
</Button>
|
||||
{gefiltert && (
|
||||
<Button href="/schluessel" className="flex-1 lg:flex-none">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{liste.length === 0 ? (
|
||||
gefiltert ? (
|
||||
<Leerzustand
|
||||
icon={<Search className="size-5" />}
|
||||
titel="Keine Treffer"
|
||||
text="Kein Schlüssel passt zu Suche und Filter."
|
||||
aktion={<Button href="/schluessel">Filter zurücksetzen</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Leerzustand
|
||||
icon={<KeyRound className="size-5" />}
|
||||
titel="Noch keine Schlüssel"
|
||||
text="Erzeugen Sie einen Schlüssel oder importieren Sie einen vorhandenen Public Key."
|
||||
aktion={anlegen}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Karte>
|
||||
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
|
||||
<ul className="flex flex-col md:hidden">
|
||||
{liste.map((s) => (
|
||||
<li key={s.id} className="border-border border-b last:border-b-0">
|
||||
<Link
|
||||
href={`/schluessel/${s.id}`}
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
|
||||
>
|
||||
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
|
||||
<span className="truncate">{s.name}</span>
|
||||
<PrivatHinweis s={s} />
|
||||
</span>
|
||||
<span className="text-muted block truncate font-mono text-[12.5px]">
|
||||
{fingerprintKurz(s.fingerprint)}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||
<KategorieBadge kategorie={s.kategorie} />
|
||||
<StatusBadge status={s.status} />
|
||||
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||
</span>
|
||||
<span className="text-muted text-[13px]">
|
||||
Verwendungen: <Verwendungen anzahl={s.verwendungen} />
|
||||
{s.person && <> · {s.person.name}</>}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Tabelle
|
||||
className="hidden md:block"
|
||||
beschriftung="Schlüssel"
|
||||
spalten={[
|
||||
{ titel: "Name" },
|
||||
{ titel: "Typ" },
|
||||
{ titel: "Kategorie" },
|
||||
{ titel: "Fingerprint" },
|
||||
{ titel: "Ablauf" },
|
||||
{ titel: "Verwendungen" },
|
||||
]}
|
||||
>
|
||||
{liste.map((s) => (
|
||||
<TabellenZeile key={s.id}>
|
||||
<TabellenZelle>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Link
|
||||
href={`/schluessel/${s.id}`}
|
||||
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
<PrivatHinweis s={s} />
|
||||
<StatusBadge status={s.status} />
|
||||
</span>
|
||||
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<KategorieBadge kategorie={s.kategorie} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||
<span title={s.fingerprint}>{fingerprintKurz(s.fingerprint)}</span>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Ablauf ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Verwendungen anzahl={s.verwendungen} />
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { HostFehler } from "@/server/services/hosts";
|
||||
import { privateKeyAbholen } from "@/server/services/privatekey";
|
||||
|
||||
/**
|
||||
* Download des Private Keys als Datei `<slug>` (#014). Nur POST aus einem Formular dieser
|
||||
* Anwendung: ein GET liesse sich per Link oder Vorabruf ausloesen und verbraeuchte die
|
||||
* einmalige Abholung. Fremde Seiten haelt die Herkunftspruefung fern.
|
||||
*
|
||||
* Prueft selbst Recht und frische Bestaetigung. Antwort ohne Caching; Fehlertexte enthalten
|
||||
* nie Schluesselmaterial.
|
||||
*/
|
||||
const KEIN_CACHE = { "Cache-Control": "no-store, max-age=0", Pragma: "no-cache" } as const;
|
||||
|
||||
function text(meldung: string, status: number): NextResponse {
|
||||
return new NextResponse(`${meldung}\n`, {
|
||||
status,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8", ...KEIN_CACHE },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommt die Anfrage von dieser Anwendung? Wegen `Referrer-Policy: no-referrer` schicken
|
||||
* Browser bei Formular-POSTs `Origin: null`; massgeblich ist deshalb `Sec-Fetch-Site`, das
|
||||
* Seiten nicht faelschen koennen. Ein passender Origin genuegt ebenso.
|
||||
*/
|
||||
function vonHier(anfrage: NextRequest): boolean {
|
||||
if (anfrage.headers.get("sec-fetch-site") === "same-origin") return true;
|
||||
return anfrage.headers.get("origin") === oeffentlicheAdresse("/", anfrage.nextUrl.origin).origin;
|
||||
}
|
||||
|
||||
export async function POST(anfrage: NextRequest, { params }: { params: Promise<{ vorgangId: string }> }) {
|
||||
if (!vonHier(anfrage)) return text("Anfrage von fremder Herkunft.", 403);
|
||||
|
||||
let benutzer;
|
||||
try {
|
||||
({ benutzer } = await rechtErforderlich("privateKeyAnzeigen"));
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler)
|
||||
return text(fehler.message, 403);
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const { vorgangId } = await params;
|
||||
try {
|
||||
const abholung = await privateKeyAbholen(vorgangId.slice(0, 64), "herunterladen", akteurVon(benutzer));
|
||||
return new NextResponse(abholung.privateKey, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
// Der Slug passt per DB-CHECK in einen Dateinamen; so heisst die Datei wie in ~/.ssh.
|
||||
"Content-Disposition": `attachment; filename="${abholung.slug}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...KEIN_CACHE,
|
||||
},
|
||||
});
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof HostFehler) return text(fehler.message, 410);
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { publicKeyExport } from "@/server/queries/schluessel";
|
||||
|
||||
/**
|
||||
* Public Key als Datei `<slug>.pub` (#012). Ein Public Key ist kein Geheimnis – jede
|
||||
* angemeldete Rolle darf ihn holen. Der Slug passt per DB-CHECK in einen Dateinamen.
|
||||
*/
|
||||
export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await rechtErforderlich("lesen");
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||
throw fehler;
|
||||
}
|
||||
const schluessel = await publicKeyExport((await params).id);
|
||||
if (!schluessel) return NextResponse.json({ fehler: "Unbekannter Schlüssel." }, { status: 404 });
|
||||
return new NextResponse(`${schluessel.publicKey}\n`, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${schluessel.slug}.pub"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user