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"; "use server";
import { AuthError } from "next-auth";
import { signIn, signOut } from "@/auth"; import { signIn, signOut } from "@/auth";
// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen // Zwei Anmeldewege, und der zweite ist auf Zeit.
// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede
// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim
// Austritt.
// //
// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js // **Entra ID ist der Hauptweg.** Was der Mandant vorgibt — Mehrfaktor,
// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben // bedingter Zugriff, Sperrung beim Austritt — gilt nur auf diesem Weg. Ein
// Host. Ein untergeschobener Host läuft also weiterhin ins Leere. // 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() { export async function signInWithEntra() {
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in // 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: "/" }); 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() { export async function logout() {
await signOut({ redirectTo: "/login" }); 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("/");
}

View File

@@ -13,15 +13,24 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt — // Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
// und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie // und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie
// vielen Rundreisen gelesen wird, steht in lib/shell-data.ts. // vielen Rundreisen gelesen wird, steht in lib/shell-data.ts.
const data = await withUser(userId, (tx) => loadShellData(tx, userId)); const ergebnis = await withUser(userId, (tx) => loadShellData(tx, userId));
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet // Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat keine
// ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat // Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
// keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten // entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
// Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer // Sitzungstoken.
// wortlosen Anmeldeseite und versuchte es endlos erneut. //
if (!data) redirect("/login?error=no_hr_access"); // Beide Umleitungen sind Bedienkomfort, keine Absicherung: wer sie umgeht,
// bekommt trotzdem keine Zeile, weil die RLS-Policies dieselbe Frage stellen
// (is_hr_user()). Ohne sie stünde die Person nur vor einer leeren Anwendung
// und wüsste nicht, warum.
if (ergebnis.status === "passwort_wechseln") redirect("/passwort-aendern");
// Ohne den Grund in der Adresse stünde die Person vor einer wortlosen
// Anmeldeseite und versuchte es endlos erneut.
if (ergebnis.status === "kein_zugang") redirect("/login?error=no_hr_access");
const data = ergebnis.daten;
const userLabel = data.profile.full_name || data.profile.email || ""; const userLabel = data.profile.full_name || data.profile.email || "";

View File

@@ -1,4 +1,5 @@
import { EntraSignInButton } from "@/components/auth/EntraSignInButton"; import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
import { PasswortFormular } from "@/components/auth/PasswortFormular";
import { logout, signInWithEntra } from "@/actions/auth"; import { logout, signInWithEntra } from "@/actions/auth";
// The query string is attacker-controlled, so the login page renders a message // The query string is attacker-controlled, so the login page renders a message
@@ -15,6 +16,15 @@ const ERROR_MESSAGES = {
title: "Anmeldung fehlgeschlagen", title: "Anmeldung fehlgeschlagen",
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
}, },
// Auth.js' eigener Code für eine gescheiterte Passwortanmeldung. Im Regelfall
// kommt er hier nicht an — die Server Action fängt den Fehlschlag ab und
// zeigt ihn am Formular. Ohne diesen Eintrag fiele er aber auf `sso_failed`
// zurück, und dort stünde „über das Firmenkonto", was mit dem tatsächlichen
// Weg nichts zu tun hätte.
CredentialsSignin: {
title: "Anmeldung fehlgeschlagen",
body: "E-Mail-Adresse oder Passwort stimmt nicht.",
},
} as const; } as const;
type ErrorCode = keyof typeof ERROR_MESSAGES; type ErrorCode = keyof typeof ERROR_MESSAGES;
@@ -77,6 +87,20 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
<EntraSignInButton /> <EntraSignInButton />
</form> </form>
{/* Der Passwortweg steht bewusst *unter* dem Firmenkonto und ohne
eigene Überschrift: er ist die Ausnahme für die Testphase, nicht
die zweite gleichwertige Möglichkeit. Wer ein Firmenkonto hat,
soll oben klicken. */}
<div className="mt-6 flex items-center gap-3" aria-hidden>
<span className="h-px flex-1 bg-border-subtle" />
<span className="text-xs font-semibold uppercase tracking-wider text-ink-muted">oder</span>
<span className="h-px flex-1 bg-border-subtle" />
</div>
<div className="mt-6">
<PasswortFormular />
</div>
<p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted"> <p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted">
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle
Personaldaten verschlossen. Personaldaten verschlossen.

