Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
This commit is contained in:
@@ -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
61
actions/passwort.ts
Normal 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("/");
|
||||||
|
}
|
||||||
@@ -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 || "";
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
57
app/passwort-aendern/page.tsx
Normal file
57
app/passwort-aendern/page.tsx
Normal 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
75
auth.ts
@@ -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
|
||||||
|
|||||||
100
components/auth/PasswortAendernFormular.tsx
Normal file
100
components/auth/PasswortAendernFormular.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
components/auth/PasswortFormular.tsx
Normal file
54
components/auth/PasswortFormular.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
46
lib/passwort.ts
Normal 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.
|
||||||
@@ -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,11 +78,21 @@ 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 {
|
||||||
|
status: "ok",
|
||||||
|
daten: {
|
||||||
profile,
|
profile,
|
||||||
// Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen
|
// Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen
|
||||||
// offen sind — das lässt sich nicht in die erste ziehen.
|
// offen sind — das lässt sich nicht in die erste ziehen.
|
||||||
@@ -72,5 +100,6 @@ export async function loadShellData(tx: Tx, userId: string): Promise<ShellData |
|
|||||||
locations: gelesen.locations as Location[],
|
locations: gelesen.locations as Location[],
|
||||||
drafts: gelesen.drafts as ShellData["drafts"],
|
drafts: gelesen.drafts as ShellData["drafts"],
|
||||||
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
|
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user