From 1e9f9c0414cbe3fc98d2a90cb3f3235b8bf69ecf Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 20:30:11 +0000 Subject: [PATCH] feat(auth): wire getAdminUser() to Auth.js v5 Google sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a user signs in with Google, they land on /admin but see 'Your account does not have admin access' because getAdminUser() only checked the legacy dev_session and rc_auth_uid cookies. This completes the Auth.js path: - New src/lib/db.ts: shared pg.Pool singleton (extracted from src/lib/auth.ts). The single connection pool for the whole app — server actions, API routes, and Auth.js all import from here. - src/lib/auth.ts: imports the shared pool, signIn event now calls the new upsert_admin_user_for_authjs RPC (idempotent) to auto-create a platform_admin row on first sign-in. - New supabase/migrations/209_authjs_auto_create_admin.sql: - Defensive ALTER TABLE ADD COLUMN IF NOT EXISTS for can_manage_settings (was likely dashboard-added, not in any tracked migration) - SECURITY DEFINER RPC upsert_admin_user_for_authjs(p_user_id UUID) that inserts a platform_admin row with all permissions true and ON CONFLICT (user_id) DO NOTHING - NOTIFY pgrst to reload PostgREST schema cache - src/lib/admin-permissions.ts: new Auth.js session check between dev_session and rc_auth_uid. Uses auth() from @/lib/auth to decrypt the JWT cookie server-side, then getAdminUserFromPool() queries admin_users + admin_user_brands via the shared pool. Legacy rc_auth_uid path unchanged (deferred). - src/middleware.ts: recognizes Auth.js session cookies (authjs.session-token and __Secure-authjs.session-token) at the edge so signed-in users aren't bounced to /login. Flow after this change: Dev/demo: visit /admin → middleware auto-issues dev_session → in Prod: click Google → Auth.js OAuth → signIn event creates admin_users row → redirect to /admin → getAdminUser() reads JWT, queries pool, returns platform_admin. --- src/lib/admin-permissions.ts | 62 +++++++++++++++- src/lib/auth.ts | 71 ++++--------------- src/lib/db.ts | 49 +++++++++++++ src/middleware.ts | 24 ++++--- .../209_authjs_auto_create_admin.sql | 66 +++++++++++++++++ 5 files changed, 203 insertions(+), 69 deletions(-) create mode 100644 src/lib/db.ts create mode 100644 supabase/migrations/209_authjs_auto_create_admin.sql diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts index 8fb26cd..cd1ea9c 100644 --- a/src/lib/admin-permissions.ts +++ b/src/lib/admin-permissions.ts @@ -1,4 +1,6 @@ import { cookies } from "next/headers"; +import { auth } from "@/lib/auth"; +import { pool } from "@/lib/db"; import type { AdminUser } from "./admin-permissions-types"; export type { AdminUser } from "./admin-permissions-types"; @@ -8,7 +10,9 @@ export type { AdminUser } from "./admin-permissions-types"; * Resolution order: * 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev. * 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee). - * 3. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids. + * 3. Auth.js v5 session (JWT cookie) → look up `admin_users` by the + * Auth.js user id (the `users.id` UUID managed by @auth/pg-adapter). + * 4. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids. * * `brand_id` is the active brand; `brand_ids` is the full membership list. * For dev sessions without a real DB, `brand_ids` is populated by: @@ -24,12 +28,23 @@ export async function getAdminUser(): Promise { return buildDevAdmin("platform_admin"); } - // ── Dev session bypass (enabled for testing on all envs) ────────────── + // ── Dev session bypass (enabled for testing on all envs) ────────── const dev = cookieStore.get("dev_session")?.value; if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") { return buildDevAdmin(dev); } + // ── Auth.js v5 session (JWT) ───────────────────────────────────── + // After Google sign-in, the encrypted JWT cookie is set. `auth()` + // decrypts it server-side and returns the session — no DB call here, + // just cookie decryption. Then we look up the admin row by the + // Auth.js `users.id` UUID (same ID space as `admin_users.user_id`). + const session = await auth(); + if (session?.user?.id) { + const admin = await getAdminUserFromPool(session.user.id); + if (admin) return admin; + } + // ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─ const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value; if (!uid) return null; @@ -91,6 +106,49 @@ export async function getAdminUser(): Promise { return buildAdminUser(admin, brandIds); } +/** + * Look up an admin user by the Auth.js `users.id` UUID using the shared + * `pg` pool. Returns `null` if no active row exists. + * + * The `admin_users.user_id` column is UUID (see 028_fix_caller_uid_type.sql). + * The Auth.js `users.id` is also UUID (see 204_authjs_tables.sql:18). The + * @auth/pg-adapter auto-generates a fresh UUID per new user on first + * sign-in; the Google `sub` claim is stored separately in + * `accounts."providerAccountId"`. So both IDs are in the same UUID space. + */ +async function getAdminUserFromPool(userId: string): Promise { + try { + const { rows } = await pool.query>( + "SELECT * FROM admin_users WHERE user_id = $1 AND active = true LIMIT 1", + [userId] + ); + if (rows.length === 0) return null; + const admin = rows[0]; + const brandIds = await fetchAdminUserBrandIdsFromPool(admin.id as string); + return buildAdminUser(admin, brandIds); + } catch (e) { + console.warn("[admin-permissions] getAdminUserFromPool error:", e); + return null; + } +} + +/** + * Load `brand_ids` from the admin_user_brands junction for the given + * admin row id, via the shared `pg` pool. Returns an empty array on any + * failure. + */ +async function fetchAdminUserBrandIdsFromPool(adminRowId: string): Promise { + try { + const { rows } = await pool.query<{ brand_id: string }>( + "SELECT brand_id FROM admin_user_brands WHERE admin_user_id = $1", + [adminRowId] + ); + return rows.map((r) => r.brand_id).filter((id): id is string => typeof id === "string"); + } catch { + return []; + } +} + /** * Load `brand_ids` from the admin_user_brands junction for the given admin row. * Returns an empty array on any failure (e.g. before migration 207 is applied). diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 88d143a..d3f02f8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,11 +1,8 @@ import NextAuth from "next-auth"; import PostgresAdapter from "@auth/pg-adapter"; -import { Pool } from "pg"; import Credentials from "next-auth/providers/credentials"; -import { - authConfig, - isDevLoginEnabled, -} from "@/auth.config"; +import { pool } from "@/lib/db"; +import { authConfig, isDevLoginEnabled } from "@/auth.config"; /** * Build the dev Credentials provider. Lives here (Node-only) because @@ -39,51 +36,11 @@ function buildDevCredentialsProvider() { }); } -/** - * Shared Postgres pool for Auth.js. Reuses the same database the rest of - * the app talks to (via `pg`). Lives behind a module-level singleton so - * Next.js hot reload doesn't open a new pool on every request. - * - * Note: in production, `DATABASE_URL` should be the only DB env var. The - * Supabase project URL / service role key are no longer required for auth - * (they are still used elsewhere until the rest of the app is migrated off - * the @supabase client — see CLAUDE.md). - */ -const globalForPool = globalThis as unknown as { __pgPool?: Pool }; - -function getPool(): Pool { - if (globalForPool.__pgPool) return globalForPool.__pgPool; - - const connectionString = - process.env.DATABASE_URL ?? - process.env.SUPABASE_DB_URL ?? - process.env.POSTGRES_URL; - - if (!connectionString) { - // Don't throw at module load — let route handlers return a clean 500 - // if env is missing. The smoke test instructions tell the user to - // set DATABASE_URL. - // eslint-disable-next-line no-console - console.warn( - "[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up." - ); - } - - const pool = new Pool({ - connectionString, - // Reasonable defaults; override via connection string if you need more - max: 10, - idleTimeoutMillis: 30_000, - }); - globalForPool.__pgPool = pool; - return pool; -} - /** * Final server-side Auth.js config. * * Builds on `authConfig` (edge-safe) and layers on: - * 1. The Postgres database adapter + * 1. The Postgres database adapter (uses the shared `pool` from @/lib/db) * 2. The dev Credentials provider (only in development) * * Note: when using a database adapter the session strategy is fixed to @@ -96,7 +53,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // must use JWT. The Postgres adapter is still wired up so that user // records are created/updated when a new OAuth sign-in happens — but // the session itself is stored in the cookie as an encrypted JWT. - adapter: PostgresAdapter(getPool()), + adapter: PostgresAdapter(pool), // `session.strategy` is inherited from `authConfig` ("jwt") providers: [ // Re-declare the providers from authConfig and append the dev @@ -108,27 +65,23 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ events: { /** * First-time sign-in: auto-create a `platform_admin` row in - * `admin_users` keyed to this auth.js user id, mirroring the legacy - * `rc_auth_uid` flow. This is the seam between the new auth layer - * and the existing admin authorization model. + * `admin_users` keyed to this Auth.js user id. The RPC is + * idempotent (ON CONFLICT DO NOTHING) so repeat sign-ins are no-ops. + * + * This is the seam between the new Auth.js auth layer and the + * existing admin authorization model. After this fires, the user + * is recognized by `getAdminUser()` in `src/lib/admin-permissions.ts`. */ async signIn({ user }) { try { - const pool = getPool(); const userId = user.id; if (!userId) return; - // Fire and forget — don't block sign-in on a missing admin_users row. await pool.query( - `SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`, + "SELECT * FROM upsert_admin_user_for_authjs($1)", [userId] ); - // Note: we don't auto-create here; the existing `getAdminUser()` - // in `src/lib/admin-permissions.ts` is the source of truth for - // role lookups and is unchanged. After this migration the user - // is authenticated; the existing `dev_session` demo path still - // works for the smoke test. } catch (e) { - // eslint-disable-next-line no-console + // Don't block sign-in on a missing admin_users row. console.warn("[auth] signIn event error (non-fatal):", e); } }, diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..de915ad --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,49 @@ +import { Pool } from "pg"; + +/** + * Shared `pg.Pool` for direct Postgres access. + * + * This is the single connection pool for the entire app — server actions, + * API routes, and Auth.js all import `pool` from here. No more ad-hoc pools + * in individual files. + * + * Replaces the Supabase JS client (see CLAUDE.md "Supabase is being + * removed in favor of a direct Postgres connection"). SECURITY DEFINER + * RPCs are the recommended way to do reads/writes; this pool is the + * transport. + * + * Connection resolution: + * 1. `DATABASE_URL` — preferred, single connection string + * 2. `SUPABASE_DB_URL` — legacy, for projects still on Supabase + * 3. `POSTGRES_URL` — alternative + * + * Singleton pattern: `globalThis.__pgPool` so Next.js hot reload doesn't + * open a new pool on every request. + */ + +const globalForPool = globalThis as unknown as { __pgPool?: Pool }; + +function createPool(): Pool { + const connectionString = + process.env.DATABASE_URL ?? + process.env.SUPABASE_DB_URL ?? + process.env.POSTGRES_URL; + + if (!connectionString) { + // Don't throw at module load — let route handlers return a clean 500 + // if env is missing. The deploy workflow and `.env.example` document + // the required vars. + console.warn( + "[db] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — pg pool will fail on first query." + ); + } + + return new Pool({ + connectionString, + max: 10, + idleTimeoutMillis: 30_000, + }); +} + +export const pool: Pool = + globalForPool.__pgPool ?? (globalForPool.__pgPool = createPool()); diff --git a/src/middleware.ts b/src/middleware.ts index fc06744..17c17c4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,18 +3,18 @@ import { NextResponse, type NextRequest } from "next/server"; /** * Middleware for /admin/* and /login routes. * - * Two responsibilities: - * 1. Gate /admin/* — only authenticated users (by dev_session, rc_auth_uid, - * or rc_uid cookie) can access. Unauthenticated users are redirected - * to /login. - * 2. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by + * Recognises three auth sources at the edge: + * 1. Auth.js v5 JWT cookie (`authjs.session-token` or the `__Secure-` + * variant in prod) — set after Google sign-in. + * 2. `dev_session`, `rc_auth_uid`, `rc_uid` — legacy / dev cookies. + * 3. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by * default in non-prod) and the user has no auth cookie, automatically * issue `dev_session=platform_admin` so visiting /admin just works. * No buttons, no demo page, no client-side cookie games. * * This is the single source of truth for "am I allowed in?" at the edge. - * The page-level `getAdminUser()` re-checks the same cookies, so the two - * stay in sync. + * The page-level `getAdminUser()` re-checks the same cookies (and reads + * the Auth.js JWT) to resolve the role. */ export function middleware(request: NextRequest) { const { nextUrl } = request; @@ -25,12 +25,20 @@ export function middleware(request: NextRequest) { const dev = request.cookies.get("dev_session")?.value; const rcAuthUid = request.cookies.get("rc_auth_uid")?.value; const rcUid = request.cookies.get("rc_uid")?.value; + // Auth.js v5 sets an encrypted JWT cookie. Names differ by transport: + // `authjs.session-token` (dev / HTTP) and `__Secure-authjs.session-token` + // (prod / HTTPS). The presence of either cookie is sufficient to + // consider the request authenticated at the edge — the page-level + // `getAdminUser()` re-reads the JWT and resolves the role. + const authJsSessionToken = request.cookies.get("authjs.session-token")?.value; + const authJsSecureToken = request.cookies.get("__Secure-authjs.session-token")?.value; const hasDevSession = dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee"; const hasRealAuth = Boolean(rcAuthUid || rcUid); - const isAuthenticated = hasDevSession || hasRealAuth; + const hasAuthJsSession = Boolean(authJsSessionToken || authJsSecureToken); + const isAuthenticated = hasDevSession || hasRealAuth || hasAuthJsSession; // ── /admin/* ────────────────────────────────────────────────────── if (isOnAdmin) { diff --git a/supabase/migrations/209_authjs_auto_create_admin.sql b/supabase/migrations/209_authjs_auto_create_admin.sql new file mode 100644 index 0000000..8359f3a --- /dev/null +++ b/supabase/migrations/209_authjs_auto_create_admin.sql @@ -0,0 +1,66 @@ +-- 209_authjs_auto_create_admin.sql +-- Auto-create a platform_admin row when a new user signs in via Auth.js. +-- +-- Called from the `signIn` event in `src/lib/auth.ts`. The RPC is +-- idempotent (ON CONFLICT DO NOTHING) so repeat sign-ins are no-ops. + +-- Defensive: ensure can_manage_settings column exists. It was likely +-- added via the Supabase dashboard (it's referenced in the TypeScript +-- `AdminUser` type at `src/lib/admin-permissions-types.ts` but not in +-- any tracked migration). ADD COLUMN IF NOT EXISTS is safe to re-run. +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS can_manage_settings BOOLEAN NOT NULL DEFAULT false; + +-- SECURITY DEFINER RPC: upsert a platform_admin row for the given +-- Auth.js user id. +-- +-- Bypasses RLS on admin_users (which is enabled — see +-- 109_enable_rls_critical.sql:21). Runs with the function owner's +-- privileges so the auto-create on first sign-in can always succeed. +CREATE OR REPLACE FUNCTION upsert_admin_user_for_authjs(p_user_id UUID) +RETURNS SETOF admin_users +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + RETURN QUERY + INSERT INTO admin_users ( + user_id, + role, + active, + must_change_password, + can_manage_products, + can_manage_stops, + can_manage_orders, + can_manage_pickup, + can_manage_messages, + can_manage_refunds, + can_manage_users, + can_manage_water_log, + can_manage_reports, + can_manage_settings + ) + VALUES ( + p_user_id, + 'platform_admin', + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ) + ON CONFLICT (user_id) DO NOTHING + RETURNING *; +END; +$$; + +-- Reload PostgREST schema cache so the new RPC is immediately callable. +NOTIFY pgrst, 'reload schema';