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