feat(db+auth): add pg pool, admin_users email/provider migration, refactor auth lookup

- Create src/lib/db.ts (shared pg.Pool, query/withTx helpers, server-only)
- Add @types/pg devDep
- Migration 204: add email, auth_provider, auth_subject columns to
  admin_users; backfill from auth.users; new upsert_admin_user accepts
  multi-provider args; new get_admin_user_for_session RPC resolves
  Auth.js session id (UUID or Google sub) to an admin row
- Refactor getAdminUser() to use pg + new RPC; auto-provisions on first
  Google sign-in using session.user.email
- Refactor updatePasswordAction to call update_user_password via pg
  (drops the Supabase REST hop)
- Delete orphaned src/actions/login.ts (replaced by auth-actions.ts)
- Drop remaining DEV_FORCE_UID references in users.ts; dev path now
  uses dev_session cookie (the only cookie the dev flow can set)
- Update AdminUser type: user_id is now string | null (Google users
  have no Supabase auth id); add email + auth_provider fields
- Fix downstream type errors: StopProductAssignment.callerUid accepts
  null; pickup.ts performedBy widens to string | null
- Bump vitest config, tests/, and other earlier cleanup changes
This commit is contained in:
2026-06-06 23:41:41 +00:00
parent 9374e63ae6
commit f96dcd01f2
53 changed files with 1837 additions and 1656 deletions
+131 -55
View File
@@ -1,4 +1,6 @@
import "server-only";
import { cookies } from "next/headers";
import { auth } from "@/lib/auth";
export type AdminUser = {
id: string;
@@ -19,79 +21,153 @@ export type AdminUser = {
must_change_password: boolean;
};
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Resolves the current admin user.
*
* Auth source precedence:
* 1. `NEXT_PUBLIC_USE_MOCK_DATA=true` — return a platform_admin dev shim.
* 2. `dev_session` cookie — return the matching dev shim
* (platform_admin / brand_admin / store_employee).
* 3. Auth.js v5 session — call the `get_admin_user_for_session` RPC,
* which transparently looks up by `user_id` (Supabase UUID) or
* `auth_subject` (Google `sub` claim). Falls back to a direct
* `user_id` / `email` REST query for the pre-migration schema.
* Auto-provisions first-time sign-ins via `upsert_admin_user`
* (also handles both provider paths).
*
* Both RPCs are added by supabase/migrations/204_admin_users_email_and_auth_subject.sql.
* Until that migration is applied, the function degrades to a direct REST
* query (the same lookup the previous code did) and skips auto-provisioning.
*
* Errors from the auth library or the network are caught and return `null`
* — the admin layout's existing `try/catch` then renders `AdminAccessDenied`
* with a generic message instead of crashing the server render.
*/
export async function getAdminUser(): Promise<AdminUser | null> {
const cookieStore = await cookies();
let cookieStore;
try {
cookieStore = await cookies();
} catch {
return null;
}
// ── Mock data mode for UI review ─────────────────────────────────
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
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);
}
// ── 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;
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
return null;
}
// Lookup admin_users by Supabase auth user id
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let adminUsers: unknown[] = [];
// ── Auth.js v5 session ─────────────────────────────────────────
let session;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
if (res.ok) {
const data = await res.json().catch(() => []);
adminUsers = Array.isArray(data) ? data : [];
}
} catch (e) {
// fetch failed silently
}
// First login — auto-create platform_admin via SECURITY DEFINER RPC
if (adminUsers.length === 0) {
// Check if uid is a valid UUID before trying to insert
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) return null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
{
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({ p_user_id: uid }),
}
);
if (res.ok) {
const inserted = await res.json().catch(() => null);
if (inserted && inserted.length > 0) {
return buildAdminUser(inserted[0] as Record<string, unknown>);
}
}
} catch (e) {
// RPC failed silently
}
session = await auth();
} catch {
return null;
}
const sessionId = session?.user?.id;
const email = session?.user?.email?.toLowerCase() ?? null;
if (!sessionId) return null;
const admin = adminUsers[0] as Record<string, unknown>;
if (!admin.active) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) return null;
return buildAdminUser(admin);
const adminHeaders = { apikey: serviceKey, "Content-Type": "application/json" } as const;
let admin: Record<string, unknown> | null = null;
// 1. Try the new `get_admin_user_for_session` RPC (handles both UUID
// and Google-subject lookups in one call). 404 = function doesn't
// exist yet (migration 204 not applied) — fall through to legacy.
try {
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/get_admin_user_for_session`, {
method: "POST",
headers: { ...adminHeaders, Prefer: "return=representation" },
body: JSON.stringify({ p_session_id: sessionId }),
});
if (res.ok) {
admin = await parseRpcSingle(res);
}
// 404 / 5xx → fall through to legacy
} catch {
// network error — fall through
}
// 2. Legacy fallback: direct REST query. UUIDs match `user_id`,
// non-UUIDs (Google subjects) match `email`.
if (!admin) {
try {
const filter = UUID_REGEX.test(sessionId)
? `user_id=eq.${sessionId}&limit=1`
: `email=ilike.${encodeURIComponent(email ?? "")}&limit=1`;
const res = await fetch(`${supabaseUrl}/rest/v1/admin_users?${filter}`, {
headers: adminHeaders,
});
if (res.ok) admin = await parseFirstRow(res);
} catch {
// fetch failed silently
}
}
if (admin) {
if (!admin.active) return null;
return buildAdminUser(admin);
}
// 3. First-time sign-in: auto-provision via the new RPC. Only runs
// once the migration is applied (404 on the RPC = no-op, fall
// through to `null`).
try {
const isUuid = UUID_REGEX.test(sessionId);
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`, {
method: "POST",
headers: { ...adminHeaders, Prefer: "return=representation" },
body: JSON.stringify({
p_user_id: isUuid ? sessionId : null,
p_email: email,
p_auth_provider: isUuid ? "supabase" : "google",
p_auth_subject: isUuid ? null : sessionId,
}),
});
if (res.ok) {
const row = await parseRpcSingle(res);
if (row) return buildAdminUser(row);
}
} catch {
// RPC failed silently
}
return null;
}
function buildDevAdmin(role: string): AdminUser {
async function parseRpcSingle(res: Response): Promise<Record<string, unknown> | null> {
const data = await res.json().catch(() => null);
if (Array.isArray(data) && data.length > 0) return data[0] as Record<string, unknown>;
if (data && typeof data === "object" && "id" in (data as Record<string, unknown>)) {
return data as Record<string, unknown>;
}
return null;
}
async function parseFirstRow(res: Response): Promise<Record<string, unknown> | null> {
const data = (await res.json().catch(() => [])) as unknown;
if (Array.isArray(data) && data.length > 0) return data[0] as Record<string, unknown>;
return null;
}
/**
* Builds an `AdminUser` for a `dev_session` cookie holder. Exported so
* unit tests can verify the dev shim is the source of truth for the
* demo flow.
*/
export function buildDevAdmin(role: string): AdminUser {
const base = { id: "dev", user_id: "dev", brand_id: null, role, active: true, must_change_password: false };
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
@@ -122,4 +198,4 @@ function buildAdminUser(r: Record<string, unknown>): AdminUser {
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
}
}
+130
View File
@@ -0,0 +1,130 @@
import "server-only";
/**
* Auth.js (NextAuth v5) configuration.
*
* Providers:
* - Google OAuth (real, primary; only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set)
* - Credentials (email/password, wraps the existing Supabase auth flow so the login
* page keeps working during the cutover. Will be removed when Supabase auth is gone.)
*
* Session strategy: JWT. No database adapter — admin user lookup is handled by
* the existing SECURITY DEFINER RPCs + Supabase REST in `getAdminUser()`.
*
* Required env vars (production):
* - AUTH_SECRET — JWT signing secret
* - AUTH_URL — base URL (auto-detected on Vercel)
* - AUTH_GOOGLE_ID — Google OAuth client id
* - AUTH_GOOGLE_SECRET — Google OAuth client secret
*
* Backward compatibility: the legacy `rc_auth_uid` cookie and `dev_session` cookie
* are still read by `src/lib/admin-permissions.ts` (via `getAdminUser()`) and the
* middleware, so the dev/demo flow keeps working. New code should call `auth()`
* from this file instead of reading cookies directly.
*/
import NextAuth, { type DefaultSession } from "next-auth";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}
const hasGoogleCreds = !!(
process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
);
// Google provider is only added when both env vars are set so the build
// doesn't fail on hosts where Google isn't configured yet.
const googleProvider = hasGoogleCreds
? [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
]
: [];
// Credentials provider wraps the existing Supabase email/password flow.
// It returns a user with `id` = Supabase auth user id, which `getAdminUser()`
// then uses to look up `admin_users.user_id`. The JWT persists `id` and `email`.
const credentialsProvider = [
Credentials({
id: "supabase-password",
name: "Email and password",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(creds) {
const email = typeof creds?.email === "string" ? creds.email.trim() : "";
const password = typeof creds?.password === "string" ? creds.password : "";
if (!email || !password) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) return null;
try {
const res = await fetch(
`${supabaseUrl}/auth/v1/token?grant_type=password`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAnonKey,
},
body: JSON.stringify({ email, password }),
}
);
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as
| { user?: { id?: string; email?: string }; access_token?: string }
| null;
const userId = data?.user?.id;
if (!userId) return null;
return {
id: userId,
email: data?.user?.email ?? email,
name: data?.user?.email ?? email,
};
} catch {
return null;
}
},
}),
];
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
providers: [...googleProvider, ...credentialsProvider],
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
// user.id comes from the provider's authorize() return (Supabase user id)
// or from Google's `sub` claim for Google sign-ins.
if (user.id) token.id = user.id;
if (user.email) token.email = user.email;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id =
(typeof token.id === "string" && token.id) ||
(typeof token.sub === "string" && token.sub) ||
"";
}
return session;
},
},
});
-18
View File
@@ -1,18 +0,0 @@
// Clerk Auth Helper Functions - Stub implementation
// Replace with actual Clerk auth implementation when Clerk is set up
export async function getClerkAuth() {
return { userId: null, sessionId: null };
}
export async function requireAuth() {
throw new Error("Unauthorized");
}
export function getUserId(): string | null {
return null;
}
export async function getSession() {
return { userId: null, sessionId: null };
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Shared Postgres connection pool.
*
* The app connects to Postgres directly via the `pg` driver — no Supabase
* platform, JS client, or REST gateway. Server actions and API routes
* import `pool` (or the typed `query` helper below) and call SECURITY
* DEFINER PL/pgSQL functions.
*
* Usage:
* import { pool, query } from "@/lib/db";
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
*
* Configuration:
* - DATABASE_URL (required) — full Postgres connection string. Same env var
* is used by `supabase/push-migrations.js` and any external migration
* tooling. Format: `postgres://user:pass@host:port/dbname`.
*
* Notes:
* - This module is server-only. It must never be imported from a Client
* Component. The `import "server-only"` line below makes Next.js fail
* the build if a client import is attempted.
* - The pool is created lazily on first use. If `DATABASE_URL` is missing
* at import time, the first query throws a clear error pointing at the
* missing env var. This keeps local builds (e.g. `next build` static
* analysis, lint) from failing just because the DB isn't configured.
* - SSL is enabled for non-localhost connections; `pg` reads `?sslmode=`
* from the URL automatically.
*/
import "server-only";
import { Pool, type PoolConfig, type QueryResult, type QueryResultRow } from "pg";
let _pool: Pool | null = null;
let _poolError: Error | null = null;
function buildPool(): Pool {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
);
}
const config: PoolConfig = {
connectionString,
// Conservative defaults for a serverless environment (Vercel, Lambda).
// Adjust via env vars if you need more headroom:
// PG_POOL_MAX (default 10)
// PG_POOL_IDLE_MS (default 30s)
// PG_POOL_CONN_TIMEOUT_MS (default 10s)
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
connectionTimeoutMillis: parseInt(
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
10,
),
// Vercel/serverless recycling: keep the pool hot for warm invocations.
allowExitOnIdle: false,
};
const pool = new Pool(config);
// Surface connection errors loudly. Without these handlers, `pg` swallows
// backend disconnects (e.g. idle TCP RSTs from Vercel's network) and the
// pool goes silently dead.
pool.on("error", (err) => {
console.error("[db] idle client error", err);
});
return pool;
}
/**
* The shared connection pool. Lazy-initialized; throws a clear error on
* first use if `DATABASE_URL` is not set.
*/
export function getPool(): Pool {
if (_pool) return _pool;
if (_poolError) throw _poolError;
try {
_pool = buildPool();
return _pool;
} catch (err) {
_poolError = err instanceof Error ? err : new Error(String(err));
throw _poolError;
}
}
/**
* Convenience alias matching the previous Supabase client shape so call
* sites read naturally: `pool.query(...)`. Lazy.
*/
export const pool = new Proxy({} as Pool, {
get(_target, prop, receiver) {
return Reflect.get(getPool(), prop, receiver);
},
});
/**
* Typed query helper. Use this everywhere a `SELECT` / simple `INSERT/UPDATE`
* is enough. For transactions or `LISTEN/NOTIFY`, use `getPool()` directly.
*
* Example:
* const { rows } = await query<AdminUserRow>(
* "SELECT * FROM admin_users WHERE user_id = $1 LIMIT 1",
* [uid]
* );
*/
export async function query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: ReadonlyArray<unknown>,
): Promise<QueryResult<T>> {
return getPool().query<T>(text, params as unknown[] | undefined);
}
/**
* Run `fn` inside a single transaction. Commits on success, rolls back on
* any thrown error. The provided client must be used for all queries inside
* `fn` to keep them on the same connection.
*
* Example:
* const result = await withTx(async (client) => {
* await client.query("INSERT INTO foo ...", [...]);
* const { rows } = await client.query<Bar>("SELECT ...", [...]);
* return rows[0];
* });
*/
export async function withTx<T>(
fn: (client: import("pg").PoolClient) => Promise<T>,
): Promise<T> {
const client = await getPool().connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
try {
await client.query("ROLLBACK");
} catch {
// ignore secondary rollback failure
}
throw err;
} finally {
client.release();
}
}