Files
alpenwerk-hr/app/(auth)/login/page.tsx
Andrei Laas 3664b9a435
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m48s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Neuer Slogan, und Hay-Grades stehen als Leiter statt nach Haeufigkeit
Auf der Anmeldeseite steht jetzt "Alles im Blick. Alles Manner." statt des
bisherigen Satzes.

Die Berichte sortieren Gruppen nach der Kennzahl, gross zuerst. Bei Bereich
oder Standort ist das die Antwort auf die Frage, die der Bericht stellt. Bei
einer Leiter ist es keine: HG09 vor HG10 ist die Reihenfolge, in der die Werte
sind, und nach Haeufigkeit umgestellt liest sie sich als Zufall.

Die Sonderbehandlung, die es fuer Wochentage schon gab, ist dafuer zu einer
Liste "Dimensionen mit eigener Reihenfolge" verallgemeinert -- Wochentage und
Hay-Grades stehen darin, und die naechste Leiter ist ein Eintrag statt einer
dritten Verzweigung. Gilt damit auch fuer die Aufschluesselung innerhalb einer
Gruppe und fuer die Spalten im Berichtsexport, die dieselbe Funktion nutzen.

Der Generic Grade steht vor HG09: er ist keine Stufe, sondern ihr Fehlen, und
vor der niedrigsten faellt das am wenigsten als Aussage auf.
2026-09-29 18:26:38 +02:00

176 lines
8.2 KiB
TypeScript

import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
import { PasswortFormular } from "@/components/auth/PasswortFormular";
import { AppWortmarke } from "@/components/brand/Logo";
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">
{/* Ohne Versatz: hier steht das Logo auf Weiss und bekommt nach §4.1
sein rosa Feld, dessen Kante mit der Ueberschrift darunter
fluchten soll. */}
<div className="lg:hidden">
<AppWortmarke hoehe={26} textKlasse="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 (
// Rosa und nicht Blau: Rosa ist die Farbe, in der die Marke auftritt, und
// die Flaeche ist das Groesste, was diese Seite zu vergeben hat. Blau war
// hier doppelt falsch — es drehte das Verhaeltnis der beiden Markenfarben
// um, und das Logo bringt sein eigenes rosa Feld mit (§1.2), das auf Blau
// wie ein aufgeklebtes Rechteck stand. Auf Rosa geht es in die Flaeche
// ueber, und genau das meint §3.1 mit „in einem grossflaechigen rosa
// Umfeld eingebettet".
<aside className="relative hidden overflow-hidden bg-accent-500 lg:flex lg:flex-col lg:justify-between lg:p-12">
{/* Ein feines Raster und eine leichte Tiefe zur unteren Ecke — genug
Struktur, damit die Fläche nicht wie ein Farbfehler wirkt, aber ohne
Bilddatei und ohne von der einen Schaltfläche gegenüber abzulenken.
Beides aus ink: Weiss verschwindet auf Rosa, und die Lichtblende, die
hier vorher lag, hellte die Markenfarbe so weit auf, dass sie kaum
noch die Markenfarbe war. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
"radial-gradient(45rem 45rem at 100% 100%, rgb(35 31 32 / 0.08), transparent 60%)," +
"linear-gradient(rgb(35 31 32 / 0.04) 1px, transparent 1px)," +
"linear-gradient(90deg, rgb(35 31 32 / 0.04) 1px, transparent 1px)",
backgroundSize: "auto, 3rem 3rem, 3rem 3rem",
}}
/>
{/* -ml-3 gleicht den Freiraum des Logos aus (hoehe 36 / 3 = 12 px),
damit der Schriftzug buendig mit dem Text darunter beginnt. */}
{/* Alles Geschriebene hier steht in brand-700. Weiss faellt auf Manner
Rosa mit 2.18:1 aus, und ink traegt zwar (7.47:1), sieht aber aus wie
Text, der aus Versehen auf der Marke gelandet ist. Dunkelblau auf
Rosa (6.60:1) ist dieselbe Paarung, aus der der Schriftzug selbst
besteht. Die Abstufung macht Groesse und Gewicht, nicht Transparenz:
eine aufgehellte Schrift waere genau das, was hier durchfaellt. */}
<div className="relative -ml-3">
<AppWortmarke hoehe={36} aufRosa textKlasse="text-brand-700" />
</div>
<div className="relative max-w-md">
<p className="text-3xl font-extrabold leading-tight tracking-tight text-brand-700">
Alles im Blick. Alles Manner.
</p>
<p className="mt-4 text-sm leading-relaxed text-brand-700">
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
Stichtag.
</p>
</div>
<p className="relative text-xs font-semibold text-brand-700">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
</aside>
);
}
// Die vier Quadrate, die hier als Platzhalter-Bildmarke standen, sind
// entfallen: an ihrer Stelle steht jetzt das Manner-Logo
// (components/brand/Logo.tsx).