The app runs against a real database for the first time since the port, and two things were broken. Both were invisible to typecheck, lint, 192 tests and the build. Sign-in looped. Auth.js created the session and app_upsert_user() adopted the existing profiles id correctly — the row was in app_users, right id and all — but the proxy builds its own Auth.js instance from lib/auth/config.ts alone, and the session callback that copies token.uid onto session.user.id lived in auth.ts. So the proxy saw a session without an id, treated every signed-in user as signed out, and sent them back to /login. Click, flash, login page: from the outside it looked like the button did nothing. The callback moves to the config both instances share. auth.ts now spreads the base callbacks instead of replacing them, which is the mistake that would reintroduce this. The proxy test did not catch it because its fixture hands the handler a session that already has user.id — it tested the routing, not the shape Auth.js actually produces. Then the dashboard crashed on a.date.localeCompare. PostgREST returned JSON: a `date` arrived as "2026-08-03", a `numeric` as a number, and that is what lib/supabase/types.ts declares and what every sort, every date comparison and every status derivation assumes. The pg driver does the opposite — Date object and string respectively. The declarations stayed true to what the code believes; only the runtime value changed, which is why nothing flagged it. The driver is configured back to the declared shapes in lib/db/pool.ts, rather than rewriting 49 call sites. That also removes a timezone hazard: `date` is a calendar day, and as a Date object it acquires midnight in the server's zone — a birth date would shift by a day in Austria, always. The same class of bug as in the seed. int8 stays a string on purpose: it only comes from count() and is read through Number() everywhere; parsed as a number it would quietly lose precision past 2^53. A missing sign-in error now reaches the server log. Auth.js was failing silently — a 302 back to /login and nothing to read. That was its own defect, and it is the reason the first diagnosis took as long as it did. Verified against the live database: all six pages render, 797 active of 852 records, 744.4 FTE, and a detail page shows birth date 15.08.1968 against SV number 7960 150868 — the digits agree, so no day has shifted. Both new tests were checked by mutation: remove the fix and they fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
66 lines
2.8 KiB
TypeScript
66 lines
2.8 KiB
TypeScript
import "server-only";
|
|
import NextAuth from "next-auth";
|
|
import { authConfig } from "@/lib/auth/config";
|
|
import { asSystem, sql } from "@/lib/db";
|
|
|
|
// Die vollständige Anmeldung — die Fassung, die die Datenbank kennt.
|
|
//
|
|
// Aufgeteilt ist sie, weil proxy.ts nur den Teil aus lib/auth/config.ts lädt.
|
|
// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der
|
|
// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung
|
|
// versteht.
|
|
|
|
/**
|
|
* Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung,
|
|
* die überall sonst als `userId` durchgereicht wird.
|
|
*
|
|
* Die Datenbankfunktion ist SECURITY DEFINER und darf genau dieses eine:
|
|
* app_users schreiben. Für den einen Schreibvorgang, für den es noch keinen
|
|
* Sitzungskontext geben kann, ist das der kleinstmögliche Hebel — früher lag
|
|
* hier ein Dienstschlüssel, der jede Zeile jeder Tabelle lesen konnte.
|
|
*/
|
|
async function upsertAppUser(externalId: string, email: string, fullName: string | null): Promise<string> {
|
|
const row = await asSystem(async (tx) => {
|
|
const result = await sql<{ id: string }>`
|
|
select app_upsert_user(${externalId}, ${email}, ${fullName}) as id
|
|
`.execute(tx);
|
|
return result.rows[0];
|
|
});
|
|
|
|
if (!row?.id) throw new Error("app_upsert_user() lieferte keine Kennung.");
|
|
return row.id;
|
|
}
|
|
|
|
export const { handlers, auth, signIn, signOut } = NextAuth(() => {
|
|
const base = authConfig();
|
|
return {
|
|
...base,
|
|
callbacks: {
|
|
// Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt
|
|
// session(), das die Kennung aus dem Token auf die Sitzung legt. Ein
|
|
// schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt.
|
|
...base.callbacks,
|
|
async jwt({ token, profile }) {
|
|
// `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra
|
|
// vor. Danach wird das Token nur noch weitergereicht — die Datenbank
|
|
// wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf.
|
|
if (!profile) return token;
|
|
|
|
const externalId = typeof profile.oid === "string" ? profile.oid : null;
|
|
const email = [profile.email, profile.preferred_username, profile.upn].find(
|
|
(v): v is string => typeof v === "string" && v.length > 0
|
|
);
|
|
|
|
// Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme
|
|
// als `null` bei withUser() an, und die Policies gäben dann konsequent
|
|
// nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als
|
|
// Anmeldefehler.
|
|
if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse.");
|
|
|
|
token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null);
|
|
return token;
|
|
},
|
|
},
|
|
};
|
|
});
|