Merge branch 'phase/019-vorlagen-oberflaeche'
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -1,5 +1,5 @@
|
|||||||
# #019 Vorlagen
|
# #019 Vorlagen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||||
@@ -8,12 +8,25 @@
|
|||||||
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
|
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Platzhalter `{{quellHostIp}}` usw.
|
- [x] Platzhalter `{{quellHostIp}}` usw.
|
||||||
|
- [x] Oberfläche: Liste `/vorlagen` (alle lesen), Anlegen/Bearbeiten/Archivieren nur Admin
|
||||||
|
- [x] Live-Vorschau der aufgelösten Optionen, des Alias und der sudoers-Datei
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Vorlage füllt Assistent vor
|
- [ ] Vorlage füllt Assistent vor (folgt mit dem Assistenten, #020)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#017, #018
|
#017, #018
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- Vorlagen pflegt nur, wer `einstellungenVerwalten` darf (Admin); lesen darf jede Rolle, weil
|
||||||
|
Vorlagen keine Geheimnisse enthalten und Operatoren sie im Assistenten sehen.
|
||||||
|
- Die Vorschau im Formular ruft dieselben reinen Funktionen wie der Server
|
||||||
|
(`platzhalterErsetzen`, `optionenPruefen`, `sudoersInhalt`) mit eigenen Beispielwerten auf;
|
||||||
|
Fehler werden dort nur angezeigt, verbindlich prüft der Server beim Speichern.
|
||||||
|
- Das Befehlsfeld wird bei anderem sudo-Modus nur ausgeblendet, nicht entfernt – so gehen
|
||||||
|
eingegebene Befehle beim Umschalten nicht verloren; der Service verwirft sie ohnehin.
|
||||||
|
- Die sudoers-Vorschau bricht nicht um: sudoers ist zeilenbasiert, ein optischer Umbruch sähe
|
||||||
|
wie eine zweite Regel aus.
|
||||||
|
- Eingebaute Vorlagen lassen sich bearbeiten, aber nicht archivieren; ihre Bearbeiten-Seite
|
||||||
|
zeigt deshalb keinen Archivieren-Knopf.
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
|||||||
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
||||||
{ name: "hosts", pfad: "/hosts" },
|
{ name: "hosts", pfad: "/hosts" },
|
||||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||||||
|
{ name: "vorlagen", pfad: "/vorlagen" },
|
||||||
|
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
|
||||||
|
{
|
||||||
|
name: "vorlage-bearbeiten",
|
||||||
|
pfad: "/vorlagen",
|
||||||
|
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ANSICHTEN = [
|
const ANSICHTEN = [
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { VorlageArchivierenDialog } from "@/components/vorlagen/VorlageArchivierenDialog";
|
||||||
|
import { VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { vorlageDetail } from "@/server/queries/vorlagen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Vorlage bearbeiten" };
|
||||||
|
|
||||||
|
export default async function VorlageBearbeiten({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
await sitzungErforderlich("einstellungenVerwalten", `/vorlagen/${id}`);
|
||||||
|
const v = await vorlageDetail(id);
|
||||||
|
// Archivierte Vorlagen sind fuer die Oberflaeche weg; das Protokoll kennt sie noch (#019).
|
||||||
|
if (!v || v.archiviertAm) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: v.name }]} />}
|
||||||
|
titel={`${v.name} bearbeiten`}
|
||||||
|
badges={v.eingebaut ? <Badge>eingebaut</Badge> : undefined}
|
||||||
|
untertitel={
|
||||||
|
v.eingebaut
|
||||||
|
? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aktionen={!v.eingebaut && <VorlageArchivierenDialog vorlageId={v.id} name={v.name} />}
|
||||||
|
/>
|
||||||
|
<VorlagenFormular
|
||||||
|
vorgabe={{
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
beschreibung: v.beschreibung ?? "",
|
||||||
|
// Das Formular kennt nur, was Vorlagen erzeugen; ANDERER/DEPLOYMENT kommen hier nicht vor.
|
||||||
|
schluesselTyp: v.schluesselTyp === "RSA4096" ? "RSA4096" : "ED25519",
|
||||||
|
kategorie: v.kategorie === "PERSON" ? "PERSON" : "DIENST",
|
||||||
|
standardLaufzeitTage: v.standardLaufzeitTage,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
zielbenutzerAnlegen: v.zielbenutzerAnlegen,
|
||||||
|
sudoModus: v.sudoModus,
|
||||||
|
sudoBefehle: v.sudoBefehle,
|
||||||
|
authorizedKeysOptionen: v.authorizedKeysOptionen ?? "",
|
||||||
|
sshConfigAlias: v.sshConfigAlias ?? "",
|
||||||
|
knownHostsEintragen: v.knownHostsEintragen,
|
||||||
|
privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { LEERE_VORLAGE, VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Vorlage anlegen" };
|
||||||
|
|
||||||
|
export default async function VorlageNeu() {
|
||||||
|
// Vorlagen pflegen nur Admins (#019) – wie die uebrigen Einstellungen.
|
||||||
|
await sitzungErforderlich("einstellungenVerwalten", "/vorlagen/neu");
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: "Neue Vorlage" }]} />}
|
||||||
|
titel="Vorlage anlegen"
|
||||||
|
untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern."
|
||||||
|
/>
|
||||||
|
<VorlagenFormular vorgabe={LEERE_VORLAGE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+129
-10
@@ -1,18 +1,137 @@
|
|||||||
|
import { FileStack, Pencil, Plus } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { Fragment, type ReactNode } from "react";
|
||||||
|
import { KategorieBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
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 { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { vorlagenListe, type VorlagenListenEintrag } from "@/server/queries/vorlagen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Vorlagen" };
|
export const metadata: Metadata = { title: "Vorlagen" };
|
||||||
|
|
||||||
export default async function Vorlagen() {
|
const SUDO_TEXT = { KEINER: null, BEFEHLE: "sudo: bestimmte Befehle", VOLL: "sudo: voll" } as const;
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
function Angabe({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
titel="Vorlagen"
|
<dt className="text-muted text-[12.5px] font-medium">{titel}</dt>
|
||||||
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
|
<dd className="min-w-0 text-[14px]">{children}</dd>
|
||||||
phase={3}
|
</div>
|
||||||
issue="#019"
|
);
|
||||||
/>
|
}
|
||||||
|
|
||||||
|
function Mono({ wert }: { wert: string | null }) {
|
||||||
|
if (!wert) return <span className="text-muted">–</span>;
|
||||||
|
// Lange Optionslisten sollen auf 390 px nach einem Komma umbrechen, nicht mitten im Wort (#019).
|
||||||
|
const teile = wert.split(",");
|
||||||
|
return (
|
||||||
|
<code className="text-text font-mono text-[13px] [overflow-wrap:anywhere]">
|
||||||
|
{teile.map((teil, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
{teil}
|
||||||
|
{i < teile.length - 1 && (
|
||||||
|
<>
|
||||||
|
,<wbr />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VorlagenKarte({ v, verwalten }: { v: VorlagenListenEintrag; verwalten: boolean }) {
|
||||||
|
const sudo = SUDO_TEXT[v.sudoModus];
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
as="article"
|
||||||
|
titel={v.name}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button
|
||||||
|
href={`/vorlagen/${v.id}`}
|
||||||
|
klein
|
||||||
|
icon={<Pencil className="size-3.5" />}
|
||||||
|
aria-label={`${v.name} bearbeiten`}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="-mt-2 mb-4 flex flex-wrap items-center gap-1.5">
|
||||||
|
<KategorieBadge kategorie={v.kategorie} />
|
||||||
|
<Badge mono>{v.schluesselTyp === "RSA4096" ? "rsa-4096" : "ed25519"}</Badge>
|
||||||
|
{v.eingebaut && <Badge>eingebaut</Badge>}
|
||||||
|
{v.standardLaufzeitTage && (
|
||||||
|
<Badge variante="messing">
|
||||||
|
{v.standardLaufzeitTage === 1 ? "1 Tag" : `${v.standardLaufzeitTage} Tage`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{v.beschreibung && <p className="text-muted mb-4 text-[14px]">{v.beschreibung}</p>}
|
||||||
|
<dl className="border-border mt-auto grid gap-3 border-t pt-4">
|
||||||
|
<Angabe titel="Zielbenutzer">
|
||||||
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<Mono wert={v.zielbenutzer} />
|
||||||
|
{v.zielbenutzerAnlegen && <span className="text-muted text-[13px]">wird angelegt</span>}
|
||||||
|
{sudo && (
|
||||||
|
<Badge variante={v.sudoModus === "VOLL" ? "gefahr" : "neutral"}>
|
||||||
|
{sudo}
|
||||||
|
{v.sudoModus === "BEFEHLE" && ` (${v.sudoBefehle.length})`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Angabe>
|
||||||
|
<Angabe titel="authorized_keys-Optionen">
|
||||||
|
<Mono wert={v.authorizedKeysOptionen} />
|
||||||
|
</Angabe>
|
||||||
|
<Angabe titel="Alias-Muster">
|
||||||
|
<Mono wert={v.sshConfigAlias} />
|
||||||
|
</Angabe>
|
||||||
|
</dl>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorlagen (#019): lesen darf jeder, anlegen und bearbeiten nur, wer Einstellungen verwaltet. */
|
||||||
|
export default async function Vorlagen() {
|
||||||
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
|
||||||
|
const vorlagen = await vorlagenListe();
|
||||||
|
const verwalten = darf(benutzer.rolle, "einstellungenVerwalten");
|
||||||
|
const anlegen = verwalten && (
|
||||||
|
<Button href="/vorlagen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Vorlage anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Vorlagen"
|
||||||
|
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup – sie füllen den Assistenten vor."
|
||||||
|
aktionen={anlegen}
|
||||||
|
/>
|
||||||
|
{vorlagen.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<FileStack className="size-5" />}
|
||||||
|
titel="Keine Vorlagen"
|
||||||
|
text="Die eingebauten Vorlagen legt der Seed an (pnpm db:seed)."
|
||||||
|
aktion={anlegen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<VorlagenKarte key={v.id} v={v} verwalten={verwalten} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Archive } from "lucide-react";
|
||||||
|
import { useActionState, useRef } from "react";
|
||||||
|
import { Button, Formularfehler } from "@/components/ui";
|
||||||
|
import type { FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { vorlageArchivierenAktion } from "@/server/actions/vorlagen";
|
||||||
|
|
||||||
|
/** Archivieren mit Rueckfrage (#019), gleiches Muster wie bei Hosts (#008). */
|
||||||
|
export function VorlageArchivierenDialog({ vorlageId, name }: { vorlageId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(vorlageArchivierenAktion, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Archive className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
>
|
||||||
|
Vorlage archivieren
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby="archivieren-titel"
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="id" value={vorlageId} />
|
||||||
|
<h2 id="archivieren-titel" className="text-[17px] font-semibold">
|
||||||
|
„{name}“ archivieren?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
Die Vorlage steht im Assistenten nicht mehr zur Auswahl. Schlüssel, die mit ihr angelegt wurden,
|
||||||
|
bleiben unverändert; das Protokoll bleibt erhalten.
|
||||||
|
</p>
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||||
|
{laeuft ? "Archiviere …" : "Archivieren"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui";
|
||||||
|
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||||
|
import { optionenPruefen } from "@/lib/ssh/optionen";
|
||||||
|
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
|
||||||
|
import { PLATZHALTER, platzhalterErsetzen, type PlatzhalterWerte } from "@/lib/vorlagen";
|
||||||
|
import { vorlageSpeichernAktion } from "@/server/actions/vorlagen";
|
||||||
|
|
||||||
|
type SudoModus = "KEINER" | "BEFEHLE" | "VOLL";
|
||||||
|
|
||||||
|
export type VorlageFormularVorgabe = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
beschreibung: string;
|
||||||
|
schluesselTyp: "ED25519" | "RSA4096";
|
||||||
|
kategorie: "DIENST" | "PERSON";
|
||||||
|
standardLaufzeitTage: number | null;
|
||||||
|
zielbenutzer: string;
|
||||||
|
zielbenutzerAnlegen: boolean;
|
||||||
|
sudoModus: SudoModus;
|
||||||
|
sudoBefehle: string[];
|
||||||
|
authorizedKeysOptionen: string;
|
||||||
|
sshConfigAlias: string;
|
||||||
|
knownHostsEintragen: boolean;
|
||||||
|
privatNachAusrollenLoeschen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LEERE_VORLAGE: VorlageFormularVorgabe = {
|
||||||
|
name: "",
|
||||||
|
beschreibung: "",
|
||||||
|
schluesselTyp: "ED25519",
|
||||||
|
kategorie: "DIENST",
|
||||||
|
standardLaufzeitTage: null,
|
||||||
|
zielbenutzer: "",
|
||||||
|
zielbenutzerAnlegen: false,
|
||||||
|
sudoModus: "KEINER",
|
||||||
|
sudoBefehle: [],
|
||||||
|
authorizedKeysOptionen: "",
|
||||||
|
sshConfigAlias: "",
|
||||||
|
knownHostsEintragen: true,
|
||||||
|
privatNachAusrollenLoeschen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Beispielwerte der Vorschau – sichtbar andere als die Probewerte des Servers. */
|
||||||
|
const BEISPIEL: Required<PlatzhalterWerte> = {
|
||||||
|
quellHost: "srv-build01",
|
||||||
|
quellHostIp: "10.0.0.5",
|
||||||
|
zielHost: "srv-web01",
|
||||||
|
zielHostIp: "10.0.0.20",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Vorschau = { wert: string | null; fehler?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Vorschau ist nur Anzeige (#019): Fehler werden zum Hinweis, nicht zum Absturz – die
|
||||||
|
* verbindliche Pruefung macht der Server beim Speichern mit denselben Funktionen.
|
||||||
|
*/
|
||||||
|
function vorschau(berechnen: () => string | null): Vorschau {
|
||||||
|
try {
|
||||||
|
return { wert: berechnen() };
|
||||||
|
} catch (fehler) {
|
||||||
|
return { wert: null, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function befehlsZeilen(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((b) => b.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vorschauzeile({ titel, ergebnis, leer }: { titel: string; ergebnis: Vorschau; leer: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="min-w-0">
|
||||||
|
{ergebnis.fehler ? (
|
||||||
|
<span className="text-danger text-[13.5px]">{ergebnis.fehler}</span>
|
||||||
|
) : ergebnis.wert ? (
|
||||||
|
<code className="text-text font-mono text-[13px] break-all">{ergebnis.wert}</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted text-[13.5px]">{leer}</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formular fuer neue und bestehende Vorlagen (#019). Regeln prueft der Server. */
|
||||||
|
export function VorlagenFormular({ vorgabe }: { vorgabe: VorlageFormularVorgabe }) {
|
||||||
|
const bearbeiten = Boolean(vorgabe.id);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(vorlageSpeichernAktion);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
|
||||||
|
// Kontrolliert nur, was die Vorschau braucht; der Rest bleibt unkontrolliert.
|
||||||
|
const [zielbenutzer, setZielbenutzer] = useState(vorgabe.zielbenutzer);
|
||||||
|
const [sudoModus, setSudoModus] = useState<SudoModus>(vorgabe.sudoModus);
|
||||||
|
const [befehle, setBefehle] = useState(vorgabe.sudoBefehle.join("\n"));
|
||||||
|
const [optionen, setOptionen] = useState(vorgabe.authorizedKeysOptionen);
|
||||||
|
const [alias, setAlias] = useState(vorgabe.sshConfigAlias);
|
||||||
|
|
||||||
|
const optionenVorschau = vorschau(() =>
|
||||||
|
optionen.trim() ? optionenPruefen(platzhalterErsetzen(optionen.trim(), BEISPIEL)) || null : null,
|
||||||
|
);
|
||||||
|
const aliasVorschau = vorschau(() => (alias.trim() ? platzhalterErsetzen(alias.trim(), BEISPIEL) : null));
|
||||||
|
const sudoersVorschau = vorschau(() =>
|
||||||
|
sudoModus === "KEINER" ? null : sudoersInhalt(zielbenutzer.trim(), sudoModus, befehlsZeilen(befehle)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||||
|
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
|
||||||
|
<Karte titel="Allgemein">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={vorgabe.name}
|
||||||
|
required
|
||||||
|
maxLength={80}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="z. B. Monitoring-Zugang"
|
||||||
|
fehler={f["name"]}
|
||||||
|
/>
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Beschreibung (optional)"
|
||||||
|
name="beschreibung"
|
||||||
|
defaultValue={vorgabe.beschreibung}
|
||||||
|
maxLength={500}
|
||||||
|
fehler={f["beschreibung"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Schlüssel">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feldgruppe legende="Typ" fehler={f["schluesselTyp"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="schluesselTyp"
|
||||||
|
value="ED25519"
|
||||||
|
defaultChecked={vorgabe.schluesselTyp === "ED25519"}
|
||||||
|
beschriftung={<span className="font-mono">ed25519</span>}
|
||||||
|
erklaerung="Empfohlen – kurz, schnell, sicher."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="schluesselTyp"
|
||||||
|
value="RSA4096"
|
||||||
|
defaultChecked={vorgabe.schluesselTyp === "RSA4096"}
|
||||||
|
beschriftung={<span className="font-mono">rsa-4096</span>}
|
||||||
|
erklaerung="Nur für alte Gegenstellen ohne ed25519."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="DIENST"
|
||||||
|
defaultChecked={vorgabe.kategorie === "DIENST"}
|
||||||
|
beschriftung="Dienst"
|
||||||
|
erklaerung="Host-zu-Host oder Automatisierung; sshkv verwahrt den Private Key."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="PERSON"
|
||||||
|
defaultChecked={vorgabe.kategorie === "PERSON"}
|
||||||
|
beschriftung="Person"
|
||||||
|
erklaerung="Der Private Key bleibt bei der Person; kein Benutzer anlegen, kein sudo."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Standard-Laufzeit in Tagen (optional)"
|
||||||
|
name="standardLaufzeitTage"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={3650}
|
||||||
|
defaultValue={vorgabe.standardLaufzeitTage ?? ""}
|
||||||
|
hinweis="Leer lassen für Schlüssel ohne Ablaufdatum."
|
||||||
|
fehler={f["standardLaufzeitTage"]}
|
||||||
|
className="sm:max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Zielkonto" untertitel="Unter welchem Benutzer der Schlüssel auf dem Zielhost landet">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zielbenutzer"
|
||||||
|
name="zielbenutzer"
|
||||||
|
value={zielbenutzer}
|
||||||
|
onChange={(e) => setZielbenutzer(e.target.value)}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
maxLength={32}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="deploy"
|
||||||
|
hinweis="Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen."
|
||||||
|
fehler={f["zielbenutzer"]}
|
||||||
|
className="sm:max-w-xs"
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="zielbenutzerAnlegen"
|
||||||
|
value="ja"
|
||||||
|
defaultChecked={vorgabe.zielbenutzerAnlegen}
|
||||||
|
beschriftung="Benutzer anlegen, falls er fehlt"
|
||||||
|
erklaerung="sshkv legt ihn beim Ausrollen an und entfernt ihn beim Widerruf wieder."
|
||||||
|
/>
|
||||||
|
<Feldgruppe
|
||||||
|
legende="sudo-Rechte"
|
||||||
|
hinweis="Nur für Benutzer, die sshkv selbst anlegt."
|
||||||
|
fehler={f["sudoModus"]}
|
||||||
|
>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["KEINER", "keine"],
|
||||||
|
["BEFEHLE", "bestimmte Befehle"],
|
||||||
|
["VOLL", "voll"],
|
||||||
|
] as const
|
||||||
|
).map(([wert, text]) => (
|
||||||
|
<Ankreuzfeld
|
||||||
|
key={wert}
|
||||||
|
type="radio"
|
||||||
|
name="sudoModus"
|
||||||
|
value={wert}
|
||||||
|
checked={sudoModus === wert}
|
||||||
|
onChange={() => setSudoModus(wert)}
|
||||||
|
beschriftung={text}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
{/* Ausgeblendet statt entfernt, damit die Befehle beim Hin- und Herschalten bleiben. */}
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Erlaubte Befehle"
|
||||||
|
name="sudoBefehle"
|
||||||
|
value={befehle}
|
||||||
|
onChange={(e) => setBefehle(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={"/usr/bin/systemctl restart nginx\n/usr/bin/systemctl reload nginx"}
|
||||||
|
hinweis="Ein Befehl je Zeile, mit absolutem Pfad. Argumente sind erlaubt, Platzhalter nicht."
|
||||||
|
fehler={f["sudoBefehle"]}
|
||||||
|
className={sudoModus === "BEFEHLE" ? "[&_textarea]:font-mono [&_textarea]:text-[13px]" : "hidden"}
|
||||||
|
/>
|
||||||
|
{sudoModus === "VOLL" && (
|
||||||
|
<p
|
||||||
|
role="note"
|
||||||
|
className="border-danger-border bg-danger-soft text-danger rounded-box flex items-start gap-2.5 border px-3 py-2.5 text-[14px]"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
|
<span>
|
||||||
|
Voller sudo-Zugang ohne Passwort: Wer diesen Schlüssel hat, ist auf dem Zielhost faktisch{" "}
|
||||||
|
<strong className="font-semibold">root</strong>. Nur wählen, wenn bestimmte Befehle nicht
|
||||||
|
reichen.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="authorized_keys & ssh">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feld
|
||||||
|
beschriftung="authorized_keys-Optionen (optional)"
|
||||||
|
name="authorizedKeysOptionen"
|
||||||
|
value={optionen}
|
||||||
|
onChange={(e) => setOptionen(e.target.value)}
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={'from="{{quellHostIp}}",no-port-forwarding'}
|
||||||
|
fehler={f["authorizedKeysOptionen"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Alias-Muster für ~/.ssh/config (optional)"
|
||||||
|
name="sshConfigAlias"
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="deploy-{{zielHost}}"
|
||||||
|
fehler={f["sshConfigAlias"]}
|
||||||
|
/>
|
||||||
|
<div className="border-border bg-sidebar rounded-box text-muted border px-4 py-3 text-[13px]">
|
||||||
|
<p>
|
||||||
|
Platzhalter:{" "}
|
||||||
|
{PLATZHALTER.map((p, i) => (
|
||||||
|
<span key={p}>
|
||||||
|
{i > 0 && ", "}
|
||||||
|
<code className="text-text font-mono whitespace-nowrap">{`{{${p}}}`}</code>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
. Beim Ausrollen setzt sshkv Name und IP von Quell- und Zielhost ein.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5">
|
||||||
|
Beispiel: <code className="text-text font-mono break-all">{'from="{{quellHostIp}}"'}</code> wird
|
||||||
|
zu <code className="text-text font-mono break-all">{`from="${BEISPIEL.quellHostIp}"`}</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="knownHostsEintragen"
|
||||||
|
value="ja"
|
||||||
|
defaultChecked={vorgabe.knownHostsEintragen}
|
||||||
|
beschriftung="Host-Key des Ziels in known_hosts der Quelle eintragen"
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="privatNachAusrollenLoeschen"
|
||||||
|
value="ja"
|
||||||
|
defaultChecked={vorgabe.privatNachAusrollenLoeschen}
|
||||||
|
beschriftung="Private Key nach dem Ausrollen in sshkv löschen"
|
||||||
|
erklaerung="Dann liegt er nur noch auf dem Quellhost und lässt sich nicht erneut ausrollen."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Vorschau"
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Mit Beispielwerten: Quelle <span className="font-mono">{BEISPIEL.quellHost}</span> (
|
||||||
|
<span className="font-mono">{BEISPIEL.quellHostIp}</span>), Ziel{" "}
|
||||||
|
<span className="font-mono">{BEISPIEL.zielHost}</span> (
|
||||||
|
<span className="font-mono">{BEISPIEL.zielHostIp}</span>)
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div aria-live="polite" className="flex flex-col gap-4">
|
||||||
|
<dl className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Vorschauzeile titel="authorized_keys-Optionen" ergebnis={optionenVorschau} leer="keine" />
|
||||||
|
<Vorschauzeile titel="Alias" ergebnis={aliasVorschau} leer="kein Eintrag in ~/.ssh/config" />
|
||||||
|
</dl>
|
||||||
|
{sudoModus !== "KEINER" &&
|
||||||
|
(sudoersVorschau.fehler ? (
|
||||||
|
<p className="text-danger text-[13.5px]">sudoers: {sudoersVorschau.fehler}</p>
|
||||||
|
) : (
|
||||||
|
sudoersVorschau.wert && (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<p className="text-muted text-[13px] font-medium">
|
||||||
|
<span className="font-mono">/etc/sudoers.d/sshkv-{zielbenutzer.trim()}</span>
|
||||||
|
</p>
|
||||||
|
{/* Ohne Umbruch: sudoers ist zeilenbasiert, ein optischer Umbruch taeuschte eine
|
||||||
|
zweite Regel vor (#019). Der Block scrollt dann fuer sich. */}
|
||||||
|
<MonoBlock wert={sudoersVorschau.wert} beschriftung="sudoers-Inhalt" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Vorlage anlegen"}
|
||||||
|
</Button>
|
||||||
|
<Button href="/vorlagen">Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodelle fuer Vorlagen (#019). Vorlagen enthalten keine Geheimnisse – jede angemeldete
|
||||||
|
* Rolle darf sie sehen; die Seite blendet nur die Bearbeiten-Knoepfe aus.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AUSWAHL = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
beschreibung: true,
|
||||||
|
eingebaut: true,
|
||||||
|
schluesselTyp: true,
|
||||||
|
kategorie: true,
|
||||||
|
standardLaufzeitTage: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
zielbenutzerAnlegen: true,
|
||||||
|
sudoModus: true,
|
||||||
|
sudoBefehle: true,
|
||||||
|
authorizedKeysOptionen: true,
|
||||||
|
sshConfigAlias: true,
|
||||||
|
knownHostsEintragen: true,
|
||||||
|
privatNachAusrollenLoeschen: true,
|
||||||
|
archiviertAm: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Eingebaute zuerst in Seed-Reihenfolge – sie sind der Normalfall, eigene die Ausnahme. */
|
||||||
|
export async function vorlagenListe() {
|
||||||
|
return prisma.vorlage.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
orderBy: [{ eingebaut: "desc" }, { angelegtAm: "asc" }, { name: "asc" }],
|
||||||
|
select: AUSWAHL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VorlagenListenEintrag = Awaited<ReturnType<typeof vorlagenListe>>[number];
|
||||||
|
|
||||||
|
export async function vorlageDetail(id: string) {
|
||||||
|
return prisma.vorlage.findUnique({ where: { id }, select: AUSWAHL });
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorlagen im gebauten Stand (#019). Testvorlagen heissen zz-e2e-… und werden am Ende des
|
||||||
|
* Ablaufs archiviert, damit die Entwicklungsdatenbank sauber bleibt.
|
||||||
|
*/
|
||||||
|
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") {
|
||||||
|
await page.goto("/anmelden");
|
||||||
|
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||||
|
await page.locator(`button[data-rolle="${rolle}"]`).click();
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Admin legt eine Vorlage an, sieht Feldfehler und archiviert sie", async ({ page }) => {
|
||||||
|
const name = `zz-e2e-${Date.now().toString(36)}`;
|
||||||
|
await oidcAnmelden(page, "admin");
|
||||||
|
await page.goto("/vorlagen");
|
||||||
|
await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible();
|
||||||
|
await page.getByRole("link", { name: "Vorlage anlegen" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/vorlagen\/neu$/);
|
||||||
|
|
||||||
|
await page.getByLabel("Name").fill(name);
|
||||||
|
await page.getByLabel("Zielbenutzer").fill("zzdeploy");
|
||||||
|
await page.getByRole("checkbox", { name: /Benutzer anlegen/ }).check();
|
||||||
|
await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellIp}}",no-pty');
|
||||||
|
|
||||||
|
// Die Vorschau meldet den Fehler schon vor dem Absenden, der Server dann am Feld.
|
||||||
|
await expect(page.getByText("Unbekannter Platzhalter {{quellIp}}.").first()).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Vorlage anlegen" }).click();
|
||||||
|
await expect(page.locator("#feld-authorizedKeysOptionen-fehler")).toHaveText(
|
||||||
|
"Unbekannter Platzhalter {{quellIp}}.",
|
||||||
|
);
|
||||||
|
// Eingaben bleiben nach der Feldmeldung stehen.
|
||||||
|
await expect(page.getByLabel("Name")).toHaveValue(name);
|
||||||
|
|
||||||
|
await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellHostIp}}",no-pty');
|
||||||
|
await page.getByLabel("Alias-Muster für ~/.ssh/config (optional)").fill("zz-{{zielHost}}");
|
||||||
|
await expect(page.getByText('from="10.0.0.5",no-pty')).toBeVisible();
|
||||||
|
await expect(page.getByText("zz-srv-web01")).toBeVisible();
|
||||||
|
|
||||||
|
// sudo „bestimmte Befehle“ blendet das Befehlsfeld ein, „voll“ warnt.
|
||||||
|
await page.getByRole("radio", { name: "voll" }).check();
|
||||||
|
await expect(page.getByRole("note")).toContainText("root");
|
||||||
|
await page.getByRole("radio", { name: "bestimmte Befehle" }).check();
|
||||||
|
await page.getByLabel("Erlaubte Befehle").fill("/usr/bin/systemctl restart nginx");
|
||||||
|
await expect(page.getByLabel("sudoers-Inhalt", { exact: true })).toContainText(
|
||||||
|
"zzdeploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Vorlage anlegen" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/vorlagen$/);
|
||||||
|
const karte = page.locator("article", { has: page.getByRole("heading", { name }) });
|
||||||
|
await expect(karte).toBeVisible();
|
||||||
|
await expect(karte.getByText('from="{{quellHostIp}}",no-pty')).toBeVisible();
|
||||||
|
await expect(karte.getByText("wird angelegt")).toBeVisible();
|
||||||
|
|
||||||
|
await karte.getByRole("link", { name: `${name} bearbeiten` }).click();
|
||||||
|
await page.getByRole("button", { name: "Vorlage archivieren" }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Archivieren" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/vorlagen$/);
|
||||||
|
await expect(page.getByRole("heading", { name })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("eingebaute Vorlagen haben keinen Archivieren-Knopf", async ({ page }) => {
|
||||||
|
await oidcAnmelden(page, "admin");
|
||||||
|
await page.goto("/vorlagen");
|
||||||
|
await page.getByRole("link", { name: "Deployment-Zugang bearbeiten" }).click();
|
||||||
|
await expect(page.getByLabel("Name")).toHaveValue("Deployment-Zugang");
|
||||||
|
await expect(page.getByRole("button", { name: "Vorlage archivieren" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Leser sieht Vorlagen, darf aber keine anlegen oder bearbeiten", async ({ page }) => {
|
||||||
|
await oidcAnmelden(page, "reader");
|
||||||
|
await page.goto("/vorlagen");
|
||||||
|
await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Vorlage anlegen" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("link", { name: /bearbeiten/ })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto("/vorlagen/neu");
|
||||||
|
await expect(page).toHaveURL(/\/kein-zugriff/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { VorlagenFehler, type VorlageEingabe } from "@/lib/vorlagen";
|
||||||
|
import { vorlagenListe } from "@/server/queries/vorlagen";
|
||||||
|
import { vorlageAnlegen, vorlageArchivieren, vorlageBearbeiten } from "@/server/services/vorlagen";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
|
||||||
|
/** Vorlagen-Service (#019) gegen die Testdatenbank: Eindeutigkeit, Protokoll, Schutz der eingebauten. */
|
||||||
|
|
||||||
|
let akteur: { id: string; name: string };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
const benutzer = await prisma.benutzer.create({ data: { name: "Ada Admin", rolle: "ADMIN" } });
|
||||||
|
akteur = { id: benutzer.id, name: benutzer.name };
|
||||||
|
});
|
||||||
|
|
||||||
|
const BASIS: VorlageEingabe = {
|
||||||
|
name: "Monitoring",
|
||||||
|
beschreibung: " Zugang für Prometheus ",
|
||||||
|
schluesselTyp: "ED25519",
|
||||||
|
kategorie: "DIENST",
|
||||||
|
standardLaufzeitTage: 30,
|
||||||
|
zielbenutzer: "monitor",
|
||||||
|
zielbenutzerAnlegen: true,
|
||||||
|
sudoModus: "BEFEHLE",
|
||||||
|
sudoBefehle: ["/usr/bin/systemctl status nginx"],
|
||||||
|
authorizedKeysOptionen: 'from="{{quellHostIp}}",no-pty',
|
||||||
|
sshConfigAlias: "mon-{{zielHost}}",
|
||||||
|
knownHostsEintragen: true,
|
||||||
|
privatNachAusrollenLoeschen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fehlerVon(versuch: Promise<unknown>): Promise<VorlagenFehler> {
|
||||||
|
const fehler = await versuch.then(
|
||||||
|
() => undefined,
|
||||||
|
(f: unknown) => f,
|
||||||
|
);
|
||||||
|
expect(fehler).toBeInstanceOf(VorlagenFehler);
|
||||||
|
return fehler as VorlagenFehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Vorlage anlegen", () => {
|
||||||
|
it("speichert bereinigt, laesst Platzhalter stehen und protokolliert", async () => {
|
||||||
|
const id = await vorlageAnlegen(BASIS, akteur);
|
||||||
|
const v = await prisma.vorlage.findUniqueOrThrow({ where: { id } });
|
||||||
|
expect(v.eingebaut).toBe(false);
|
||||||
|
expect(v.beschreibung).toBe("Zugang für Prometheus");
|
||||||
|
expect(v.sudoBefehle).toEqual(["/usr/bin/systemctl status nginx"]);
|
||||||
|
expect(v.authorizedKeysOptionen).toBe('from="{{quellHostIp}}",no-pty');
|
||||||
|
expect(v.sshConfigAlias).toBe("mon-{{zielHost}}");
|
||||||
|
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "vorlage.angelegt" } });
|
||||||
|
expect(eintrag.akteurId).toBe(akteur.id);
|
||||||
|
expect(eintrag.details).toMatchObject({ name: "Monitoring" });
|
||||||
|
expect((await vorlagenListe()).map((l) => l.name)).toEqual(["Monitoring"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft sudo-Befehle, wenn der Modus nicht BEFEHLE ist", async () => {
|
||||||
|
const id = await vorlageAnlegen({ ...BASIS, sudoModus: "KEINER" }, akteur);
|
||||||
|
expect((await prisma.vorlage.findUniqueOrThrow({ where: { id } })).sudoBefehle).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet einen doppelten Namen am Feld name", async () => {
|
||||||
|
await vorlageAnlegen(BASIS, akteur);
|
||||||
|
const fehler = await fehlerVon(vorlageAnlegen({ ...BASIS, zielbenutzer: "anders" }, akteur));
|
||||||
|
expect(fehler.feld).toBe("name");
|
||||||
|
expect(await prisma.vorlage.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet einen unbekannten Platzhalter am Feld der Optionen", async () => {
|
||||||
|
const fehler = await fehlerVon(
|
||||||
|
vorlageAnlegen({ ...BASIS, authorizedKeysOptionen: 'from="{{quellIp}}"' }, akteur),
|
||||||
|
);
|
||||||
|
expect(fehler.feld).toBe("authorizedKeysOptionen");
|
||||||
|
expect(fehler.message).toContain("{{quellIp}}");
|
||||||
|
expect(await prisma.vorlage.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Vorlage bearbeiten", () => {
|
||||||
|
it("protokolliert genau die geaenderten Felder", async () => {
|
||||||
|
const id = await vorlageAnlegen(BASIS, akteur);
|
||||||
|
await vorlageBearbeiten(
|
||||||
|
id,
|
||||||
|
{ ...BASIS, standardLaufzeitTage: 60, sudoBefehle: ["/usr/bin/systemctl status nginx", "/usr/bin/id"] },
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
const v = await prisma.vorlage.findUniqueOrThrow({ where: { id } });
|
||||||
|
expect(v.standardLaufzeitTage).toBe(60);
|
||||||
|
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "vorlage.geaendert" } });
|
||||||
|
const details = eintrag.details as { name: string; geaendert: string[] };
|
||||||
|
expect(details.name).toBe("Monitoring");
|
||||||
|
expect(details.geaendert.sort()).toEqual(["standardLaufzeitTage", "sudoBefehle"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet beim Umbenennen auf einen vergebenen Namen das Feld name", async () => {
|
||||||
|
await vorlageAnlegen(BASIS, akteur);
|
||||||
|
const id = await vorlageAnlegen({ ...BASIS, name: "Zweite" }, akteur);
|
||||||
|
const fehler = await fehlerVon(vorlageBearbeiten(id, BASIS, akteur));
|
||||||
|
expect(fehler.feld).toBe("name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verweigert archivierte Vorlagen", async () => {
|
||||||
|
const id = await vorlageAnlegen(BASIS, akteur);
|
||||||
|
await vorlageArchivieren(id, akteur);
|
||||||
|
await fehlerVon(vorlageBearbeiten(id, BASIS, akteur));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Vorlage archivieren", () => {
|
||||||
|
it("archiviert eigene Vorlagen und blendet sie aus der Liste aus", async () => {
|
||||||
|
const id = await vorlageAnlegen(BASIS, akteur);
|
||||||
|
await vorlageArchivieren(id, akteur);
|
||||||
|
expect((await prisma.vorlage.findUniqueOrThrow({ where: { id } })).archiviertAm).not.toBeNull();
|
||||||
|
expect(await vorlagenListe()).toEqual([]);
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "vorlage.archiviert" } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst eingebaute Vorlagen nicht archivieren, aber bearbeiten", async () => {
|
||||||
|
const eingebaut = await prisma.vorlage.create({
|
||||||
|
data: { name: "Deployment-Zugang", eingebaut: true, zielbenutzer: "deploy" },
|
||||||
|
});
|
||||||
|
await fehlerVon(vorlageArchivieren(eingebaut.id, akteur));
|
||||||
|
expect((await prisma.vorlage.findUniqueOrThrow({ where: { id: eingebaut.id } })).archiviertAm).toBeNull();
|
||||||
|
|
||||||
|
await vorlageBearbeiten(eingebaut.id, { ...BASIS, name: "Deployment-Zugang" }, akteur);
|
||||||
|
const v = await prisma.vorlage.findUniqueOrThrow({ where: { id: eingebaut.id } });
|
||||||
|
expect(v.eingebaut).toBe(true);
|
||||||
|
expect(v.zielbenutzer).toBe("monitor");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user