Files
Laufzettel/src/server/actions/verzeichnis.ts
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

325 lines
12 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { satzEnde } from "@/lib/satzzeichen";
import {
INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS,
intervallMeldung,
zertifikatWahl,
zuordnungAusEingabe,
ZUORDNUNGS_FELDER,
} from "@/lib/verzeichnis";
import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
import {
VerzeichnisFehler,
quelleAnlegen,
quelleBearbeiten,
quelleLoeschen,
verbindungTesten,
type GemappterEintrag,
} from "@/server/services/verzeichnis";
/**
* Server Actions des Verzeichnisses (#016).
*
* Duenn wie ueberall: Recht pruefen, Zod anwenden, Dienst rufen. Eine Regel
* steht hier nicht — dass nur der Modus INTERN ein Verzeichnis hat, dass das
* Bind-Passwort verschluesselt wird und dass eine Fehlermeldung der Bibliothek
* gefiltert gehoert, entscheidet alles der Dienst.
*
* **Das Bind-Passwort kommt durch diese Datei und geht nie zurueck.** Keine der
* Antworten hier traegt es, und keine Meldung gibt eine rohe Ausnahme der
* LDAP-Bibliothek weiter: die kann es enthalten.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
async function angemeldetOderMeldung(): Promise<
{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("verwaltung");
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
/**
* Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine
* neutrale — **und zwar hier**, nicht in der Komponente.
*
* Eine unbekannte Ausnahme wird nicht weitergereicht: sie kann aus `ldapts`
* stammen und den Bind-Versuch samt Zugangsdaten enthalten. Ins Protokoll des
* Prozesses kommt nur Name und Meldung, nicht das Objekt.
*/
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof VerzeichnisFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : neutral);
return { fehler: neutral };
}
function text(formular: FormData, name: string): string {
const wert = formular.get(name);
return typeof wert === "string" ? wert.trim() : "";
}
/**
* Die Attributzuordnung aus dem Formular. Die Felder heissen `attr.<feld>`, damit
* sie nicht mit den Kopfdaten kollidieren; geprueft wird in `src/lib/verzeichnis.ts`.
*/
function zuordnungAusFormular(formular: FormData) {
return zuordnungAusEingabe(
Object.fromEntries(ZUORDNUNGS_FELDER.map((feld) => [feld, text(formular, `attr.${feld}`)])),
);
}
const QuelleSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
url: z
.string()
.trim()
.min(1, "Bitte die Adresse angeben, etwa ldaps://dc01.firma.local:636.")
.refine(
(wert) =>
wert.startsWith("ldaps://") || wert.startsWith("ldap+starttls://") || wert.startsWith("ldap://"),
"Die Adresse beginnt mit ldaps://, ldap+starttls:// oder ldap://.",
),
bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."),
basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
filter: z.string().trim().optional(),
caZertifikatPem: z.string().trim().optional(),
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
abgleichIntervallMinuten: z.coerce
.number()
.int()
.min(INTERVALL_MINDESTENS, intervallMeldung())
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
aktiv: z.boolean(),
});
export async function quelleAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = QuelleSchema.safeParse({
name: text(formular, "name"),
url: text(formular, "url"),
bindDn: text(formular, "bindDn"),
basisDn: text(formular, "basisDn"),
filter: text(formular, "filter") || undefined,
caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
aktiv: formular.get("aktiv") === "1",
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const bindPasswort = text(formular, "bindPasswort");
if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
const zuordnung = zuordnungAusFormular(formular);
if ("fehler" in zuordnung) return zuordnung;
try {
const quelle = await quelleAnlegen(
{ ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung },
zugang.benutzer,
);
revalidatePath("/admin/verzeichnis");
return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
}
}
export async function quelleBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
const eingabe = QuelleSchema.safeParse({
name: text(formular, "name"),
url: text(formular, "url"),
bindDn: text(formular, "bindDn"),
basisDn: text(formular, "basisDn"),
filter: text(formular, "filter") || undefined,
// Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
// Schema nur zwei.
caZertifikatPem: undefined,
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
aktiv: formular.get("aktiv") === "1",
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
/*
* Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
* entfernen (Kaestchen). Ohne das Kaestchen liess sich ein einmal hinterlegtes
* Zertifikat nie wieder loswerden — es stand unsichtbar in der Datenbank und
* entschied weiter, welcher CA vertraut wird (Reviewbefund zu #016).
*/
const zertifikat = zertifikatWahl(
text(formular, "caZertifikatPem"),
formular.get("caZertifikatEntfernen") === "1",
);
if ("fehler" in zertifikat) return zertifikat;
const caZertifikatPem = zertifikat.wert;
/*
* Ein leeres Passwortfeld heisst „unveraendert“ und nicht „leer“. Das Feld
* steht beim Bearbeiten leer da — es gibt das gespeicherte nicht her, und ein
* Formular, das es als Loeschung verstuende, naehme der Quelle bei jeder
* Namensaenderung die Anmeldung.
*/
const bindPasswort = text(formular, "bindPasswort");
const zuordnung = zuordnungAusFormular(formular);
if ("fehler" in zuordnung) return zuordnung;
try {
const quelle = await quelleBearbeiten(
id,
{
...eingabe.data,
...(bindPasswort ? { bindPasswort } : {}),
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
attributZuordnung: zuordnung.zuordnung,
},
zugang.benutzer,
);
revalidatePath("/admin/verzeichnis");
return { erfolg: `„${quelle.name}“ ist gespeichert.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function quelleLoeschenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
try {
await quelleLoeschen(id, zugang.benutzer);
revalidatePath("/admin/verzeichnis");
return { erfolg: "Die Quelle ist entfernt." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Quelle konnte nicht entfernt werden.");
}
}
export type TestErgebnis = AktionsErgebnis & {
readonly treffer?: readonly GemappterEintrag[];
readonly gesamtGelesen?: number;
};
/**
* „Verbindung testen“ — liest die ersten Treffer und **schreibt nichts**.
*
* Der Wert liegt nicht im „geht/geht nicht“, sondern in der Tabelle darunter:
* erst an den gemappten Feldern sieht ein Administrator, ob die Zuordnung
* stimmt. Ein „Verbindung erfolgreich“ allein saehe genauso aus, wenn `sam` und
* `upn` vertauscht waeren.
*/
export async function verbindungTestenAktion(
_vorher: TestErgebnis,
formular: FormData,
): Promise<TestErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
try {
const ergebnis = await verbindungTesten(id, zugang.benutzer);
return {
erfolg:
ergebnis.treffer.length === 0
? "Die Verbindung steht, aber der Filter findet niemanden."
: ergebnis.treffer.length === 0
? /*
* Gelesen, aber nichts zuordenbar: das ist fast immer ein falsch
* gesetztes Kennungsattribut und **nicht** ein zu enger Filter. Die
* beiden Faelle denselben Satz sagen zu lassen hiesse, den
* Administrator an der falschen Stelle suchen zu schicken.
*/
`Die Verbindung steht und der Filter trifft ${ergebnis.gesamtGelesen} Einträge — ` +
`zuordnen ließ sich keiner davon. Grund des ersten: ` +
`${satzEnde(ergebnis.uebersprungen[0]?.grund)} Prüf die Attributzuordnung, ` +
"besonders das Kennungsattribut."
: ergebnis.uebersprungen.length > 0
? `Die Verbindung steht. ${ergebnis.gesamtGelesen} Einträge gelesen, ` +
`${ergebnis.uebersprungen.length} übersprungen — Grund des ersten: ` +
satzEnde(ergebnis.uebersprungen[0]?.grund)
: `Die Verbindung steht. ${ergebnis.gesamtGelesen} Einträge gelesen.`,
treffer: ergebnis.treffer,
gesamtGelesen: ergebnis.gesamtGelesen,
};
} catch (fehler) {
return alsErgebnis(fehler, "Die Verbindung kam nicht zustande.");
}
}
export type AbgleichAktionsErgebnis = AktionsErgebnis & { readonly ergebnis?: AbgleichErgebnis };
export async function abgleichJetztAktion(
_vorher: AbgleichAktionsErgebnis,
formular: FormData,
): Promise<AbgleichAktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
try {
const ergebnis = await abgleichAusfuehren(id, zugang.benutzer);
revalidatePath("/admin/verzeichnis");
revalidatePath("/mitarbeiter");
return ergebnis.status === "FEHLER"
? { fehler: ergebnis.fehler ?? "Der Abgleich ist gescheitert.", ergebnis }
: {
erfolg:
`${ergebnis.angelegt} angelegt, ${ergebnis.aktualisiert} aktualisiert, ` +
`${ergebnis.deaktiviert} deaktiviert.` +
// Die Uebernahme steht nur da, wenn es eine gab: eine Zeile „0
// übernommen“ bei jedem Lauf erklaert nichts und verdeckt den Rest.
(ergebnis.uebernommen > 0
? ` ${ergebnis.uebernommen} von Hand angelegte Person${ergebnis.uebernommen === 1 ? "" : "en"} übernommen.`
: "") +
// Der Hinweis haengt an derselben Zeile, weil ein zweiter Absatz in
// gruener Schrift wie eine zweite Erfolgsmeldung liest.
(ergebnis.hinweis ? ` ${ergebnis.hinweis}` : ""),
ergebnis,
};
} catch (fehler) {
return alsErgebnis(fehler, "Der Abgleich ist gescheitert.");
}
}