**Das Logo auf der Anmeldeseite.** Statt des Schriftzugs stand dort der Ersatztext "Manner". Die SVG-Fassung war gueltiges XML, lag im Repository und war committet — warum sie im Betrieb nicht geladen wurde, laesst sich von hier aus nicht feststellen; dafuer braucht es die Antwort des Servers auf die URL. Statt das weiter zu raten, faellt die Angriffsflaeche weg: es gibt jetzt eine Datei, manner-logo.png, der Schriftzug mit durchsichtigem Grund. Kein XML, kein Beschnitt, kein eingebackenes Feld. Das Blau darin ist #164194, also der Wert aus §1.1. Damit verschwinden auch die zwei Fassungen. §1.2 laesst den Schriftzug nur zur Gaenze auf Rosa zu, und das Manual kennt dafuer zwei Lagen: auf Weiss gehoert er nach §4.1 in ein rechteckiges rosa Feld, in einem grossflaechigen rosa Umfeld nach §3.1 nur mit Freiraum. Beides entsteht jetzt aus derselben Datei — das Feld zeichnet das Bauteil, aus derselben Polsterung wie den Freiraum. **Der Filter "Geplant" fand Ausgetretene.** Die Ableitung pruefte den Eintritt vor dem Austritt, und wer einen Eintritt in der Zukunft hatte, galt als geplant — auch wenn der Austritt laengst verbucht war. Das trifft genau den No-Show (Migration 20260814100000): eingestellt, nie erschienen, Austritt vor dem Eintrittstag. Im Bestand sind das Zeilen mit Eintritt 01.10.2026, die der Filter mitzaehlte, waehrend die Liste daneben "Ausgetreten" anzeigte. employees.status, das die SQL-Funktion beim Austritt setzt, sagte von Anfang an das Richtige; falsch war die Ableitung in der Anwendung. Ein abgeschlossener Austritt wird jetzt zuerst geprueft: er beendet das Verhaeltnis, gleichgueltig ob der Eintritt schon war oder noch kommt. Ein Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht zurueck — wer am 01.10. anfaengt und am 31.12. aufhoert, ist heute geplant. Die SQL-Fassung in lib/employee-status-filter.ts bildet dieselbe Reihenfolge ab. Keine Migration: beide Fassungen der Regel liegen in TypeScript. In SQL wird nur der Karenz-Teil wiederholt, fuer die Fuehrungslinie, und der ist nicht betroffen. **Die vier Anstehend-Chips.** Zwei davon standen in der Markenfarbe, weil die Farbe ueber den Beschriftungstext aus der Tabelle der Protokoll-Aktionen geholt wurde — und die kennt eine andere Sprache: "Neueinstellung", nicht "Eintritt". Wer dort nicht steht, bekam den neutralen Chip. Ein Nachschlagen, das bei einem Fehlschlag still etwas Plausibles liefert, faellt eben nicht auf. Die vier haben jetzt eine eigene Zuordnung, nach dem Wert verschluesselt und nicht nach der Beschriftung: Eintritt gruen, Austritt rot, Wiedervorlage gelb, Rueckkehr violett. Tuerkis waere fuer die Rueckkehr die naheliegendere Lesart gewesen, kam gegen das Gruen des Eintritts aber nur auf dE 13.0; Violett steht mit 30.8 eindeutig daneben. Schwaechstes Paar der vier: 14.2, schwaechster Kontrast 5.49:1. Zehn Tests dazu, darunter die drei Faelle, an denen der Filter gescheitert war. Lint, Typen, Schemaabgleich, 562 Tests und der Build sind sauber. Im Browser nicht gesehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
8.2 KiB
TypeScript
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">
|
|
Die Organisation, so wie sie heute wirklich aussieht.
|
|
</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).
|