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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user