Files
alpenwerk-hr/app/(auth)/login/page.tsx
Andrei Laas c6cff9656e
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m3s
Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
2026-09-08 16:51:14 +02:00

168 lines
7.4 KiB
TypeScript

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
// looked up by code rather than whatever text ?error= carries. Reflecting the
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt
// für die Fehlertexte, die Entra im Rückweg mitschickt.
const ERROR_MESSAGES = {
no_hr_access: {
title: "Kein HR-Zugriff",
body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
},
sso_failed: {
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;
type LoginPageProps = {
searchParams: Promise<{ error?: string }>;
};
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
// Ein unbekannter Code wird nicht verschluckt, sondern auf die allgemeine
// Meldung abgebildet: Auth.js schickt bei einem Fehlschlag seine eigenen
// Codes („Configuration", „AccessDenied", „OAuthCallbackError" …), und ohne
// diese Abbildung stünde man vor einer Anmeldeseite, die so tut, als wäre
// nichts gewesen. Angezeigt wird trotzdem nur eigener Text — der Parameter
// selbst kommt nie auf die Seite.
const code = params.error
? Object.hasOwn(ERROR_MESSAGES, params.error)
? (params.error as ErrorCode)
: "sso_failed"
: null;
const error = code ? ERROR_MESSAGES[code] : null;
return (
// dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte
// im ersten Moment unter dem Faltenrand sitzt.
<div className="min-h-dvh lg:grid lg:grid-cols-[1.05fr_1fr]">
<BrandPanel />
<main className="flex items-center justify-center px-6 py-12 lg:py-6">
<div className="w-full max-w-sm">
<div className="lg:hidden">
<Wordmark className="text-ink" />
</div>
<h2 className="mt-8 text-2xl font-extrabold tracking-tight text-ink lg:mt-0">Anmelden</h2>
<p className="mt-1.5 text-sm text-ink-muted">
Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht.
</p>
{error && (
<div role="alert" className="mt-6 rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3">
<p className="text-sm font-bold text-danger-text">{error.title}</p>
<p className="mt-1 text-sm text-danger-text/90">{error.body}</p>
{code === "no_hr_access" && (
<form action={logout} className="mt-3">
<button
type="submit"
className="rounded text-xs font-semibold text-danger-text underline underline-offset-2 hover:no-underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger-text"
>
Abmelden und mit einem anderen Konto versuchen
</button>
</form>
)}
</div>
)}
<form action={signInWithEntra} className="mt-7">
<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.
</p>
</div>
</main>
</div>
);
}
function BrandPanel() {
return (
<aside className="relative hidden overflow-hidden bg-brand-700 lg:flex lg:flex-col lg:justify-between lg:p-12">
{/* Zwei weiche Lichtpunkte und ein feines Raster — genug Struktur, damit
die Fläche nicht wie ein Farbfehler wirkt, aber ohne Bilddatei und
ohne von der einen Schaltfläche gegenüber abzulenken. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
"radial-gradient(60rem 40rem at 15% 0%, rgb(255 255 255 / 0.16), transparent 60%)," +
"radial-gradient(40rem 40rem at 100% 100%, rgb(255 255 255 / 0.10), transparent 55%)," +
"linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px)," +
"linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px)",
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
}}
/>
<div className="relative">
<Wordmark className="text-white" />
</div>
<div className="relative max-w-md">
<p className="text-3xl font-extrabold leading-tight tracking-tight text-white">
Die Organisation, so wie sie heute wirklich aussieht.
</p>
<p className="mt-4 text-sm leading-relaxed text-white/70">
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
Stichtag.
</p>
</div>
<p className="relative text-xs text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
</aside>
);
}
function Wordmark({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center gap-2.5 ${className}`}>
{/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
drei Einheiten. */}
<svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true">
<rect x="9" y="2" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="1" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
<rect x="9" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="17" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
<path d="M12 8v4M4 16v-4h16v4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
</svg>
<span className="text-lg font-extrabold tracking-tight">Alpenwerk HR</span>
</div>
);
}