Auth.js replaces GoTrue. The sign-in still goes to the same Entra tenant,
but nothing sits between the app and the identity provider any more — the
code exchange, state, nonce and the session cookie are ours.
lib/auth/session.ts stays the only place that knows where a user id comes
from, which is why this was one file and not fifty. What it returns is now
app_users.id. app_upsert_user() maps the Entra `oid` onto it, and for an
address that already has a profiles row it adopts that id instead of
minting a new one — otherwise everyone would have been signed in and cut
off from their own notes, drafts and audit trail at the same time.
That upsert is the one write that cannot have a session context yet: the
id is what it produces. It runs as a SECURITY DEFINER function that may
touch app_users and nothing else, which is a far smaller lever than the
service key that used to answer this class of problem.
The proxy no longer checks HR rights. It has no database connection, and
putting role/is_active in the token would have frozen the claim until the
next sign-in. The check moved to where it can read the current truth: the
app layout on every render, requireHrUser() for the export routes, and
underneath both, RLS.
Two things only came out by running it:
- `export const proxy = auth(…)` is not a function declaration, so
Next.js never found it and every request 404'd. `next build` reported
success and listed the proxy. In the function config form auth() also
returns the handler as a promise, so it needs an await. The proxy test
now mocks it as a promise for that reason — a friendlier mock would
let the same bug back in.
- A missing AUTH_MICROSOFT_ENTRA_ID_ISSUER silently falls back to
/common/, and the redirect really did go there. That would let any
Microsoft account sign in, including a private one, and it would never
look broken. It now refuses to start in production.
Neither build nor image needs credentials any more: the pool is created on
first use, the auth config is evaluated per request, and there are no
NEXT_PUBLIC_* values left to bake in. One image now runs in every
environment.
Verified: typecheck, lint, 187 tests, build, and by hand in the browser —
/employees redirects to /login, and the sign-in button reaches the Entra
page with PKCE and the callback URL that goes into the app registration.
Not verified against a real database; there is still no DATABASE_URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
6.2 KiB
TypeScript
144 lines
6.2 KiB
TypeScript
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
|
|
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.",
|
|
},
|
|
} 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>
|
|
|
|
<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>
|
|
);
|
|
}
|