diff --git a/actions/auth.ts b/actions/auth.ts
index 09f3ade..9ce36c3 100644
--- a/actions/auth.ts
+++ b/actions/auth.ts
@@ -1,20 +1,39 @@
"use server";
+import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
-export async function login(formData: FormData) {
- const email = String(formData.get("email") ?? "");
- const password = String(formData.get("password") ?? "");
+// Anmeldung ausschliesslich über Entra ID (in Supabase heisst der Anbieter
+// „Azure"). Es gibt bewusst keinen Passwort-Pfad mehr: ein zweiter Anmeldeweg
+// neben dem Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor,
+// bedingten Zugriff, Sperrung beim Austritt.
+//
+// Für die Datenbank ändert sich dadurch nichts. auth.uid() liefert weiterhin
+// eine UUID, profiles.id trägt weiterhin role und is_active, und damit bleiben
+// is_hr_user() und alle darauf gebauten RLS-Policies unverändert gültig.
+export async function signInWithEntra() {
const supabase = await createClient();
- const { error } = await supabase.auth.signInWithPassword({ email, password });
- if (error) {
- redirect("/login?error=invalid_credentials");
- }
+ // Die Herkunft kommt aus dem Request statt aus einer Umgebungsvariablen,
+ // damit lokal, Vorschau und Produktion denselben Code benutzen. Supabase
+ // nimmt das Ziel nur an, wenn es in der Redirect-Allowlist des Projekts
+ // steht — ein untergeschobener Host läuft also ins Leere.
+ const origin = (await headers()).get("origin") ?? "http://localhost:3000";
- redirect("/");
+ const { data, error } = await supabase.auth.signInWithOAuth({
+ provider: "azure",
+ options: {
+ // openid/profile/email sind das Minimum für Anmeldung und Anzeigename.
+ // Weitere Berechtigungen holt sich die Anwendung bewusst nicht.
+ scopes: "openid profile email",
+ redirectTo: `${origin}/auth/callback`,
+ },
+ });
+
+ if (error || !data.url) redirect("/login?error=sso_failed");
+ redirect(data.url);
}
export async function logout() {
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
index 58e84b7..189da87 100644
--- a/app/(auth)/login/page.tsx
+++ b/app/(auth)/login/page.tsx
@@ -1,14 +1,20 @@
-import { login, logout } from "@/actions/auth";
-import { Button } from "@/components/ui/Button";
-import { CONTROL_CLASS } from "@/components/ui/Field";
+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.
+// 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: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
- invalid_credentials: "E-Mail oder Passwort ist falsch.",
+ 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;
@@ -23,56 +29,105 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const error = code ? ERROR_MESSAGES[code] : null;
return (
-
-
-
Alpenwerk HR
-
Melden Sie sich mit Ihrem Firmenkonto an.
+ // dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte
+ // im ersten Moment unter dem Faltenrand sitzt.
+
+ {/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
+ drei Einheiten. */}
+
+ Alpenwerk HR
);
}
diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts
new file mode 100644
index 0000000..2772fd0
--- /dev/null
+++ b/app/auth/callback/route.ts
@@ -0,0 +1,30 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { createClient } from "@/lib/supabase/server";
+
+// Rückweg aus Entra ID. @supabase/ssr benutzt PKCE, das heisst der Anbieter
+// liefert einen einmaligen Code, der hier gegen eine Sitzung getauscht wird.
+// Ohne diese Route landet die Anmeldung in einer Schleife: der Code steht in
+// der URL, aber es entsteht nie ein Sitzungscookie, und der Proxy schickt
+// zurück auf /login.
+export async function GET(request: NextRequest) {
+ const { searchParams, origin } = request.nextUrl;
+
+ // Entra meldet abgelehnte Zustimmung oder gesperrte Konten als Fehler
+ // zurück. Der Text daraus wird nicht angezeigt — er ist fremdbestimmt und
+ // stünde sonst auf der echten, korrekt gebrandeten Anmeldeseite.
+ if (searchParams.get("error")) {
+ return NextResponse.redirect(`${origin}/login?error=sso_failed`);
+ }
+
+ const code = searchParams.get("code");
+ if (!code) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
+
+ const supabase = await createClient();
+ const { error } = await supabase.auth.exchangeCodeForSession(code);
+ if (error) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
+
+ // Ob die Person HR-Zugriff hat, entscheidet nicht diese Route, sondern
+ // proxy.ts anhand von profiles.role/is_active — und darunter, unabhängig
+ // davon, die RLS-Policies. Hier wird nur die Sitzung hergestellt.
+ return NextResponse.redirect(`${origin}/`);
+}
diff --git a/components/auth/EntraSignInButton.tsx b/components/auth/EntraSignInButton.tsx
new file mode 100644
index 0000000..a228256
--- /dev/null
+++ b/components/auth/EntraSignInButton.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { useFormStatus } from "react-dom";
+
+// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in
+// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene
+// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus
+// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine
+// fremde Anmeldeseite springt.
+export function EntraSignInButton() {
+ const { pending } = useFormStatus();
+
+ return (
+
+ );
+}
diff --git a/docs/entra-sso.md b/docs/entra-sso.md
new file mode 100644
index 0000000..f33960e
--- /dev/null
+++ b/docs/entra-sso.md
@@ -0,0 +1,117 @@
+# Anmeldung über Entra ID
+
+Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Supabase Auth
+bleibt dabei die Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz.
+
+**Das ist der Grund, warum der Umstieg klein ist:** `auth.uid()` liefert
+weiterhin eine UUID, `profiles.id` trägt weiterhin `role` und `is_active`, und
+damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
+Sicherheitsgrenze wandert nicht in den Anwendungscode.
+
+## Einrichtung im Entra-Mandanten
+
+App-Registrierung, einmalig — angelegt im Mandanten *loudspring management GmbH*:
+
+| | |
+|---|---|
+| Name | Alpenwerk HR |
+| Kontotypen | Nur ein Mandant |
+| Umleitungs-URI (Web) | `https://wcqzamhttnzhionftyoe.supabase.co/auth/v1/callback` |
+| Anwendungs-ID (Client) | `88037b8a-54f6-488f-8fcd-67d72b311a88` |
+| Verzeichnis-ID (Mandant) | `b002d91e-4569-4911-8756-3dcdc47152bb` |
+
+Die beiden IDs sind Kennungen, keine Geheimnisse — sie stehen deshalb hier.
+Der *Wert* des Client-Geheimnisses gehört ausschliesslich ins Supabase-Feld
+„Secret Value" und in keine Datei im Projekt.
+
+Die Umleitungs-URI ist **Supabases** Callback, nicht der der Anwendung. Der
+eigene Callback (`/auth/callback`) steht nur in der Redirect-Allowlist des
+Supabase-Projekts.
+
+Danach:
+
+1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
+ gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
+2. **API-Berechtigungen** → `openid`, `profile`, `email` (Microsoft Graph,
+ delegiert), Administratorzustimmung erteilen.
+3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
+
+## Einrichtung in Supabase
+
+Authentication → Providers → Azure:
+
+| Feld | Wert |
+|---|---|
+| Application (Client) ID | `88037b8a-54f6-488f-8fcd-67d72b311a88` |
+| Secret Value | der Wert aus „Zertifikate & Geheimnisse" |
+| Azure Tenant URL | `https://login.microsoftonline.com/b002d91e-4569-4911-8756-3dcdc47152bb` |
+
+Die Tenant URL ist bei „Nur ein Mandant" nicht optional. Bleibt sie leer,
+benutzt Supabase `common`, und Entra weist die Anmeldung ab, weil die
+Registrierung nur den eigenen Mandanten akzeptiert.
+
+Authentication → URL Configuration:
+
+- Site URL: die Produktions-URL
+- Redirect URLs: `http://localhost:3000/auth/callback` und
+ `https:///auth/callback`
+
+## Freischaltung über die Entra-Gruppe
+
+Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
+`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. Diese Zeile
+entsteht aus der Mitgliedschaft in einer Entra-Gruppe.
+
+### Woher der Gruppen-Anspruch kommt
+
+Entra schickt Gruppen nur mit, wenn es in der Tokenkonfiguration eingestellt
+ist. Zwei Varianten:
+
+| Variante | Lizenz | Haken |
+|---|---|---|
+| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
+| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
+
+### Warum die Auswertung aus `auth.identities` liest, nicht aus `auth.users`
+
+Das ist kein Detail, sondern der Kern der Absicherung.
+
+`auth.users.raw_user_meta_data` ist **von der angemeldeten Person selbst
+beschreibbar** — `supabase.auth.updateUser({ data: … })` schreibt genau dorthin.
+Läse die Freischaltung von dort, könnte sich jede:r Angemeldete den HR-Anspruch
+selbst eintragen und hätte damit Zugriff auf sämtliche Personaldaten.
+
+`auth.identities.identity_data` schreibt ausschliesslich GoTrue aus der Antwort
+des Anbieters. Nur das ist eine belastbare Quelle.
+
+### Reihenfolge
+
+Der Trigger wird erst gebaut, wenn feststeht, wie der Anspruch tatsächlich
+ankommt — das hängt an der gewählten Variante und an der Konfiguration des
+Mandanten. Ablauf:
+
+1. SSO in Betrieb nehmen, einmal anmelden.
+2. `node --env-file=.env.local supabase/entra-claims.ts ` zeigt, was in
+ `identity_data` gelandet ist.
+3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben.
+
+Ohne Schritt 2 wäre die Migration geraten.
+
+### Was die Gruppe nicht kann
+
+Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert
+den Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den
+sofortigen Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt
+beim nächsten Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
+
+## Bestehende Zugänge
+
+Ein bestehendes Konto mit Passwort-Anmeldung und ein Entra-Konto derselben
+Person sind für Supabase **zwei verschiedene Benutzer** mit verschiedenen IDs.
+Die `profiles`-Zeile hängt an der alten ID; nach der ersten Entra-Anmeldung
+zeigt sie ins Leere und die Person ist ausgesperrt.
+
+`supabase/relink-profile.ts` hängt sie um. Es überträgt auch die
+Fremdschlüssel, die auf die alte Benutzer-ID zeigen (`audit_log.actor_user_id`,
+`employee_notes.author_user_id`, …), sonst stünde in der Historie eine Kennung,
+zu der es kein Konto mehr gibt.
diff --git a/proxy.ts b/proxy.ts
index 2d2c1ef..e0335d1 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -38,6 +38,11 @@ export async function proxy(request: NextRequest) {
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
+ // Der Rückweg aus Entra muss durch, bevor es eine Sitzung gibt — dort wird
+ // sie ja erst hergestellt. Ohne diese Ausnahme leitet der Gate den Code
+ // nach /login um und die Anmeldung kommt nie zustande.
+ if (request.nextUrl.pathname.startsWith("/auth/callback")) return response;
+
if (!user) {
if (isLoginRoute) return response;
const url = request.nextUrl.clone();
diff --git a/supabase/entra-claims.ts b/supabase/entra-claims.ts
new file mode 100644
index 0000000..049712c
--- /dev/null
+++ b/supabase/entra-claims.ts
@@ -0,0 +1,70 @@
+// Zeigt, was Entra ID beim Anmelden tatsächlich mitgeschickt hat.
+//
+// Run with: node --env-file=.env.local supabase/entra-claims.ts
+//
+// Die Freischaltung über eine Entra-Gruppe hängt daran, wie der Anspruch im
+// Token heisst und wie er aussieht — das unterscheidet sich je nachdem, ob im
+// Mandanten „Sicherheitsgruppen" oder „der Anwendung zugewiesene Gruppen"
+// eingestellt ist. Diese Ausgabe ist die Grundlage für den Trigger; ohne sie
+// wäre er geraten.
+
+import { createClient } from "@supabase/supabase-js";
+
+const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
+const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
+if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
+ throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
+}
+
+const email = process.argv[2];
+if (!email) {
+ console.error("Aufruf: node --env-file=.env.local supabase/entra-claims.ts ");
+ process.exit(1);
+}
+
+const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
+ auth: { autoRefreshToken: false, persistSession: false },
+});
+
+const { data, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
+if (error) throw new Error(error.message);
+
+const matches = data.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
+if (matches.length === 0) {
+ console.error(`Kein Konto zu ${email}. Vorhanden:`);
+ for (const u of data.users) console.error(` ${u.email}`);
+ process.exit(1);
+}
+
+// Mehrere Treffer sind der Normalfall in der Umstellungsphase: das alte Konto
+// mit Passwort und das neue über Entra sind für Supabase zwei Benutzer.
+for (const user of matches) {
+ console.log(`\n── ${user.email} ──`);
+ console.log(` id: ${user.id}`);
+ console.log(` erstellt: ${user.created_at}`);
+ console.log(` Anbieter: ${user.identities?.map((i) => i.provider).join(", ") || "keiner"}`);
+
+ for (const identity of user.identities ?? []) {
+ console.log(`\n identity_data (${identity.provider}) — von GoTrue aus der Antwort des Anbieters:`);
+ console.log(
+ Object.entries(identity.identity_data ?? {})
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
+ .join("\n") || " (leer)"
+ );
+ }
+
+ // Zum Vergleich, und als Warnung: hierher schreibt auch updateUser(), also
+ // die angemeldete Person selbst. Als Grundlage für eine Freischaltung ist
+ // das unbrauchbar.
+ console.log("\n raw_user_meta_data — auch von der Person selbst beschreibbar, NICHT als Quelle verwenden:");
+ console.log(
+ Object.entries(user.user_metadata ?? {})
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
+ .join("\n") || " (leer)"
+ );
+}
+
+const { data: profiles } = await supabase.from("profiles").select("id, email, role, is_active").eq("email", email);
+console.log(`\n── profiles zu ${email} ──`);
+for (const p of profiles ?? []) console.log(` ${p.id} role=${p.role} is_active=${p.is_active}`);
+if (!profiles?.length) console.log(" (keine Zeile — damit besteht kein Zugriff)");
diff --git a/supabase/relink-profile.ts b/supabase/relink-profile.ts
new file mode 100644
index 0000000..f37d6b9
--- /dev/null
+++ b/supabase/relink-profile.ts
@@ -0,0 +1,110 @@
+// Hängt eine bestehende profiles-Zeile auf die Entra-Identität derselben
+// Person um.
+//
+// Run with: node --env-file=.env.local supabase/relink-profile.ts [--apply]
+//
+// Ein Konto mit Passwort-Anmeldung und das Entra-Konto derselben Person sind
+// für Supabase zwei Benutzer mit verschiedenen IDs. Die profiles-Zeile hängt an
+// der alten; nach der ersten Anmeldung über Entra zeigt sie ins Leere und die
+// Person ist ausgesperrt — mit „Kein HR-Zugriff", obwohl sie HR ist.
+//
+// Ohne --apply wird nur angezeigt, was passieren würde.
+
+import { createClient } from "@supabase/supabase-js";
+
+const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
+const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
+if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
+ throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
+}
+
+const email = process.argv[2];
+const apply = process.argv.includes("--apply");
+if (!email) {
+ console.error("Aufruf: node --env-file=.env.local supabase/relink-profile.ts [--apply]");
+ process.exit(1);
+}
+
+const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
+ auth: { autoRefreshToken: false, persistSession: false },
+});
+
+// Die Fremdschlüssel auf auth.users(id). Sie zeigen sonst weiter auf das alte
+// Konto, und in der Historie stünde eine Kennung ohne Konto dahinter.
+const REFERENCES: { table: string; column: string }[] = [
+ { table: "audit_log", column: "actor_user_id" },
+ { table: "employee_notes", column: "author_user_id" },
+ { table: "employee_notes", column: "done_by" },
+ { table: "hire_drafts", column: "created_by" },
+ { table: "saved_reports", column: "created_by" },
+ { table: "pending_org_changes", column: "created_by" },
+ { table: "profiles", column: "created_by" },
+];
+
+const { data: userList, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
+if (error) throw new Error(error.message);
+
+const accounts = userList.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
+const entra = accounts.find((u) => u.identities?.some((i) => i.provider === "azure"));
+const alt = accounts.find((u) => u.id !== entra?.id);
+
+if (!entra) {
+ console.error(`Kein Entra-Konto zu ${email}. Bitte zuerst einmal über „Mit Firmenkonto anmelden" anmelden.`);
+ process.exit(1);
+}
+if (!alt) {
+ console.log(`Zu ${email} gibt es nur das Entra-Konto (${entra.id}) — nichts umzuhängen.`);
+ process.exit(0);
+}
+
+const { data: profile } = await supabase.from("profiles").select("*").eq("id", alt.id).maybeSingle();
+if (!profile) {
+ console.error(`Das alte Konto ${alt.id} hat keine profiles-Zeile. Nichts umzuhängen.`);
+ process.exit(1);
+}
+
+console.log(`alt: ${alt.id} (${alt.identities?.map((i) => i.provider).join(", ")})`);
+console.log(`neu: ${entra.id} (azure)`);
+console.log(`Rolle: ${profile.role}, aktiv: ${profile.is_active}`);
+
+if (!apply) {
+ console.log("\nTrockenlauf. Mit --apply ausführen.");
+ process.exit(0);
+}
+
+// Neue Zeile zuerst: profiles.id verweist auf auth.users(id), und die alte
+// Zeile fällt erst, wenn die neue steht — sonst gibt es einen Moment ohne
+// HR-Konto, und niemand könnte eines mehr freischalten.
+const { error: insertError } = await supabase.from("profiles").insert({
+ ...profile,
+ id: entra.id,
+ email: entra.email ?? profile.email,
+});
+if (insertError) throw new Error(`profiles-Zeile anlegen fehlgeschlagen: ${insertError.message}`);
+console.log("profiles-Zeile für das Entra-Konto angelegt.");
+
+for (const ref of REFERENCES) {
+ const { error: updateError, count } = await supabase
+ .from(ref.table)
+ .update({ [ref.column]: entra.id }, { count: "exact" })
+ .eq(ref.column, alt.id);
+ if (updateError) {
+ console.warn(` ${ref.table}.${ref.column}: ${updateError.message}`);
+ continue;
+ }
+ console.log(` ${ref.table}.${ref.column}: ${count ?? 0} Zeile(n) umgehängt`);
+}
+
+const { error: deleteProfileError } = await supabase.from("profiles").delete().eq("id", alt.id);
+if (deleteProfileError) throw new Error(`alte profiles-Zeile löschen fehlgeschlagen: ${deleteProfileError.message}`);
+
+const { error: deleteUserError } = await supabase.auth.admin.deleteUser(alt.id);
+if (deleteUserError) {
+ // Kein Abbruch: der Zugriff hängt an profiles, und die ist bereits
+ // umgehängt. Das alte Konto ist damit wirkungslos, nur nicht aufgeräumt.
+ console.warn(`Altes Konto konnte nicht gelöscht werden: ${deleteUserError.message}`);
+} else {
+ console.log("Altes Konto gelöscht.");
+}
+
+console.log("\nFertig. Die Anmeldung läuft jetzt über das Firmenkonto.");