feat(auth): wire getAdminUser() to Auth.js v5 Google sign-in
Deploy to route.crispygoat.com / deploy (push) Failing after 2s
Deploy to route.crispygoat.com / deploy (push) Failing after 2s
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.
This commit is contained in:
@@ -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<AdminUser | null> {
|
||||
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<AdminUser | null> {
|
||||
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<AdminUser | null> {
|
||||
try {
|
||||
const { rows } = await pool.query<Record<string, unknown>>(
|
||||
"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<string[]> {
|
||||
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).
|
||||
|
||||
+12
-59
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
+16
-8
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user