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
+208
View File
@@ -0,0 +1,208 @@
import { redirect } from "next/navigation";
import { AlertTriangle, CheckCircle2, Clock, Server } from "lucide-react";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { betriebsmodus } from "@/server/mandant";
import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis";
import type { QuellenSicht } from "@/server/services/verzeichnis";
/**
* Admin → Verzeichnis (#016).
*
* Nur im Modus INTERN: dort hat das Haus **ein** Active Directory, aus dem seine
* Leute kommen. Im Modus DIENSTLEISTUNG gäbe es Verzeichnisse fremder Kunden,
* und die bindet diese Anwendung ausdrücklich nicht an (#008) — die Seite leitet
* dann um, nicht nur der Reiter verschwindet.
*
* Die Reihenfolge auf der Seite ist die Reihenfolge der Arbeit: erst sehen, was
* da ist und wie der letzte Abgleich lief, dann prüfen, dann anlegen.
*/
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
function Status({ quelle }: { quelle: QuellenSicht }) {
if (quelle.letzterAbgleichAm === null) {
return (
<p className="mt-2 text-sm text-nebentext">
Noch nie abgeglichen. „Verbindung testen“ zeigt, was ankäme, ohne etwas zu schreiben.
</p>
);
}
const gescheitert = quelle.letzterAbgleichStatus === "FEHLER";
return (
<div className="mt-2 flex flex-col gap-1">
<p className="flex flex-wrap items-center gap-2 text-sm">
{gescheitert ? (
<AlertTriangle className="size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
) : (
<CheckCircle2 className="size-4 shrink-0 text-erledigt" strokeWidth={2} aria-hidden="true" />
)}
<span className={gescheitert ? "text-fehler" : "text-nebentext"}>
{gescheitert ? "Letzter Abgleich gescheitert" : "Zuletzt abgeglichen"} am{" "}
{ZEITPUNKT.format(quelle.letzterAbgleichAm)}
</span>
</p>
{quelle.letzterAbgleichFehler ? (
<p className="max-w-prose text-sm break-words text-fehler">{quelle.letzterAbgleichFehler}</p>
) : null}
</div>
);
}
/**
* Das Abgleich-Log: was die letzten Läufe getan haben.
*
* Keine Tabelle, sondern eine Liste mit umbrechenden Zeilen — bei 390 px hätte
* eine Tabelle mit sechs Zahlenspalten quer geschoben werden müssen.
*
* Gezeigt werden auch Nullen: „0 angelegt, 0 aktualisiert“ ist die Auskunft, dass
* der Abgleich lief und das Verzeichnis sich nicht geändert hat. Fehlte die Zeile,
* sähe ein erfolgreicher Lauf ohne Änderung aus wie ein Lauf, der nie stattfand.
*/
function Log({ zeilen }: { zeilen: readonly AbgleichLogZeile[] }) {
if (zeilen.length === 0) {
return (
<p className="mt-3 text-sm text-nebentext">
Noch kein Lauf. Hier steht danach, was jeder Abgleich getan hat.
</p>
);
}
return (
<ul className="mt-3 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} className="border-t border-linie pt-3 first:border-t-0 first:pt-0">
<p className="flex flex-wrap items-center gap-2 text-sm">
{zeile.gescheitert ? (
<AlertTriangle className="size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
) : (
<CheckCircle2 className="size-4 shrink-0 text-erledigt" strokeWidth={2} aria-hidden="true" />
)}
<span>{ZEITPUNKT.format(zeile.zeitpunkt)}</span>
<span className="text-nebentext">
{/* Ohne Menschen war es der Zeitplan – das ist der Regelfall und keine Lücke. */}
{zeile.durch === null ? "Zeitplan" : zeile.durch}
</span>
</p>
<p className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-nebentext">
<span>{zeile.gelesen} gelesen</span>
<span>{zeile.angelegt} angelegt</span>
{zeile.uebernommen > 0 ? <span>{zeile.uebernommen} übernommen</span> : null}
<span>{zeile.aktualisiert} aktualisiert</span>
<span>{zeile.deaktiviert} deaktiviert</span>
{zeile.uebersprungen > 0 ? (
<span className="text-faellig">{zeile.uebersprungen} übersprungen</span>
) : null}
</p>
{zeile.fehler ? (
<p className="mt-1 max-w-prose text-xs break-words text-fehler">{zeile.fehler}</p>
) : null}
</li>
))}
</ul>
);
}
export default async function VerzeichnisSeite() {
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/verzeichnis");
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) {
redirect("/admin");
}
// Der angemeldete Benutzer, nicht ein erfundener: die Mandantensicht haengt
// daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer.
const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
const quellen = await quellenListe(handelnder);
// Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen,
// und das sind im Modus INTERN eine oder zwei.
const logs = new Map(
await Promise.all(
quellen.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
),
);
return (
<>
<h1 className="text-2xl font-bold">Verzeichnis</h1>
<p className="mt-2 max-w-prose text-nebentext">
Die Personen der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts
abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier <strong>inaktiv</strong>{" "}
— gelöscht wird nie, sonst verlöre die Historie ihre Namen.
</p>
{quellen.length === 0 ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Es ist keine Quelle eingerichtet. Personen lassen sich auch von Hand anlegen oder aus einer
CSV-Datei einlesen — ein Verzeichnis nimmt nur die Arbeit ab.
</p>
) : (
<ul className="mt-6 flex flex-col gap-4">
{quellen.map((quelle) => (
<li key={quelle.id} className="rounded-karte border border-linie bg-flaeche p-5">
<div className="flex flex-wrap items-center gap-2">
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
{quelle.aktiv ? (
<span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
alle {quelle.abgleichIntervallMinuten} min
</span>
) : (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
kein Zeitplan
</span>
)}
</div>
<p className="mono mt-2 text-xs break-all text-nebentext">
{quelle.url} · {quelle.basisDn}
</p>
<Status quelle={quelle} />
<div className="mt-4 flex flex-col gap-4">
<VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} />
</div>
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log
</summary>
<Log zeilen={logs.get(quelle.id) ?? []} />
</details>
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Einstellungen bearbeiten
</summary>
<div className="mt-3">
<QuelleFormular quelle={quelle} />
</div>
</details>
</li>
))}
</ul>
)}
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Quelle anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
</p>
<QuelleFormular />
</section>
</>
);
}