Sign in with Entra ID, and give the login screen something to look at
Die Anmeldung läuft über das Firmenkonto. Supabase Auth bleibt dabei die
Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. Genau deshalb
ist der Eingriff klein: 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.
Der Passwort-Pfad ist weg, nicht deaktiviert. Ein zweiter Anmeldeweg neben dem
Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, bedingten
Zugriff, Sperrung beim Austritt.
Dazu die Rückweg-Route /auth/callback, die den PKCE-Code gegen eine Sitzung
tauscht, und eine Ausnahme im Proxy: ohne sie leitet der Gate den Code nach
/login um, weil es die Sitzung ja erst danach gibt, und die Anmeldung kommt
nie zustande. Ob jemand HR-Zugriff hat, entscheidet weiterhin nicht die Route,
sondern profiles.role/is_active und darunter die Policies.
Zwei Werkzeuge für die Umstellung:
- relink-profile.ts hängt eine bestehende profiles-Zeile auf die
Entra-Identität um. Ein Passwort-Konto und das Entra-Konto derselben
Person sind für Supabase zwei Benutzer mit verschiedenen IDs; ohne das
zeigt die profiles-Zeile nach der ersten SSO-Anmeldung ins Leere und man
sperrt sich aus. Die Fremdschlüssel auf auth.users wandern mit, sonst
stünde in der Historie eine Kennung ohne Konto dahinter.
- entra-claims.ts zeigt, was der Anbieter tatsächlich mitgeschickt hat.
Die geplante Freischaltung über eine Entra-Gruppe hängt daran, wie der
Anspruch heisst und aussieht, und das unterscheidet sich je nach
Tokenkonfiguration des Mandanten. Der Trigger wird erst danach gebaut,
sonst wäre er geraten.
Beim Auswerten der Gruppe später gilt: die Quelle ist auth.identities.
identity_data, nie raw_user_meta_data. Letzteres beschreibt die angemeldete
Person über updateUser() selbst — läse die Freischaltung von dort, könnte sich
jede:r Angemeldete HR-Rechte eintragen. Steht so in docs/entra-sso.md.
Die Anmeldeseite war eine Box im leeren Rosa. Jetzt zweispaltig: links eine
Markenfläche, rechts die Anmeldung; unter 1024px fällt die Fläche weg und die
Wortmarke rückt über die Karte. Die Microsoft-Schaltfläche ist bewusst nicht
mehr in der Hausfarbe — magenta las sich als Aktion *innerhalb* dieser
Anwendung, während sie auf eine fremde Anmeldeseite springt. Weiss mit
grauem Rand ist Microsofts eigene Vorgabe und das Muster, das man
wiedererkennt. Dazu ein Wartezustand für den Sprung und eine Fehlermeldung,
die erklärt, was zu tun ist, statt nur "Kein HR-Zugriff" zu behaupten.
Nachgemessen im laufenden Server statt geschätzt: 656/624 auf 1280px,
Markenfläche in brand-700, Schaltfläche 45px hoch, kein Querlauf auf 375px.
Typecheck, Lint, Build und 182 Tests sind grün.
This commit is contained in:
110
supabase/relink-profile.ts
Normal file
110
supabase/relink-profile.ts
Normal file
@@ -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 <e-mail> [--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 <e-mail> [--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.");
|
||||
Reference in New Issue
Block a user