"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.`, 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 { 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 { 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 { 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 { 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 { 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."); } }