View File

@@ -0,0 +1,57 @@
import { redirect } from "next/navigation";
import { PasswortAendernFormular } from "@/components/auth/PasswortAendernFormular";
import { CARD_CLASS } from "@/components/ui/Card";
import { currentUserId } from "@/lib/auth/session";
import { sql, withUser } from "@/lib/db";
// Diese Seite liegt **ausserhalb** der Gruppe (app), und das ist der Punkt.
//
// app/(app)/layout.tsx lässt nur durch, wer aktive HR-Person ist — und genau
// das ist man nicht, solange ein Passwortwechsel aussteht: is_hr_user() liefert
// dann false (Migration 20260908120000). Läge diese Seite unter (app), wäre sie
// für die einzigen Menschen gesperrt, die sie brauchen.
//
// Angemeldet sein muss man trotzdem: darum kümmert sich proxy.ts, der jede
// Adresse ausser /login und /api/auth hinter eine Sitzung stellt. Die Prüfung
// unten ist die zweite Linie für den Fall, dass jemand den Matcher ändert.
export const metadata = { title: "Passwort ändern" };
export default async function PasswortAendernPage() {
const userId = await currentUserId();
if (!userId) redirect("/login");
// Erzwungen oder freiwillig — davon hängt nur die Beschriftung ab und ob es
// einen Weg zurück gibt.
//
// Ob dieses Konto überhaupt ein Passwort hat, wird hier *nicht* gefragt:
// app_passwoerter ist für die Anwendungsrolle unerreichbar (Policy
// `using (false)`), und eine Auskunftsfunktion nur für diese Anzeige wäre
// eine Funktion zu viel. Wer kein Passwort hat, bekommt beim Absenden die
// Meldung aus app_passwort_aendern().
const erzwungen = await withUser(userId, async (tx) => {
const ergebnis = await sql<{ wechseln: boolean }>`select app_muss_passwort_wechseln() as wechseln`.execute(tx);
return ergebnis.rows[0]?.wechseln === true;
});
return (
<main className="flex min-h-dvh items-center justify-center px-6 py-12">
<div className="w-full max-w-sm">
<h1 className="text-2xl font-extrabold tracking-tight text-ink">Passwort ändern</h1>
{erzwungen ? (
<p className="mt-1.5 text-sm text-ink-muted">
Ihr Zugang wurde mit einem gemeinsamen Startpasswort eingerichtet. Solange es gilt, bleiben alle
Personaldaten verschlossen — bitte vergeben Sie jetzt ein eigenes.
</p>
) : (
<p className="mt-1.5 text-sm text-ink-muted">Sie können hier jederzeit ein neues Passwort vergeben.</p>
)}
<div className={`mt-7 p-5 ${CARD_CLASS}`}>
<PasswortAendernFormular erzwungen={erzwungen} />
</div>
</div>
</main>
);
}

75
auth.ts
View File

