Files
alpenwerk-hr/app/(auth)/login/page.tsx
Andrei Laas d2d5e1dabb
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m2s
Drei Befunde aus dem Test: Logo, Geplant-Filter, Anstehend-Farben
**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>
2026-09-15 20:42:42 +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">
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).