Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
This commit is contained in:
@@ -13,15 +13,24 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||
// und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie
|
||||
// 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
|
||||
// ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat
|
||||
// keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
|
||||
// Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat keine
|
||||
// Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
|
||||
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
|
||||
// Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer
|
||||
// wortlosen Anmeldeseite und versuchte es endlos erneut.
|
||||
if (!data) redirect("/login?error=no_hr_access");
|
||||
// Sitzungstoken.
|
||||
//
|
||||
// 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 || "";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
|
||||
import { PasswortFormular } from "@/components/auth/PasswortFormular";
|
||||
import { logout, signInWithEntra } from "@/actions/auth";
|
||||
|
||||
// The query string is attacker-controlled, so the login page renders a message
|
||||
@@ -15,6 +16,15 @@ const ERROR_MESSAGES = {
|
||||
title: "Anmeldung fehlgeschlagen",
|
||||
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;
|
||||
|
||||
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
||||
@@ -77,6 +87,20 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
<EntraSignInButton />
|
||||
</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">
|
||||
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user