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
-63
View File
@@ -1,63 +0,0 @@
"use server";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = NextResponse.next();
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return cookieStore.getAll(); },
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
},
},
});
// Upsert dev platform_admin record
const { data: existing } = await supabase
.from("admin_users")
.select("id, role")
.eq("user_id", DEV_ADMIN_UID)
.single();
if (!existing) {
const { error: insertError } = await supabase
.from("admin_users")
.insert({
user_id: DEV_ADMIN_UID,
brand_id: null,
role: "platform_admin",
active: true,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
must_change_password: false,
});
if (insertError) {
return { success: false, error: insertError.message };
}
}
return { success: true, uid: DEV_ADMIN_UID };
}
+38 -20
View File
@@ -1,30 +1,48 @@
"use server";
import { cookies } from "next/headers";
import { createClient as createServiceClient } from "@supabase/supabase-js";
import { auth } from "@/lib/auth";
import { query } from "@/lib/db";
/**
* Update the current user's Supabase auth password.
*
* Reads the Auth.js v5 session to identify the user. The session's
* `user.id` is either:
* - a Supabase auth user id (UUID) for email/password sign-ins
* - a Google `sub` (non-UUID) for Google sign-ins — these are not
* provisioned in Supabase auth, so the RPC will reject them. Google
* users must be provisioned in Supabase auth separately.
*
* The password update itself runs as a SECURITY DEFINER PL/pgSQL function
* (`update_user_password`) inside the database, called directly via the
* shared `pg` pool. No Supabase REST hop required.
*/
export async function updatePasswordAction(
newPassword: string
): Promise<{ error?: string }> {
const cookieStore = await cookies();
const uid =
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value;
): Promise<{ error?: string; userId?: string }> {
const session = await auth();
const uid = session?.user?.id;
if (!uid) {
return { error: "Not authenticated. Please log in again." };
}
const service = createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
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 {
error:
"Password change is not available for social sign-in accounts. Please contact an admin.",
};
}
const { error } = await service.rpc("update_user_password", {
p_user_id: uid,
p_password: newPassword,
});
if (error) return { error: error.message };
return {};
}
try {
// The RPC is SECURITY DEFINER and returns a single row (or raises).
// We SELECT it (rather than SELECT update_user_password(...)) so the
// call stays a normal parameterized query and we can read the result.
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
return { userId: uid };
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to update password.";
return { error: message };
}
}
+46 -44
View File
@@ -79,19 +79,11 @@ export type UpdateAdminUserInput = {
async function getAuthClient() {
const cookieStore = await cookies();
const headerStore = await headers();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
const response = NextResponse.next({ request });
// Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets
// cookies that arrive in the header but NOT in next/headers cookies()).
const cookieHeader = headerStore.get("cookie") || "";
const allCookies = cookieHeader.split(";").map(c => c.trim());
const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null;
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return cookieStore.getAll(); },
@@ -101,16 +93,20 @@ async function getAuthClient() {
},
},
});
return { supabase, response, rcAuthUid };
const devSession = cookieStore.get("dev_session")?.value;
return { supabase, response, devSession };
}
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
const { supabase, rcAuthUid } = await getAuthClient();
const { supabase, devSession } = await getAuthClient();
// Dev force-login UID bypasses Supabase auth entirely
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
if (rcAuthUid === DEV_FORCE_UID) {
return { data: null, error: null }; // let the action proceed without auth check
// Dev mode bypass — let the action proceed without Supabase auth.
// (Pre-Auth.js this was gated on the legacy `rc_auth_uid === DEV_FORCE_UID`
// cookie that the now-deleted `/api/force-admin` route set. With Auth.js v5
// in place, the `dev_session` cookie is the single source of truth for the
// demo flow.)
if (process.env.NODE_ENV !== "production" && devSession) {
return { data: null, error: null };
}
const { data: userData, error: userError } = await supabase.auth.getUser();
@@ -368,8 +364,6 @@ function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { i
// ─── Production admin actions (require real Supabase auth) ─────────────────
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
@@ -389,15 +383,18 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// In development mode: ALL requests with a valid rc_auth_uid use the dev/service path.
// This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation.
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
return devListAdminUsers(rcAuthUid);
// In development mode: dev_session cookie holders use the dev/service path.
// (The previous code also accepted a legacy `rc_auth_uid` cookie set by
// `/api/dev-login` — `/api/dev-login` still sets both cookies, so existing
// dev sessions keep working. The legacy DEV_FORCE_UID check is removed
// because the only route that set that specific UID was deleted.)
if (process.env.NODE_ENV !== "production" && devSession) {
return devListAdminUsers(devSession);
}
// Dev session cookie (platform_admin/brand_admin) — always use service role path
const isDevAdmin = process.env.NODE_ENV !== "production" && (
devSession === "platform_admin" || devSession === "brand_admin" || rcAuthUid === DEV_FORCE_UID
devSession === "platform_admin" || devSession === "brand_admin"
);
if (isDevAdmin) {
return devListAdminUsers(rcAuthUid ?? undefined);
@@ -445,16 +442,10 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
// Read auth context
const cookieStore = await cookies();
const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin
if (rcAuthUid === DEV_FORCE_UID) {
return devCreateAdminUser(input);
}
// TODO: when the Auth.js v5 migration lands everywhere, replace this
// cookie-based check with a session check via `await auth()` from `@/lib/auth`.
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
@@ -463,8 +454,17 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
return devCreateAdminUser(input);
}
// Production path — use service role directly (bypasses Supabase JWT auth)
// rc_auth_uid cookie proves the admin is logged in; service role creates the account
// Production path — service role creates the account. The caller is
// expected to be an authenticated admin (gated by the admin layout /
// getAdminUser() check on the page).
// Keep reading the legacy `rc_auth_uid` cookie for backward compat with
// pre-Auth.js sessions — TODO: drop this branch once all clients are on
// Auth.js.
const headerStore = await headers();
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
if (rcAuthUid) {
const service = getServiceClient();
@@ -557,13 +557,12 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
// Dev bypass check
const cookieStore = await cookies();
const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
if (rcAuthUid === DEV_FORCE_UID) {
// Dev mode — let the action proceed with the service role.
// (The previous code also accepted a legacy `rc_auth_uid === DEV_FORCE_UID`
// cookie set by the now-deleted Emergency Force Login page. With Auth.js v5
// and the demo buttons in /login, the `dev_session` cookie is sufficient.)
if (process.env.NODE_ENV !== "production" && devSession) {
const service = getServiceClient();
const { data, error } = await service
.from("admin_users")
@@ -606,13 +605,13 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
// Dev bypass check
const cookieStore = await cookies();
const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
if (rcAuthUid === DEV_FORCE_UID) {
// Dev mode — let the action proceed with the service role.
// (The previous code gated on `rc_auth_uid === DEV_FORCE_UID`, a magic
// cookie value the now-deleted Emergency Force Login page set. With
// Auth.js v5 and the demo buttons in /login, the `dev_session` cookie is
// the source of truth for the dev path.)
if (process.env.NODE_ENV !== "production" && devSession) {
const service = getServiceClient();
// Get user_id first
const { data: adminRow, error: fetchError } = await service
@@ -642,7 +641,10 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
// Dev path or legacy rc_auth_uid cookie — use service role directly.
// TODO: when Auth.js v5 is the only auth path, drop the rcAuthUid branch
// and require `await auth()` to be present.
if (process.env.NODE_ENV !== "production" || rcAuthUid) {
const service = getServiceClient();
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null };
+51
View File
@@ -0,0 +1,51 @@
"use server";
import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
export type SignInResult = { ok: true } | { ok: false; error: string };
/**
* Sign in with the email/password (Supabase-backed) Credentials provider
* configured in src/lib/auth.ts.
*/
export async function signInWithPassword(
_prev: SignInResult | null,
formData: FormData
): Promise<SignInResult> {
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!email) return { ok: false, error: "Please enter your email address." };
if (!password) return { ok: false, error: "Please enter your password." };
try {
await signIn("supabase-password", {
email,
password,
redirect: false,
});
return { ok: true };
} catch (err) {
if (err instanceof AuthError) {
return { ok: false, error: "Invalid email or password." };
}
throw err;
}
}
/**
* Kick off the Google OAuth flow. Auth.js will redirect to Google's consent
* screen and then back to /api/auth/callback/google, which sets the session
* cookie and redirects to the configured callback URL.
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
/**
* Sign out and clear the Auth.js session cookie.
*/
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
+28 -17
View File
@@ -271,25 +271,36 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
// Public version for storefront pages — uses slug, no auth required
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
);
if (!supabaseUrl || !supabaseKey) {
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
}
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
const data = await response.json();
return {
success: true,
settings: data,
wholesaleEnabled: data?.wholesale_enabled,
};
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just falls back to its
// default brand name and revalidates from a real request later.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
);
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
const data = await response.json();
return {
success: true,
settings: data,
wholesaleEnabled: data?.wholesale_enabled,
};
} catch {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
}
export async function saveBrandSettings(params: {
-56
View File
@@ -1,56 +0,0 @@
"use server";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
export type LoginWithPasswordResult =
| { success: true; redirect: true }
| { success: false; error: string };
export async function loginWithPassword(
email: string,
password: string
): Promise<LoginWithPasswordResult> {
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return { success: false, error: "Server misconfiguration." };
}
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
});
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error || !data.user) {
return { success: false, error: error?.message || "Invalid credentials" };
}
// Set the rc_auth_uid cookie that getAdminUser() reads
const isProd = process.env.NODE_ENV === "production";
cookieStore.set("rc_auth_uid", data.user.id, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: isProd,
});
return { success: true, redirect: true };
}
+4 -1
View File
@@ -5,7 +5,7 @@ import { logAuditEvent } from "@/actions/audit";
import { svcHeaders } from "@/lib/svc-headers";
type MarkPickupResult =
| { success: true; pickup_completed_at: string; pickup_completed_by: string }
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
| { success: false; error: string };
export async function markPickupComplete(
@@ -23,6 +23,9 @@ export async function markPickupComplete(
}
const now = new Date().toISOString();
// `user_id` is null for Google-authenticated admins who haven't been
// linked to a Supabase auth user yet. Pass null through; downstream
// audit/assignment RPCs will surface a clearer error.
const performedBy = adminUser.user_id;
// brand_admin: verify the order belongs to their brand
+47 -30
View File
@@ -105,22 +105,30 @@ export type StopForSitemap = {
};
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Get all active stops with their brand slug
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
if (!supabaseUrl || !supabaseKey) return [];
if (!response.ok) return [];
// Get all active stops with their brand slug.
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
// crash the prerender — the sitemap just renders without stop URLs.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
} catch {
return [];
}
}
/**
@@ -150,24 +158,33 @@ export async function getPublicStopsForBrand(
): Promise<PublicStop[]> {
if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
);
if (!supabaseUrl || !supabaseKey) return [];
if (!response.ok) return [];
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just renders with no stops
// and revalidates from a real request once the cache is warm.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
);
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch {
return [];
}
}