325 lines
12 KiB
TypeScript
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.");
|
|
}
|
|
}
|