Files
Laufzettel/src/components/verzeichnis/quelle-formular.tsx
T
florian.wilde ae290345b7
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
2026-09-26 00:21:56 +00:00

284 lines
12 KiB
TypeScript

"use client";
import { useActionState, useState } from "react";
import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
import { FELD, KAESTCHEN } from "@/lib/formular";
import {
AD_ZUORDNUNG,
INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS,
ZUORDNUNGS_BESCHRIFTUNG,
ZUORDNUNGS_FELDER,
} from "@/lib/verzeichnis";
import {
quelleAnlegenAktion,
quelleBearbeitenAktion,
type AktionsErgebnis,
} from "@/server/actions/verzeichnis";
import type { QuellenSicht } from "@/server/services/verzeichnis";
/**
* Anlegen und Bearbeiten einer Verzeichnisquelle (#016).
*
* Ein Formular für beides, weil es dieselben Felder sind — mit einem
* Unterschied, der zählt: **beim Bearbeiten bleibt das Passwortfeld leer, und
* leer heißt „unverändert“.** Die Anwendung gibt das gespeicherte Passwort
* nirgends heraus; würde ein leeres Feld als Löschung verstanden, nähme jede
* Namensänderung der Quelle ihre Anmeldung.
*/
export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
const bearbeiten = quelle !== undefined;
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
bearbeiten ? quelleBearbeitenAktion : quelleAnlegenAktion,
{},
);
const [zertifikatOffen, setzeZertifikatOffen] = useState(false);
const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
const klartext = adresse.trim().startsWith("ldap://");
return (
<form action={aktion} className="flex flex-col gap-4">
{bearbeiten ? <input type="hidden" name="id" value={quelle.id} /> : null}
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input
name="name"
defaultValue={quelle?.name ?? ""}
required
placeholder="Firmen-AD"
className={FELD}
/>
</label>
{/*
* Die Erklärung steht **außerhalb** des `label` und hängt über
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
* ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung
* des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen
* Namen.
*/}
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-url" className="text-nebentext">
Adresse
</label>
<input
id="quelle-url"
name="url"
value={adresse}
onChange={(ereignis) => setzeAdresse(ereignis.target.value)}
required
aria-describedby="quelle-url-hinweis"
placeholder="ldaps://dc01.firma.local:636"
className={`${FELD} mono`}
/>
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die richtige
Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und dann
hochgestuft) und <span className="mono">ldap://</span>.
</span>
{klartext ? (
/*
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
* dasteht, liest nach dem dritten Mal niemand mehr.
*/
<span role="status" className="flex items-start gap-2 text-xs text-faellig">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Netz.
Für ein Dienstkonto im Active Directory ist das keine gute Idee.
</span>
</span>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anmelde-DN</span>
<input
name="bindDn"
defaultValue={quelle?.bindDn ?? ""}
required
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext">
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
Passwort
</label>
<input
id="quelle-passwort"
name="bindPasswort"
type="password"
autoComplete="new-password"
aria-describedby="quelle-passwort-hinweis"
placeholder={bearbeiten ? "unverändert lassen" : ""}
required={!bearbeiten}
className={FELD}
/>
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
{bearbeiten
? "Leer lassen behält das gespeicherte Passwort."
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
</span>
</div>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Basis-DN</span>
<input
name="basisDn"
defaultValue={quelle?.basisDn ?? ""}
required
placeholder="OU=Benutzer,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-filter" className="text-nebentext">
Filter (optional)
</label>
<input
id="quelle-filter"
name="filter"
defaultValue={quelle?.filter ?? ""}
aria-describedby="quelle-filter-hinweis"
className={`${FELD} mono`}
/>
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus
dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abgleich alle … Minuten</span>
<input
name="abgleichIntervallMinuten"
type="number"
min={INTERVALL_MINDESTENS}
max={INTERVALL_HOECHSTENS}
defaultValue={quelle?.abgleichIntervallMinuten ?? 60}
className={FELD}
/>
</label>
<label className="flex min-h-11 items-center gap-2 self-end text-sm text-nebentext md:min-h-10">
<input
type="checkbox"
name="aktiv"
value="1"
defaultChecked={quelle?.aktiv ?? false}
className={KAESTCHEN}
/>
Nach Zeitplan abgleichen
</label>
</div>
{/*
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
* sie nur in der Datenbank: der Kommentar im Code versprach „wer ein
* OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die
* Oberfläche nicht (Reviewbefund zu #016).
*/}
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Attributzuordnung anpassen
</summary>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Vorgaben sind die Attributnamen eines Active Directory. Ein OpenLDAP heißt an einigen Stellen
anders — dort ist die dauerhafte Kennung etwa <span className="mono">entryUUID</span>.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{ZUORDNUNGS_FELDER.map((feld) => (
<label key={feld} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">{ZUORDNUNGS_BESCHRIFTUNG[feld]}</span>
<input
name={`attr.${feld}`}
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
placeholder={AD_ZUORDNUNG[feld]}
className={`${FELD} mono`}
/>
</label>
))}
</div>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte: an ihr erkennt der
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
</p>
</details>
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Eigenes CA-Zertifikat hinterlegen
</summary>
{/*
* Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur
* die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das
* leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis
* darunter behauptete es auch (Reviewbefund zu #016).
*/}
{quelle?.caZertifikatVorhanden ? (
<p className="mt-2 flex items-start gap-2 text-xs text-erledigt">
<ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
lassen behält es.
</span>
</p>
) : null}
<label className="mt-2 flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">PEM</span>
<textarea
name="caZertifikatPem"
rows={4}
defaultValue=""
placeholder="-----BEGIN CERTIFICATE-----"
className={`${FELD} mono py-2`}
/>
<span className="text-xs text-nebentext">
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
{quelle?.caZertifikatVorhanden
? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
: " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
</span>
</label>
{quelle?.caZertifikatVorhanden ? (
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
Hinterlegtes Zertifikat entfernen
</label>
) : null}
</details>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
{bearbeiten ? (
<Save className="size-[18px]" strokeWidth={2} aria-hidden="true" />
) : (
<Server className="size-[18px]" strokeWidth={2} aria-hidden="true" />
)}
{laeuft ? "Wird gespeichert …" : bearbeiten ? "Speichern" : "Quelle anlegen"}
</button>
</form>
);
}