@@ -1,5 +1,6 @@
import "server-only"; import "server-only";
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { authConfig } from "@/lib/auth/config"; import { authConfig } from "@/lib/auth/config";
import { asSystem, sql } from "@/lib/db"; import { asSystem, sql } from "@/lib/db";
@@ -9,6 +10,14 @@ import { asSystem, sql } from "@/lib/db";
// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der // Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der
// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung // Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung
// versteht. // versteht.
//
// Seit 08.09.2026 steht daneben ein zweiter Weg: Anmeldung mit Passwort, für
// die Testphase und mit eingebautem Ende (siehe Migration 20260908120000).
// Auch er gehört **hierher** und nicht nach lib/auth/config.ts, denn auch er
// spricht mit der Datenbank. Der Proxy sieht ihn nicht und braucht ihn nicht:
// er liest nur das Sitzungscookie, und das ist ein signiertes Token, dessen
// Entschlüsselung von der Anbieterliste unabhängig ist. Welcher Weg zur
// Sitzung geführt hat, steht darin nicht — und muss es auch nicht.
/** /**
* Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung, * Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung,
@@ -31,21 +40,81 @@ async function upsertAppUser(externalId: string, email: string, fullName: string
return row.id; return row.id;
} }
/**
* Anmeldung mit Passwort.
*
* Die Prüfung selbst steht vollständig in app_passwort_pruefen(): Sperre nach
* fünf Fehlversuchen, Frist für das Initialpasswort, Enddatum des
* Passwortpfads, Protokolleintrag. Hier bleibt nur das Weiterreichen — und
* das ist Absicht. Eine zweite Fassung der Regeln im Anwendungscode wäre die
* Fassung, die zuerst veraltet, und sie liesse sich umgehen, indem jemand die
* Datenbankfunktion direkt aufruft.
*
* Die Funktion liefert eine Kennung oder null und **nie einen Grund**. Was
* hier ankommt, reicht deshalb nicht aus, um zu unterscheiden, ob die Adresse
* unbekannt, das Passwort falsch oder das Konto gesperrt war. Genau so soll
* die Anmeldemaske antworten.
*/
const passwortAnbieter = Credentials({
id: "passwort",
name: "Passwort",
credentials: {
email: { label: "E-Mail", type: "email" },
passwort: { label: "Passwort", type: "password" },
},
async authorize(eingabe) {
const email = typeof eingabe?.email === "string" ? eingabe.email : "";
const passwort = typeof eingabe?.passwort === "string" ? eingabe.passwort : "";
if (!email || !passwort) return null;
// asSystem, weil es noch keine angemeldete Person gibt — die entsteht ja
// erst aus dem Ergebnis. Dieselbe Rolle ohne BYPASSRLS wie überall; die
// Funktion ist SECURITY DEFINER und darf genau das eine.
const zeile = await asSystem(async (tx) => {
const ergebnis = await sql<{ id: string | null }>`
select app_passwort_pruefen(${email}, ${passwort}) as id
`.execute(tx);
return ergebnis.rows[0];
});
if (!zeile?.id) return null;
return { id: zeile.id, email, name: null };
},
});
export const { handlers, auth, signIn, signOut } = NextAuth(() => { export const { handlers, auth, signIn, signOut } = NextAuth(() => {
const base = authConfig(); const base = authConfig();
return { return {
...base, ...base,
// Entra bleibt der erste Eintrag und damit der Hauptweg. Die Basisliste
// wird ergänzt, nicht ersetzt: `providers: [passwortAnbieter]` hätte die
// Firmenanmeldung stillschweigend entfernt.
providers: [...base.providers, passwortAnbieter],
callbacks: { callbacks: {
// Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt // Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt
// session(), das die Kennung aus dem Token auf die Sitzung legt. Ein // session(), das die Kennung aus dem Token auf die Sitzung legt. Ein
// schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt. // schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt.
...base.callbacks, ...base.callbacks,
async jwt({ token, profile }) { async jwt({ token, profile, user }) {
// Drei Fälle, und die Reihenfolge trägt sie:
//
// 1. Entra, erster Durchlauf → `profile` liegt vor (und `user` auch)
// 2. Passwort, erster Durchlauf → nur `user`, aus authorize()
// 3. jeder weitere Aufruf → keins von beiden, Token durchreichen
//
// Deshalb wird `profile` zuerst geprüft: bei der Anmeldung über Entra
// ist `user` ebenfalls gesetzt, und eine Prüfung auf `user` zuerst
// führte den Entra-Weg an app_upsert_user() vorbei — die Kennung wäre
// dann die `oid` statt app_users.id, und keine einzige Policy fände
// dazu eine Zeile.
if (!profile) {
if (user?.id) token.uid = user.id;
return token;
}
// `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra // `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra
// vor. Danach wird das Token nur noch weitergereicht — die Datenbank // vor. Danach wird das Token nur noch weitergereicht — die Datenbank
// wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf. // wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf.
if (!profile) return token;
const externalId = typeof profile.oid === "string" ? profile.oid : null; const externalId = typeof profile.oid === "string" ? profile.oid : null;
const email = [profile.email, profile.preferred_username, profile.upn].find( const email = [profile.email, profile.preferred_username, profile.upn].find(
(v): v is string => typeof v === "string" && v.length > 0 (v): v is string => typeof v === "string" && v.length > 0

View File

@@ -0,0 +1,100 @@
"use client";
import { useActionState, useState } from "react";
import { passwortAendern, type WechselZustand } from "@/actions/passwort";
import { Button } from "@/components/ui/Button";
import { CONTROL_CLASS, Field } from "@/components/ui/Field";
import { PASSWORT_REGELN, passwortBeanstandung } from "@/lib/passwort";
// Das eigene Passwort wechseln.
//
// Die Regeln stehen schon beim Tippen da, statt erst nach dem Absenden — beim
// erzwungenen Wechsel ist das der erste Bildschirm, den jemand in dieser
// Anwendung sieht, und dreimal abgewiesen zu werden ist ein schlechter Anfang.
// Abgewiesen oder angenommen wird trotzdem in der Datenbank; hier steht nur
// eine Vorabanzeige (lib/passwort.ts).
const START: WechselZustand = { fehler: null };
export function PasswortAendernFormular({ erzwungen }: { erzwungen: boolean }) {
const [zustand, aktion, laeuft] = useActionState(passwortAendern, START);
const [neues, setNeues] = useState("");
const [wiederholung, setWiederholung] = useState("");
// Erst beanstanden, wenn etwas dasteht: ein leeres Feld ist noch kein Fehler.
const beanstandung = neues ? passwortBeanstandung(neues) : null;
const passtNichtZusammen = wiederholung.length > 0 && neues !== wiederholung;
return (
<form action={aktion} className="flex flex-col gap-4">
{zustand.fehler && (
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
{zustand.fehler}
</p>
)}
<Field label="Bisheriges Passwort">
{(p) => (
<input {...p} name="altes_passwort" type="password" required autoComplete="current-password" className={CONTROL_CLASS} />
)}
</Field>
<Field
label="Neues Passwort"
error={beanstandung}
hint={`Erforderlich: ${PASSWORT_REGELN.join(", ")}.`}
>
{(p) => (
<input
{...p}
name="neues_passwort"
type="password"
required
autoComplete="new-password"
value={neues}
onChange={(e) => setNeues(e.target.value)}
className={CONTROL_CLASS}
/>
)}
</Field>
<Field label="Neues Passwort wiederholen" error={passtNichtZusammen ? "Die beiden Eingaben stimmen nicht überein." : null}>
{(p) => (
<input
{...p}
name="wiederholung"
type="password"
required
autoComplete="new-password"
value={wiederholung}
onChange={(e) => setWiederholung(e.target.value)}
className={CONTROL_CLASS}
/>
)}
</Field>
{/* Gesperrt, solange die Vorabanzeige etwas zu beanstanden hat. Das ist
Bequemlichkeit, keine Absicherung — wer die Schaltfläche freischaltet,
landet in app_passwort_regeln() und wird dort abgewiesen. */}
<Button
type="submit"
fullWidth
disabled={Boolean(beanstandung) || passtNichtZusammen}
pending={laeuft}
pendingLabel="Wird gespeichert…"
>
Passwort ändern
</Button>
{!erzwungen && (
<a
href="/"
className="rounded text-center text-xs text-ink-muted underline underline-offset-2 hover:no-underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Zurück zur Anwendung
</a>
)}
</form>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useActionState } from "react";
import { anmeldenMitPasswort, type AnmeldeZustand } from "@/actions/auth";
import { Button } from "@/components/ui/Button";
import { CONTROL_CLASS, Field } from "@/components/ui/Field";
// Anmeldung mit Passwort — der zweite Weg neben dem Firmenkonto, auf Zeit
// (siehe actions/auth.ts).
//
// Die Meldung kommt aus der Server Action und ist für jeden Fehlschlag
// dieselbe. Kein `autoFocus`: die Seite bietet zwei Wege an, und der obere ist
// der gemeinte — den Cursor unten hineinzusetzen kehrte das um.
const START: AnmeldeZustand = { fehler: null };
export function PasswortFormular() {
const [zustand, aktion, laeuft] = useActionState(anmeldenMitPasswort, START);
return (
<form action={aktion} className="flex flex-col gap-3">
{zustand.fehler && (
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
{zustand.fehler}
</p>
)}
<Field label="E-Mail-Adresse">
{(p) => (
<input
{...p}
name="email"
type="email"
required
// `username`, nicht `email`: nur so bietet der Passwortspeicher des
// Browsers das Paar aus Adresse und Passwort gemeinsam an.
autoComplete="username"
className={CONTROL_CLASS}
/>
)}
</Field>
<Field label="Passwort">
{(p) => (
<input {...p} name="passwort" type="password" required autoComplete="current-password" className={CONTROL_CLASS} />
)}
</Field>
<Button type="submit" fullWidth pending={laeuft} pendingLabel="Anmelden…">
Anmelden
</Button>
</form>
);
}

View File

@@ -1,13 +1,22 @@
import "server-only"; import "server-only";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { withUser } from "@/lib/db"; import { sql, withUser } from "@/lib/db";
import { currentUserId } from "./session"; import { currentUserId } from "./session";
// Route Handlers under /api/export/* are outside the App Router layout tree, // Route Handlers under /api/export/* and /api/import are outside the App
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to // Router layout tree, so app/(app)/layout.tsx's HR gate never runs for them —
// re-establish that the caller is an active HR user itself. RLS is still the // each one has to re-establish that the caller is an active HR user itself.
// real boundary (an unauthorized session simply reads nothing); this exists // RLS is still the real boundary (an unauthorized session simply reads
// so those routes answer 401/403 instead of handing back an empty workbook. // nothing); this exists so those routes answer 401/403 instead of handing back
// an empty workbook.
//
// Gefragt wird is_hr_user() und nicht mehr profiles.role/is_active von Hand.
// Der Unterschied ist nicht kosmetisch: is_hr_user() ist dieselbe Funktion, die
// alle 25 RLS-Policies aufrufen. Was immer sie künftig zusätzlich prüft, gilt
// hier automatisch mit — beim ausstehenden Passwortwechsel ist genau das schon
// passiert (Migration 20260908120000). Die Handfassung hätte davon nichts
// gewusst und einen Export ausgeliefert, den die Policies darunter leer
// gelassen hätten.
export type HrGate = { denied: NextResponse } | { userId: string }; export type HrGate = { denied: NextResponse } | { userId: string };
@@ -15,12 +24,11 @@ export async function requireHrUser(): Promise<HrGate> {
const userId = await currentUserId(); const userId = await currentUserId();
if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) }; if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) };
const profile = await withUser(userId, (tx) => const erlaubt = await withUser(userId, async (tx) => {
tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst() const ergebnis = await sql<{ ok: boolean }>`select is_hr_user() as ok`.execute(tx);
); return ergebnis.rows[0]?.ok === true;
});
if (profile?.role !== "hr" || profile.is_active !== true) { if (!erlaubt) return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
}
return { userId }; return { userId };
} }

46
lib/passwort.ts Normal file
View File

@@ -0,0 +1,46 @@
// Die Passwortregeln — noch einmal, für die Oberfläche.
//
// Verbindlich ist app_passwort_regeln() in der Datenbank
// (20260908120000). Hier stehen dieselben Regeln ein zweites Mal, damit die
// Maske sie beim Tippen anzeigen kann statt erst nach dem Absenden. Dieselbe
// Doppelung wie in lib/history.ts, aus demselben Grund und mit derselben
// Rangfolge: läuft eine Seite der anderen davon, gewinnt die Datenbank — sie
// weist ab, und die Meldung von dort wird angezeigt.
/** Was in der Maske als Hinweis steht. Reihenfolge wie in der SQL-Funktion. */
export const PASSWORT_REGELN = [
"mindestens 12 Zeichen",
"mindestens ein Grossbuchstabe",
"mindestens ein Kleinbuchstabe",
"mindestens eine Ziffer",
] as const;
/**
* Liefert die Beanstandung im Klartext oder null.
*
* Wortgleich mit app_passwort_regeln(). Ein Unterschied bleibt und ist
* unvermeidbar: `length()` in PostgreSQL zählt Zeichen, `String.length` in
* JavaScript zählt UTF-16-Einheiten. Bei einem Emoji im Passwort weichen die
* beiden um eins ab. Das betrifft nur die Vorabanzeige — abgewiesen oder
* angenommen wird in der Datenbank.
*/
export function passwortBeanstandung(passwort: string): string | null {
if (passwort.length < 12) return "Das Passwort muss mindestens 12 Zeichen lang sein.";
if (passwort.length > 72) return "Das Passwort darf höchstens 72 Zeichen lang sein.";
if (!/\p{Lu}/u.test(passwort)) return "Das Passwort muss mindestens einen Grossbuchstaben enthalten.";
if (!/\p{Ll}/u.test(passwort)) return "Das Passwort muss mindestens einen Kleinbuchstaben enthalten.";
if (!/\p{Nd}/u.test(passwort)) return "Das Passwort muss mindestens eine Ziffer enthalten.";
return null;
}
// Das gemeinsame Initialpasswort steht **nicht** in dieser Datei.
//
// Sie wird von den Anmelde- und Wechselformularen eingebunden, und die laufen
// im Browser. Jede Konstante hier landete damit im ausgelieferten Bündel — und
// das Initialpasswort wäre für jede Besucherin der offenen Anmeldeseite
// lesbar, ohne dass sie auch nur eine E-Mail-Adresse kennen müsste. Heute
// braucht ein Fremder beides.
//
// Es liegt deshalb in actions/benutzer.ts, also in einem Modul mit
// "use server", das nie in den Browser gelangt. Dort wird es auch nur
// gebraucht: beim Anlegen und beim Zurücksetzen.

View File

@@ -1,3 +1,4 @@
import { sql } from "kysely";
import type { Tx } from "./db"; import type { Tx } from "./db";
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json"; import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
import { todayIso } from "./format"; import { todayIso } from "./format";
@@ -30,14 +31,27 @@ export type ShellData = {
}; };
/** /**
* `null` heisst: angemeldet, aber nicht als HR freigeschaltet. * Drei Ausgänge, nicht zwei.
* *
* Seit es die Anmeldung mit Passwort gibt, ist „darf nicht hinein" nicht mehr
* dasselbe wie „hat keinen Zugang": wer sein Startpasswort noch nicht
* gewechselt hat, hat einen Zugang und kommt trotzdem an keine Zeile, weil
* is_hr_user() das mitprüft (Migration 20260908120000). Unterschieden werden
* muss es, weil die beiden Fälle verschiedene Auswege haben — der eine wartet
* auf HR, der andere ist in einer Minute erledigt.
*/
export type ShellErgebnis =
| { status: "kein_zugang" }
| { status: "passwort_wechseln" }
| { status: "ok"; daten: ShellData };
/**
* Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest * Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest
* ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber * ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber
* weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS, * weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS,
* nicht die Reihenfolge hier. * nicht die Reihenfolge hier.
*/ */
export async function loadShellData(tx: Tx, userId: string): Promise<ShellData | null> { export async function loadShellData(tx: Tx, userId: string): Promise<ShellErgebnis> {
const asOf = todayIso(); const asOf = todayIso();
const gelesen = await tx const gelesen = await tx
@@ -45,6 +59,10 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellData |
jsonObjectFrom( jsonObjectFrom(
eb.selectFrom("profiles").select(["full_name", "email", "role", "is_active"]).where("id", "=", userId) eb.selectFrom("profiles").select(["full_name", "email", "role", "is_active"]).where("id", "=", userId)
).as("profile"), ).as("profile"),
// Eine Spalte mehr in derselben Abfrage, keine zusätzliche Rundreise.
// Direkt aus app_passwoerter zu lesen ginge nicht: die Tabelle ist für
// die Anwendungsrolle gesperrt, an sie kommt nur diese Funktion.
sql<boolean>`app_muss_passwort_wechseln()`.as("passwortWechseln"),
...orgMapsAbfragen(eb), ...orgMapsAbfragen(eb),
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"), jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"), jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"),
@@ -60,17 +78,28 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellData |
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
const profile = gelesen.profile; const profile = gelesen.profile;
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
// Der Passwortwechsel steht **vor** der HR-Prüfung, obwohl er der seltenere
// Fall ist: er ist der einzige, den die betroffene Person selbst beheben
// kann. Wer beides hat — offener Wechsel und keine Freischaltung — bekommt
// danach immer noch „Kein HR-Zugriff", aber wenigstens in dieser Reihenfolge
// und nicht als Sackgasse.
if (gelesen.passwortWechseln) return { status: "passwort_wechseln" };
if (profile?.role !== "hr" || profile?.is_active !== true) return { status: "kein_zugang" };
const orgMaps = buildOrgMaps(gelesen.units as never, gelesen.locations as never); const orgMaps = buildOrgMaps(gelesen.units as never, gelesen.locations as never);
return { return {
profile, status: "ok",
// Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen daten: {
// offen sind — das lässt sich nicht in die erste ziehen. profile,
openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf), // Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen
locations: gelesen.locations as Location[], // offen sind — das lässt sich nicht in die erste ziehen.
drafts: gelesen.drafts as ShellData["drafts"], openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf),
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]), locations: gelesen.locations as Location[],
drafts: gelesen.drafts as ShellData["drafts"],
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
},
}; };
} }

View File

@@ -592,6 +592,10 @@ export type Database = {
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void }; delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string }; add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void }; complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
// Eigenes Passwort wechseln. Die einzige mutierende Funktion **ohne**
// require_hr_admin() — sonst käme niemand aus dem erzwungenen Wechsel
// heraus, denn solange er aussteht, ist is_hr_user() false.
app_passwort_aendern: { Args: { payload: Record<string, unknown> }; Returns: void };
// Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige // Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige
// Weg an der fehlenden delete-Policy auf employee_history vorbei. // Weg an der fehlenden delete-Policy auf employee_history vorbei.
delete_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void }; delete_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void };

View File

@@ -19,7 +19,11 @@ import {
// also auch hier. Der Riegel ist im Betrieb richtig; für den Test wird das // also auch hier. Der Riegel ist im Betrieb richtig; für den Test wird das
// Modul zu einer leeren Hülle. // Modul zu einer leeren Hülle.
vi.mock("server-only", () => ({})); vi.mock("server-only", () => ({}));
vi.mock("@/lib/db", () => ({ asSystem: vi.fn(), withUser: vi.fn() })); // `sql` gehört mit in die Attrappe, seit lib/auth/require-hr.ts es einbindet
// (es fragt is_hr_user() statt profiles von Hand). Der Pfad hier erreicht es
// nie — die Route antwortet mit 401, bevor eine Abfrage entsteht —, aber
// Vitest wirft beim blossen Zugriff auf einen nicht gestellten Export.
vi.mock("@/lib/db", () => ({ asSystem: vi.fn(), withUser: vi.fn(), sql: vi.fn() }));
vi.mock("@/lib/db/rpc", () => ({ callFunction: vi.fn() })); vi.mock("@/lib/db/rpc", () => ({ callFunction: vi.fn() }));
describe("sanitizeForSpreadsheetCell", () => { describe("sanitizeForSpreadsheetCell", () => {