feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #30.
This commit is contained in:
2026-09-26 00:21:56 +00:00
parent ab5777a57e
commit ae290345b7
57 changed files with 6834 additions and 61 deletions
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useActionState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { abteilungAnlegenAktion, type AktionsErgebnis } from "@/server/actions/abteilungen";
/**
* Abteilungsvorschlaege aus den `department`-Werten der Personen (#016).
*
* Die Namen stehen schon in den Daten — aus dem Verzeichnisabgleich, aus dem
* CSV-Import oder von Hand eingetragen —, es fehlt nur die gepflegte Abteilung
* dazu. Ein Klick legt sie an, und zwar ueber `abteilungAnlegenAktion`: dieselbe
* Server Action wie das Formular darunter, dieselbe Rechtepruefung, dieselbe
* Regel im Dienst. Hier entsteht **keine** zweite.
*
* Angelegt wird dabei nur der Name. Das sagt der Text auch: Ziel-OU und
* Standardgruppen sind danach leer, und ein Knopf, der so aussieht, als waere
* damit alles erledigt, waere eine Zusage, die dieser Code nicht haelt.
*/
export type AbteilungsVorschlag = {
readonly name: string;
readonly anzahl: number;
readonly kundeId: string;
/** Nur im Modus Dienstleistung gesetzt — sonst gibt es keine Kundenebene. */
readonly kundeName: string | null;
};
export function AbteilungVorschlaege({ vorschlaege }: { vorschlaege: readonly AbteilungsVorschlag[] }) {
return (
<>
<p className="mt-3 max-w-prose text-sm text-nebentext">
Ein Klick legt die Abteilung mit diesem Namen an — und nur den Namen. Ziel-OU, Standardgruppen und
Mail-Domain sind danach noch leer und werden in der Liste darunter nachgetragen.
</p>
<ul className="mt-3 flex flex-col gap-2">
{vorschlaege.map((vorschlag) => (
// Der Name allein taugt nicht als Schluessel: bei zwei Kunden kann
// „Vertrieb“ zweimal offen sein, und das sind zwei Vorgaenge.
<Vorschlag key={`${vorschlag.kundeId}\u0000${vorschlag.name}`} vorschlag={vorschlag} />
))}
</ul>
</>
);
}
function Vorschlag({ vorschlag }: { vorschlag: AbteilungsVorschlag }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(abteilungAnlegenAktion, {});
return (
<li className="rounded-karte border border-linie bg-grund p-3">
<form action={aktion} className="flex flex-wrap items-center gap-3">
<input type="hidden" name="name" value={vorschlag.name} />
{/* Im Modus INTERN verwirft `kundeErmitteln` die Kennung ohnehin; im Modus
Dienstleistung ist sie Pflicht, und der Vorschlag weiss, zu welchem
Kunden er gehoert. */}
<input type="hidden" name="kundeId" value={vorschlag.kundeId} />
<span className="min-w-0 grow">
<span className="font-medium break-words">{vorschlag.name}</span>
<span className="ml-2 text-xs text-nebentext">
{vorschlag.anzahl} {vorschlag.anzahl === 1 ? "Person" : "Personen"}
{vorschlag.kundeName === null ? "" : ` · ${vorschlag.kundeName}`}
</span>
</span>
<Button
type="submit"
disabled={laeuft}
className="h-11 shrink-0 rounded-pille px-4 text-sm font-semibold md:h-10"
>
<Plus className="size-4" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird angelegt …" : "Anlegen"}
</Button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="mt-2 text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
</li>
);
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import { mitarbeiterImportierenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
/**
* Personen aus einer CSV-Datei einspielen (#016).
*
* Die Datei wird **im Browser** gelesen und ihr Inhalt als Text mitgeschickt —
* derselbe Weg wie beim Geraete- und Vorlagenimport, und dieselbe Begruendung:
* das Format ist Text, die Dateien sind klein, und so gibt es einen Weg statt
* zwei.
*
* Was ueber dem Feld steht, muss der Dienst auch halten. Deshalb stehen dort nur
* Zusagen, die in `mitarbeiterImportieren` als Code nachzulesen sind: die
* Obergrenze, die Spalten, die Wiedererkennung, und dass eine leere Zelle nichts
* loescht.
*/
export function MitarbeiterImportieren({
hoechstZeilen,
spalten,
kunden,
}: {
hoechstZeilen: number;
spalten: readonly string[];
kunden: readonly { readonly id: string; readonly name: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
mitarbeiterImportierenAktion,
{},
);
const [inhalt, setInhalt] = useState("");
const [leseFehler, setLeseFehler] = useState<string | null>(null);
async function dateiLesen(datei: File | undefined) {
if (!datei) return;
setLeseFehler(null);
try {
setInhalt(await datei.text());
} catch {
setLeseFehler("Die Datei ließ sich nicht lesen.");
}
}
return (
<form action={aktion} className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-nebentext">
Spalten: <span className="mono text-text">{spalten.join(", ")}</span>. Die deutschen Namen (Vorname,
Nachname, Anzeigename, Abteilung, Titel) gehen genauso, Groß- und Kleinschreibung ist unerheblich.
Trennzeichen darf Komma oder Semikolon sein. Höchstens {hoechstZeilen} Zeilen je Datei. Eine
fehlerhafte Zeile bricht den Import nicht ab — sie wird unten mit ihrer Zeilennummer gemeldet.
</p>
<ul className="max-w-prose list-disc pl-5 text-sm text-nebentext">
<li>
Wiedererkannt wird eine Person über <span className="mono text-text">sam</span> oder{" "}
<span className="mono text-text">upn</span>, sonst über den Anzeigenamen. Wer schon da ist, wird
aktualisiert statt ein zweites Mal angelegt.
</li>
<li>
Eine leere Zelle löscht nichts: was in der Datei nicht steht, bleibt so, wie es ist. Fehlen{" "}
<span className="mono text-text">givenName</span> und <span className="mono text-text">sn</span>,
wird <span className="mono text-text">displayName</span> am letzten Leerzeichen geteilt.
</li>
<li>
Personen aus dem Verzeichnis werden nicht überschrieben; solche Zeilen werden gemeldet. Was hier
entsteht, gilt als von Hand angelegt und bleibt änderbar.
</li>
<li>
Eine deaktivierte Person wird erkannt und aktualisiert, aber <strong>nicht</strong> wieder aktiviert
— dafür gibt es den Schalter auf ihrer Seite.
</li>
<li>
Abteilungen legt der Import nicht an — der Name wird nur bei der Person eingetragen. Als Abteilung
mit Ziel-OU und Standardgruppen übernimmt sie ein Administrator.
</li>
</ul>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">CSV-Datei</span>
<input
type="file"
accept="text/csv,.csv,text/plain,.txt"
onChange={(ereignis) => void dateiLesen(ereignis.target.files?.[0])}
className="min-h-11 rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-nebentext file:mr-3 file:rounded-pille file:border-0 file:bg-flaeche-hoch file:px-3 file:py-1.5 file:text-sm file:text-text"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">… oder Inhalt einfügen</span>
<textarea
name="inhalt"
rows={6}
required
value={inhalt}
onChange={(ereignis) => setInhalt(ereignis.target.value)}
className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-xs text-text outline-none focus-visible:border-akzent"
placeholder={
"givenName;sn;displayName;mail;upn;sam;department;title\n" +
"Anna;Muster;Anna Muster;anna.muster@firma.de;anna.muster@firma.de;amuster;Vertrieb;Innendienst"
}
/>
</label>
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select name="kundeId" defaultValue="" className={FELD} required>
<option value="">bitte wählen</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{leseFehler ? (
<p role="alert" className="text-sm text-fehler">
{leseFehler}
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
{/* Je abgewiesene Zeile die Nummer, die der Editor des Benutzers anzeigt,
und der Grund. Eine Sammelmeldung waere hier nutzlos: die Datei muss an
genau diesen Zeilen geaendert werden. */}
{ergebnis.abgewiesen && ergebnis.abgewiesen.length > 0 ? (
<ul className="flex flex-col gap-1.5 rounded-karte border border-linie bg-grund p-3 text-xs">
{ergebnis.abgewiesen.map((abweisung) => (
<li key={abweisung.zeile} className="flex flex-wrap gap-2">
<span className="mono shrink-0 text-nebentext">Zeile {abweisung.zeile}</span>
<span className="min-w-0 text-fehler">{abweisung.grund}</span>
</li>
))}
</ul>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird eingelesen …" : "Einlesen"}
</Button>
</form>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useActionState } from "react";
import { RefreshCw } from "lucide-react";
import { abgleichJetztAktion, type AbgleichAktionsErgebnis } from "@/server/actions/verzeichnis";
/**
* „Jetzt abgleichen“ (#016).
*
* Derselbe Dienst, den auch der Zeitplan ruft — kein zweiter Weg mit eigenen
* Regeln. Der Knopf ist für die Ungeduld da: nach einer Änderung am Filter will
* niemand bis zur nächsten vollen Stunde warten.
*
* Läuft die Quelle gerade schon (die Sperre liegt in der Datenbank, nicht im
* Prozess), sagt das der Dienst; hier steht dann seine Meldung.
*/
export function JetztAbgleichen({ quelleId }: { quelleId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AbgleichAktionsErgebnis, FormData>(
abgleichJetztAktion,
{},
);
return (
<div className="flex flex-col gap-2">
<form action={aktion}>
<input type="hidden" name="id" value={quelleId} />
<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"
>
<RefreshCw className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird abgeglichen …" : "Jetzt abgleichen"}
</button>
</form>
{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}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useActionState } from "react";
import { PlugZap } from "lucide-react";
import { verbindungTestenAktion, type TestErgebnis } from "@/server/actions/verzeichnis";
/**
* „Verbindung testen“ (#016).
*
* Der Wert steckt nicht im „geht“, sondern in der Tabelle darunter: erst an den
* **gemappten** Feldern sieht ein Administrator, ob die Zuordnung stimmt. Ein
* grünes „Verbindung erfolgreich“ sähe genauso aus, wenn `sam` und `upn`
* vertauscht wären — und der Fehler fiele erst auf, wenn ein PowerShell-Befehl
* den falschen Anmeldenamen trägt.
*
* Geschrieben wird dabei nichts; das steht im Dienst und nicht hier.
*/
export function VerbindungTesten({ quelleId }: { quelleId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<TestErgebnis, FormData>(verbindungTestenAktion, {});
const treffer = ergebnis.treffer ?? [];
return (
<div className="flex flex-col gap-3">
<form action={aktion}>
<input type="hidden" name="id" value={quelleId} />
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<PlugZap className="size-4" strokeWidth={1.75} aria-hidden="true" />
{laeuft ? "Wird geprüft …" : "Verbindung testen"}
</button>
</form>
{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}
{treffer.length > 0 ? (
<>
<p className="max-w-prose text-xs text-nebentext">
Die ersten {treffer.length} Einträge, so wie sie ankommen würden. Es wurde nichts gespeichert. Ein
Vorgesetzter erscheint hier nur, wenn er zufällig unter diesen Einträgen ist — beim vollständigen
Abgleich wird er aufgelöst.
</p>
{/*
* Am Handy eine Karte je Person statt einer Tabelle: eine Tabelle mit
* sieben Spalten zwingt bei 390 px zum Seitwärtsscrollen, und genau
* das ist ausgeschlossen.
*/}
<ul className="flex flex-col gap-2">
{treffer.map((eintrag) => (
<li key={eintrag.externeId} className="rounded-karte border border-linie bg-grund p-3 text-sm">
<p className="font-medium break-words">{eintrag.anzeigename}</p>
<dl className="mt-1 grid gap-x-4 gap-y-1 sm:grid-cols-2">
{(
[
["UPN", eintrag.upn],
["sam", eintrag.sam],
["Mail", eintrag.mail],
["Abteilung", eintrag.abteilung],
["Titel", eintrag.titel],
["DN", eintrag.dn],
] as const
)
.filter(([, wert]) => Boolean(wert))
.map(([name, wert]) => (
<div key={name} className="flex min-w-0 gap-2">
<dt className="shrink-0 text-xs text-nebentext">{name}</dt>
<dd className="mono min-w-0 text-xs break-all">{wert}</dd>
</div>
))}
</dl>
</li>
))}
</ul>
</>
) : null}
</div>
);
}