Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m3s

This commit is contained in:
2026-09-08 16:51:14 +02:00
parent cbde8cf3a8
commit c6cff9656e
13 changed files with 569 additions and 40 deletions

View File

@@ -1,15 +1,38 @@
"use server";
import { AuthError } from "next-auth";
import { signIn, signOut } from "@/auth";
// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen
// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede
// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim
// Austritt.
// Zwei Anmeldewege, und der zweite ist auf Zeit.
//
// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js
// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben
// Host. Ein untergeschobener Host läuft also weiterhin ins Leere.
// **Entra ID ist der Hauptweg.** Was der Mandant vorgibt — Mehrfaktor,
// bedingter Zugriff, Sperrung beim Austritt — gilt nur auf diesem Weg. Ein
// zweiter Weg daneben hebelt all das aus, und deshalb stand hier bis zum
// 08.09.2026, dass es ihn bewusst nicht gibt.
//
// **Warum es ihn jetzt trotzdem gibt.** Fünf Mitarbeiterinnen des Kunden
// (@manner.com) sollen die Anwendung testen. Manner hat einen eigenen
// Entra-Mandanten; eine Gasteinladung müsste deren IT einrichten und dauert
// Wochen. Die Daten im System sind zu diesem Zeitpunkt synthetisch.
//
// **Was den Weg begrenzt** — nichts davon steht hier, alles in der Datenbank
// (Migration 20260908120000), weil eine Regel im Anwendungscode die Regel
// wäre, die sich umgehen lässt:
//
// • Zwang zum Wechsel beim ersten Mal; solange er aussteht, liefert
// is_hr_user() false und damit gibt keine einzige Policy eine Zeile her
// • sieben Tage Frist für einen nie benutzten Zugang
// • Sperre für 15 Minuten nach fünf Fehlversuchen
// • Enddatum 08.10.2026, als Prüfbedingung festgenagelt
// (chk_passwort_pfad_endet) — danach nimmt die Anmeldung kein Passwort
// mehr an, ohne dass sich jemand erinnern muss
//
// **Vor den echten Manner-Daten muss der Passwortpfad weg sein.** Das ist die
// Bedingung, unter der er entstanden ist, nicht eine Empfehlung.
//
// Die Herkunft muss hier nicht aus dem Request geholt werden: Auth.js baut die
// Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben Host. Ein
// untergeschobener Host läuft also weiterhin ins Leere.
export async function signInWithEntra() {
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in
@@ -17,6 +40,47 @@ export async function signInWithEntra() {
await signIn("microsoft-entra-id", { redirectTo: "/" });
}
export type AnmeldeZustand = { fehler: string | null };
/**
* Anmeldung mit E-Mail und Passwort.
*
* Es gibt **eine** Fehlermeldung für jeden Fehlschlag. Ob die Adresse
* unbekannt, das Passwort falsch, das Konto gesperrt oder die Frist abgelaufen
* ist, steht im Protokoll und nicht auf dem Bildschirm: das Anmeldeformular
* steht offen im Internet, und eine Meldung, die zwischen „gibt es nicht" und
* „falsches Passwort" unterscheidet, ist ein Verzeichnis der Belegschaft.
* app_passwort_pruefen() nennt aus demselben Grund keinen Grund.
*/
export async function anmeldenMitPasswort(
_zustand: AnmeldeZustand,
formular: FormData
): Promise<AnmeldeZustand> {
const email = String(formular.get("email") ?? "").trim();
const passwort = String(formular.get("passwort") ?? "");
if (!email || !passwort) {
return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." };
}
try {
await signIn("passwort", { email, passwort, redirectTo: "/" });
} catch (fehler) {
// Bei Erfolg wirft signIn die Weiterleitung von Next.js. Die darf hier
// **nicht** hängenbleiben, sonst endet die Anmeldung auf der Anmeldeseite,
// obwohl das Cookie längst gesetzt ist. Nur ein AuthError ist ein
// Fehlschlag; alles andere gehört weitergereicht.
if (fehler instanceof AuthError) {
return { fehler: "E-Mail-Adresse oder Passwort stimmt nicht." };
}
throw fehler;
}
// Unerreichbar — signIn kehrt bei Erfolg nicht zurück. Steht da, weil der
// Typprüfer einen Rückgabewert auf jedem Pfad verlangt.
return { fehler: null };
}
export async function logout() {
await signOut({ redirectTo: "/login" });
}

61
actions/passwort.ts Normal file
View File

@@ -0,0 +1,61 @@
"use server";
import { redirect } from "next/navigation";
import { currentUserId } from "@/lib/auth/session";
import { runMutation } from "@/lib/db/rpc";
// Das eigene Passwort wechseln.
//
// Getrennt von actions/auth.ts, weil es etwas anderes ist: dort geht es um das
// Herstellen einer Sitzung, hier um eine Änderung an den eigenen Daten. Und
// getrennt von actions/benutzer.ts (Benutzerverwaltung), weil diese Aktion die
// einzige ist, die **ohne** HR-Rechte funktionieren muss.
export type WechselZustand = { fehler: string | null };
/**
* Wechselt das Passwort der angemeldeten Person.
*
* Kein `revalidatePath`: der Weg danach ist eine Weiterleitung durch die
* Komponente, und es gibt keine zwischengespeicherte Seite, die den alten Stand
* zeigen könnte.
*
* Die Meldungen kommen aus app_passwort_aendern() und sind für die Oberfläche
* geschrieben („Das bisherige Passwort stimmt nicht.") — sie werden
* durchgereicht wie bei jeder anderen Mutation.
*/
export async function passwortAendern(
_zustand: WechselZustand,
formular: FormData
): Promise<WechselZustand> {
const altes = String(formular.get("altes_passwort") ?? "");
const neues = String(formular.get("neues_passwort") ?? "");
const wiederholung = String(formular.get("wiederholung") ?? "");
if (!altes || !neues) {
return { fehler: "Bitte das bisherige und das neue Passwort eingeben." };
}
// Diese eine Prüfung gehört hierher und nicht in die Datenbank: die
// Wiederholung ist ein Bedienelement gegen Vertippen, kein Datum. Die
// Datenbank sieht sie nie und soll sie auch nicht sehen.
if (neues !== wiederholung) {
return { fehler: "Die beiden neuen Passwörter stimmen nicht überein." };
}
const ergebnis = await runMutation(await currentUserId(), "app_passwort_aendern", {
altes_passwort: altes,
neues_passwort: neues,
});
if (!ergebnis.success) {
return { fehler: ergebnis.error ?? "Unbekannter Fehler." };
}
// Ab hier ist der Wechsel erledigt, is_hr_user() liefert wieder true und die
// Anwendung ist offen. Die Weiterleitung steht in der Aktion und nicht in der
// Komponente: `redirect()` wirft, die Aktion kehrt also nicht zurück, und es
// gibt keinen Zwischenzustand, in dem das Formular noch einmal abgeschickt
// werden könnte.
redirect("/");
}