168 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|