Die Flaechenverteilung war verkehrt herum. Blau lag auf dem Groessten, was die Anmeldeseite zu vergeben hat, und Rosa nur in Andeutungen daneben — bei einer Marke, deren Schriftzug auf Rosa steht und deren Manual Rosa zuerst nennt. Sichtbar wurde es am Logo. Es bringt sein rosa Feld selbst mit, weil §1.2 den Schriftzug nur zur Gaenze auf Rosa zulaesst. Auf einer blauen Flaeche steht dieses Feld als ausgeschnittenes Rechteck darauf. Auf Weiss ist es dagegen richtig — §4.1 verlangt fuer weissen Untergrund genau das, ein rechteckiges rosa Feld. Falsch war also nicht das Logo, sondern die blaue Flaeche darunter. Rosa bekommen jetzt: das Panel der Anmeldeseite und das Band ueber der Anwendung aus Kopfzeile und Logokopf der Seitenleiste. Beide zusammen sind ein durchgehender Streifen, in dem das Logo aufgeht statt darauf zu liegen — was §3.1 mit "in einem grossflaechigen rosa Umfeld eingebettet" meint. Blau bleibt, was es ist: Knopf, Link, Fokusring, ausgewaehlter Eintrag. Das ist dasselbe Verhaeltnis, aus dem der Schriftzug besteht — blaue Lettern auf rosa Feld — und es ist das einzige, das traegt. Weiss auf Rosa sind 2.18:1. Auf den rosa Flaechen steht deshalb nichts Weisses und nichts Gedaempftes mehr. ink kommt auf 7.47:1, brand-700 auf 6.60:1; ink-muted lag bei 2.33:1 und ist aus Kopfzeile, Glocke und Logokopf verschwunden. Gestuft wird ueber Groesse und Gewicht statt ueber Transparenz — eine aufgehellte Schrift ist genau das, was auf dieser Flaeche durchfaellt. Das Raster auf dem Panel ist von Weiss auf ink gewechselt: auf Rosa war es nicht mehr zu sehen. Lint, Typen, Schemaabgleich, 552 Tests und der Build sind sauber. Im Browser nicht gesehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
8.0 KiB
TypeScript
173 lines
8.0 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">
|
|
<div className="-ml-2 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 weicher Lichtpunkt 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. Das Raster ist
|
|
jetzt aus ink statt aus Weiss: auf Rosa verschwindet Weiss. */}
|
|
<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.26), transparent 60%)," +
|
|
"radial-gradient(40rem 40rem at 100% 100%, rgb(35 31 32 / 0.07), transparent 55%)," +
|
|
"linear-gradient(rgb(35 31 32 / 0.045) 1px, transparent 1px)," +
|
|
"linear-gradient(90deg, rgb(35 31 32 / 0.045) 1px, transparent 1px)",
|
|
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
|
|
}}
|
|
/>
|
|
|
|
{/* -ml-4 gleicht den Freiraum des Logos aus, damit die Wortmarke
|
|
buendig mit dem Text darunter beginnt und nicht eingerueckt wirkt. */}
|
|
{/* Kein Weiss mehr auf dieser Flaeche: Weiss auf Manner Rosa sind
|
|
2.18:1 und damit unlesbar, in jeder Groesse. Getragen wird der Text
|
|
von ink (7.47:1) und brand-700 (6.60:1) — und die Ueberschrift in
|
|
Blau auf Rosa ist dieselbe Paarung, aus der der Schriftzug selbst
|
|
besteht. Die Abstufung macht ab jetzt Groesse und Gewicht, nicht
|
|
Transparenz: eine aufgehellte Schrift waere genau das, was hier
|
|
durchfaellt. */}
|
|
<div className="relative -ml-4">
|
|
<AppWortmarke hoehe={36} textKlasse="text-brand-700" />
|
|
</div>
|
|
|
|
<div className="relative max-w-md">
|
|
<p className="text-3xl font-extrabold leading-tight tracking-tight text-brand-700">
|
|
Die Organisation, so wie sie heute wirklich aussieht.
|
|
</p>
|
|
<p className="mt-4 text-sm leading-relaxed text-ink">
|
|
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
|
|
Stichtag.
|
|
</p>
|
|
</div>
|
|
|
|
<p className="relative text-xs font-semibold text-ink">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).
|