feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user