Merge branch 'feature/drizzle-rls-real-auth'

# Conflicts:
#	CLAUDE.md
#	package.json
#	src/app/api/auth/[...nextauth]/route.ts
#	src/app/login/LoginClient.tsx
#	src/auth.config.ts
#	src/components/admin/AdminSidebar.tsx
#	src/lib/admin-permissions-types.ts
#	src/lib/admin-permissions.ts
#	src/lib/auth.ts
#	src/middleware.ts
This commit is contained in:
2026-06-07 01:55:06 +00:00
84 changed files with 4165 additions and 2881 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 };
}
}
+243 -539
View File
@@ -1,18 +1,15 @@
"use server";
import { cookies, headers } from "next/headers";
import { createServerClient } from "@supabase/ssr";
import { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createClient as createServiceClient } from "@supabase/supabase-js";
import { supabase as publicSupabase } from "@/lib/supabase";
import "server-only";
import { cookies } from "next/headers";
import { pool, query } from "@/lib/db";
import { getMockTableData, mockBrands } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type AdminUserRow = {
id: string;
user_id: string;
user_id: string | null;
display_name: string | null;
email: string;
phone_number: string | null;
@@ -75,169 +72,17 @@ export type UpdateAdminUserInput = {
phone_number?: string | null;
};
// ─── SSR client for authenticated requests ─────────────────────────────────
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(); },
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));
},
},
});
return { supabase, response, rcAuthUid };
}
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
const { supabase, rcAuthUid } = 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
}
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError || !userData.user) {
return { data: null, error: "Not authenticated" };
}
const { data, error } = await supabase.rpc(fn, params as Record<string, unknown>);
if (error) { /* RPC error handled silently */ }
return { data: data as T, error: error ? error.message : null };
}
// ─── Service role client (server-only, never exposed to browser) ───────────
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set. Cannot use service role in dev path.");
}
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
// ─── Dev-only path — uses service role to create auth user + admin_users ──
async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
if (process.env.NODE_ENV === "production") {
return { user: null, error: "Dev path not available in production" };
}
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
if (!devSession || devSession !== "platform_admin") {
return { user: null, error: "Not authenticated" };
}
const service = getServiceClient();
// Create auth user with the provided password
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email,
password: input.password,
email_confirm: true,
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
},
});
if (authError || !authUser.user) {
return { user: null, error: authError?.message ?? "Failed to create auth user" };
}
// Insert into admin_users
const { data: inserted, error: insertError } = await service
.from("admin_users")
.insert({
user_id: authUser.user.id,
role: input.role,
brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
can_manage_products: input.flags.can_manage_products ?? false,
can_manage_stops: input.flags.can_manage_stops ?? false,
can_manage_orders: input.flags.can_manage_orders ?? false,
can_manage_pickup: input.flags.can_manage_pickup ?? false,
can_manage_messages: input.flags.can_manage_messages ?? false,
can_manage_refunds: input.flags.can_manage_refunds ?? false,
can_manage_users: input.flags.can_manage_users ?? false,
can_manage_water_log: input.flags.can_manage_water_log ?? false,
can_manage_reports: input.flags.can_manage_reports ?? false,
active: true,
must_change_password: input.mustChangePassword ?? true,
})
.select()
.single();
if (insertError) {
return { user: null, error: insertError.message };
}
// Send welcome email
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
await sendWelcomeEmail({
to: input.email,
name: input.display_name || input.email.split("@")[0],
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
brandName: "Tuxedo Corn",
tempPassword: input.password,
});
} catch (e) {
// welcome email failed silently
}
return {
user: {
id: inserted.id,
user_id: inserted.user_id,
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: inserted.phone_number ?? input.phone_number ?? null,
role: inserted.role,
brand_id: inserted.brand_id,
brand_name: null,
can_manage_products: inserted.can_manage_products,
can_manage_stops: inserted.can_manage_stops,
can_manage_orders: inserted.can_manage_orders,
can_manage_pickup: inserted.can_manage_pickup,
can_manage_messages: inserted.can_manage_messages,
can_manage_refunds: inserted.can_manage_refunds,
can_manage_users: inserted.can_manage_users,
can_manage_water_log: inserted.can_manage_water_log,
can_manage_reports: inserted.can_manage_reports,
active: inserted.active,
must_change_password: inserted.must_change_password ?? true,
created_at: inserted.created_at,
last_login: null,
},
error: null,
};
}
// ─── Row mapping ────────────────────────────────────────────────────────────
//
// `admin_users` schema (after migration 204 + 034 + 037):
// id, user_id, display_name, email, phone_number, role, brand_id,
// can_manage_<X> (BOOLEAN each), active, must_change_password,
// created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
return {
id: String(row.id ?? ""),
user_id: String(row.user_id ?? ""),
user_id: (row.user_id as string | null) ?? null,
display_name: (row.display_name as string | null) ?? null,
email: String(row.email ?? ""),
phone_number: (row.phone_number as string | null) ?? null,
@@ -260,413 +105,272 @@ function mapUserRow(row: Record<string, unknown>): AdminUserRow {
};
}
// ─── Dev path helpers (service role, local only) ───────────────────────────
// ─── Welcome email (best-effort) ────────────────────────────────────────────
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
const service = getServiceClient();
// Ensure caller has an admin_users record
if (callerUid) {
const { data: existing } = await service
.from("admin_users")
.select("id")
.eq("user_id", callerUid)
.maybeSingle();
if (!existing) {
// auto-creating admin_users for uid
const { data: authData } = await service.auth.admin.listUsers();
const authUser = authData?.users?.find((u) => u.id === callerUid);
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata;
await service.from("admin_users").insert({
user_id: callerUid,
role: "platform_admin",
brand_id: null,
display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin",
phone_number: (meta?.phone_number as string | null) ?? null,
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,
active: true,
must_change_password: false,
});
}
async function sendWelcomeEmailSafe(input: {
to: string;
name: string;
role: "platform_admin" | "brand_admin" | "store_employee";
password: string;
}): Promise<void> {
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
await sendWelcomeEmail({
to: input.to,
name: input.name,
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
brandName: "Tuxedo Corn",
tempPassword: input.password,
});
} catch {
// welcome email is best-effort; never block user creation
}
// Fetch all admin_users rows (no RLS for service role)
const { data: adminRows, error: adminError } = await service
.from("admin_users")
.select(`
id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
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,
brands (name)
`)
.order("created_at", { ascending: false });
if (adminError) return { users: [], error: adminError.message };
// Fetch auth user details via service role admin API
const { data: authData, error: authError } = await service.auth.admin.listUsers();
if (authError) return { users: [], error: authError.message };
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authData?.users ?? []).forEach((u) => {
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
authMap[user.id] = {
email: user.email ?? "",
display_name: (user.user_metadata?.display_name as string | null) ?? (user.user_metadata?.full_name as string | null) ?? null,
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
}
function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { id: string; email?: string; user_metadata?: Record<string, unknown> }[]): { users: AdminUserRow[]; error: string | null } {
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authUsers ?? []).forEach((u) => {
authMap[u.id] = {
email: u.email ?? "",
display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null,
phone_number: (u.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = adminRows.map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
}
// ─── Production admin actions (require real Supabase auth) ─────────────────
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
// ─── Public actions ─────────────────────────────────────────────────────────
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
let filteredUsers = mockUsers;
if (brandId) {
filteredUsers = mockUsers.filter(u => u.brand_id === brandId);
}
return { users: filteredUsers, error: null };
}
const cookieStore = await cookies();
const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
// Read rc_auth_uid for force-login check
const cookieHeader = headerStore.get("cookie") || "";
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);
}
// 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
);
if (isDevAdmin) {
return devListAdminUsers(rcAuthUid ?? undefined);
}
// Production path: try authenticated RPC first, fall back to service role if not authenticated
const result = await callRpcWithAuth<AdminUserRow[]>("get_admin_users", { p_brand_id: brandId ?? null });
if (result.error === "Not authenticated" && rcAuthUid) {
// No Supabase session token in browser — use service role with rc_auth_uid
const service = getServiceClient();
const { data: adminRows, error: adminError } = await service
.from("admin_users")
.select(`id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
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,
brands (name)`)
.order("created_at", { ascending: false });
if (adminError) return { users: [], error: adminError.message };
const { data: authData } = await service.auth.admin.listUsers();
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authData?.users ?? []).forEach((u) => {
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
authMap[user.id] = {
email: user.email ?? "",
display_name: (user.user_metadata?.display_name as string | null) ?? null,
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
}
return { users: result.data ?? [], error: result.error };
}
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);
}
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
// Dev path: use service role to create user without Supabase auth session
if (isDevAdmin) {
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
if (rcAuthUid) {
const service = getServiceClient();
// Create auth user
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email,
password: input.password,
email_confirm: true,
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
},
});
if (authError || !authUser.user) {
return { user: null, error: authError?.message ?? "Failed to create auth user" };
}
// Insert into admin_users
const { data: inserted, error: insertError } = await service
.from("admin_users")
.insert({
user_id: authUser.user.id,
role: input.role,
brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
can_manage_products: input.flags.can_manage_products ?? false,
can_manage_stops: input.flags.can_manage_stops ?? false,
can_manage_orders: input.flags.can_manage_orders ?? false,
can_manage_pickup: input.flags.can_manage_pickup ?? false,
can_manage_messages: input.flags.can_manage_messages ?? false,
can_manage_refunds: input.flags.can_manage_refunds ?? false,
can_manage_users: input.flags.can_manage_users ?? false,
can_manage_water_log: input.flags.can_manage_water_log ?? false,
can_manage_reports: input.flags.can_manage_reports ?? false,
active: true,
must_change_password: input.mustChangePassword ?? true,
})
.select()
.single();
if (insertError) return { user: null, error: insertError.message };
// Send welcome email
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
await sendWelcomeEmail({
to: input.email,
name: input.display_name || input.email.split("@")[0],
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
brandName: "Tuxedo Corn",
tempPassword: input.password,
});
} catch (e) {
// welcome email failed silently
}
return {
user: {
id: inserted.id,
user_id: inserted.user_id,
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: inserted.phone_number ?? input.phone_number ?? null,
role: inserted.role,
brand_id: inserted.brand_id,
brand_name: null,
can_manage_products: inserted.can_manage_products,
can_manage_stops: inserted.can_manage_stops,
can_manage_orders: inserted.can_manage_orders,
can_manage_pickup: inserted.can_manage_pickup,
can_manage_messages: inserted.can_manage_messages,
can_manage_refunds: inserted.can_manage_refunds,
can_manage_users: inserted.can_manage_users,
can_manage_water_log: inserted.can_manage_water_log,
can_manage_reports: inserted.can_manage_reports,
active: inserted.active,
must_change_password: inserted.must_change_password ?? true,
created_at: inserted.created_at,
last_login: null,
},
users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers,
error: null,
};
}
return { user: null, error: "Not authenticated" };
try {
const sql = brandId
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
au.role, au.brand_id, b.name AS brand_name,
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
au.active, au.must_change_password, au.created_at, au.last_login
FROM admin_users au
LEFT JOIN brands b ON b.id = au.brand_id
WHERE au.brand_id = $1
ORDER BY au.created_at DESC`
: `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
au.role, au.brand_id, b.name AS brand_name,
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
au.active, au.must_change_password, au.created_at, au.last_login
FROM admin_users au
LEFT JOIN brands b ON b.id = au.brand_id
ORDER BY au.created_at DESC`;
const { rows } = await query<Record<string, unknown>>(sql, brandId ? [brandId] : []);
return { users: rows.map(mapUserRow), error: null };
} catch (err) {
return { users: [], error: err instanceof Error ? err.message : String(err) };
}
}
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const newRow: AdminUserRow = {
id: `mock-${Date.now()}`,
user_id: null,
display_name: input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: input.phone_number ?? null,
role: input.role,
brand_id: input.brand_id,
brand_name: null,
can_manage_products: input.flags.can_manage_products ?? false,
can_manage_stops: input.flags.can_manage_stops ?? false,
can_manage_orders: input.flags.can_manage_orders ?? false,
can_manage_pickup: input.flags.can_manage_pickup ?? false,
can_manage_messages: input.flags.can_manage_messages ?? false,
can_manage_refunds: input.flags.can_manage_refunds ?? false,
can_manage_users: input.flags.can_manage_users ?? false,
can_manage_water_log: input.flags.can_manage_water_log ?? false,
can_manage_reports: input.flags.can_manage_reports ?? false,
active: true,
must_change_password: input.mustChangePassword ?? true,
created_at: new Date().toISOString(),
last_login: null,
};
mockUsers.push(newRow);
return { user: newRow, error: null };
}
try {
// No Supabase Auth — `user_id` stays NULL until the user signs in
// via Auth.js and `get_admin_user_for_session` matches them by
// `auth_subject` / `email`. We just insert the row.
const f = input.flags;
const { rows } = await query<Record<string, unknown>>(
`INSERT INTO admin_users
(user_id, display_name, email, phone_number, role, brand_id,
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,
active, must_change_password, auth_provider, auth_subject)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'pending',$17)
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
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,
active, must_change_password, created_at, last_login`,
[
null,
input.display_name ?? input.email.split("@")[0],
input.email.toLowerCase(),
input.phone_number ?? null,
input.role,
input.brand_id,
f.can_manage_products ?? false,
f.can_manage_stops ?? false,
f.can_manage_orders ?? false,
f.can_manage_pickup ?? false,
f.can_manage_messages ?? false,
f.can_manage_refunds ?? false,
f.can_manage_users ?? false,
f.can_manage_water_log ?? false,
f.can_manage_reports ?? false,
input.mustChangePassword ?? true,
input.email.toLowerCase(),
],
);
if (!rows[0]) return { user: null, error: "Insert returned no row" };
await sendWelcomeEmailSafe({
to: input.email,
name: input.display_name ?? input.email.split("@")[0],
role: input.role,
password: input.password,
});
return { user: mapUserRow(rows[0]), error: null };
} catch (err) {
return { user: null, error: err instanceof Error ? err.message : String(err) };
}
}
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) {
const service = getServiceClient();
const { data, error } = await service
.from("admin_users")
.update({
role: input.role ?? undefined,
brand_id: input.brand_id ?? undefined,
can_manage_products: input.flags?.can_manage_products ?? undefined,
can_manage_stops: input.flags?.can_manage_stops ?? undefined,
can_manage_orders: input.flags?.can_manage_orders ?? undefined,
can_manage_pickup: input.flags?.can_manage_pickup ?? undefined,
can_manage_messages: input.flags?.can_manage_messages ?? undefined,
can_manage_refunds: input.flags?.can_manage_refunds ?? undefined,
can_manage_users: input.flags?.can_manage_users ?? undefined,
can_manage_water_log: input.flags?.can_manage_water_log ?? undefined,
can_manage_reports: input.flags?.can_manage_reports ?? undefined,
active: input.active ?? undefined,
display_name: input.display_name ?? null,
phone_number: input.phone_number ?? null,
})
.eq("id", input.id)
.select()
.single();
if (error) return { user: null, error: error.message };
return { user: mapUserRow(data), error: null };
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const idx = mockUsers.findIndex((u) => u.id === input.id);
if (idx === -1) return { user: null, error: "User not found" };
const merged: AdminUserRow = { ...mockUsers[idx] };
if (input.role !== undefined) merged.role = input.role;
if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
if (input.active !== undefined) merged.active = input.active;
if (input.display_name !== undefined) merged.display_name = input.display_name;
if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
if (input.flags) {
for (const [k, v] of Object.entries(input.flags)) {
if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
}
}
mockUsers[idx] = merged;
return { user: merged, error: null };
}
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
p_id: input.id,
p_role: input.role ?? null,
p_brand_id: input.brand_id ?? null,
p_flags: input.flags ?? null,
p_active: input.active ?? null,
p_display_name: input.display_name ?? null,
p_phone_number: input.phone_number ?? null,
});
const rows = result.data as AdminUserRow[] | null;
return { user: rows?.[0] ?? null, error: result.error };
try {
// Build a partial SET clause. Each `can_manage_*` column is set
// individually — the input's `flags` partial is spread across them.
const sets: string[] = [];
const params: unknown[] = [];
const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); };
if (input.role !== undefined) push("role", input.role);
if (input.brand_id !== undefined) push("brand_id", input.brand_id);
if (input.active !== undefined) push("active", input.active);
if (input.display_name !== undefined) push("display_name", input.display_name);
if (input.phone_number !== undefined) push("phone_number", input.phone_number);
if (input.flags) {
for (const [key, val] of Object.entries(input.flags)) {
if (val !== undefined) push(key, val);
}
}
if (sets.length === 0) return { user: null, error: "Nothing to update" };
params.push(input.id);
const sql = `UPDATE admin_users SET ${sets.join(", ")}
WHERE id = $${params.length}
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
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,
active, must_change_password, created_at, last_login`;
const { rows } = await query<Record<string, unknown>>(sql, params);
if (!rows[0]) return { user: null, error: "User not found" };
return { user: mapUserRow(rows[0]), error: null };
} catch (err) {
return { user: null, error: err instanceof Error ? err.message : String(err) };
}
}
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) {
const service = getServiceClient();
// Get user_id first
const { data: adminRow, error: fetchError } = await service
.from("admin_users")
.select("user_id")
.eq("id", id)
.single();
if (fetchError) return { success: false, error: fetchError.message };
// Delete from admin_users
const { error: deleteError } = await service.from("admin_users").delete().eq("id", id);
if (deleteError) return { success: false, error: deleteError.message };
// Delete auth user
if (adminRow?.user_id) {
await service.auth.admin.deleteUser(adminRow.user_id);
}
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const idx = mockUsers.findIndex((u) => u.id === id);
if (idx === -1) return { success: false, error: "User not found" };
mockUsers.splice(idx, 1);
return { success: true, error: null };
}
const result = await callRpcWithAuth<boolean>("delete_admin_user", { p_id: id });
return { success: result.data ?? false, error: result.error };
try {
// No Supabase Auth — nothing to delete from the auth service.
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
return { success: (rowCount ?? 0) > 0, error: null };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
const cookieStore = await cookies();
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 === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
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 };
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const u = mockUsers.find((m) => m.id === userId);
if (!u) return { success: false, error: "User not found" };
u.must_change_password = true;
return { success: true, error: null };
}
// Production path — use service role via direct update
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 };
try {
const { rowCount } = await query(
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
[userId],
);
return { success: (rowCount ?? 0) > 0, error: null };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> {
const { error } = await publicSupabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
});
return { success: !error, error: error?.message ?? null };
/**
* No auth service anymore (no Supabase, no Auth.js password-reset
* endpoint). A platform admin can reset access by deleting +
* re-creating the user, or by toggling `must_change_password` via the
* UI — the function is preserved as a no-op so call sites keep
* compiling.
*/
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
return {
success: false,
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
};
}
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
if (useMockData) {
const brands = mockBrands.map(b => ({ id: b.id, name: b.name }));
return { brands, error: null };
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
}
try {
const { rows } = await query<{ id: string; name: string }>(
`SELECT id, name FROM brands ORDER BY name`,
);
return { brands: rows, error: null };
} catch (err) {
return { brands: [], error: err instanceof Error ? err.message : String(err) };
}
}
const { data, error } = await publicSupabase.from("brands").select("id, name").order("name");
return { brands: data ?? [], error: error?.message ?? null };
}
// Keep `pool` reachable so bundlers don't tree-shake the import — the
// import is for the `server-only` side effect.
void pool;
+1 -3
View File
@@ -21,9 +21,7 @@ type AuditResult =
/**
* Logs an audit event to the audit_logs table.
*
* In dev mode (dev_session cookie), uses the dev user identity.
* In production (Supabase auth), resolves the admin user from admin_users.
*
* Resolves the admin user from the Auth.js session via getAdminUser().
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
*/
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
+44
View File
@@ -0,0 +1,44 @@
"use server";
import "server-only";
import { signIn, signOut } from "@/lib/auth";
import { redirect } from "next/navigation";
/**
* 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 /admin.
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
/**
* Sign in with email + password. The `credentials` provider is enabled
* in dev (see `isDevLoginEnabled()` in `src/auth.config.ts`); in
* production it is omitted entirely and this action returns an
* `AuthError` (Auth.js surfaces `?error=Configuration` on /login).
*
* On a failed credential check Auth.js redirects back to
* /login?error=CredentialsSignin, which the LoginClient renders as
* "Invalid email or password."
*/
export async function signInWithCredentials(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const password = String(formData.get("password") ?? "");
if (!email || !password) {
redirect("/login?error=MissingCredentials");
}
await signIn("credentials", {
email,
password,
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
@@ -110,22 +110,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 [];
}
}
/**
@@ -155,24 +163,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 [];
}
}
-60
View File
@@ -1,60 +0,0 @@
import { cookies } from "next/headers";
export default async function DebugAuthPage() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
let adminUsersStatus = "not_tried";
let adminUsersResult: string | null = null;
if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && serviceKey) {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
adminUsersStatus = String(res.status);
const data = await res.json().catch(() => null);
adminUsersResult = JSON.stringify(data);
} catch (e) {
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
}
} else {
adminUsersStatus = "missing_env_vars";
}
} else {
adminUsersStatus = "no_rc_auth_uid_cookie";
}
return (
<div className="min-h-screen bg-zinc-950 p-8 text-white">
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
</div>
</div>
);
}
+10 -41
View File
@@ -2,9 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { AdminUserRow } from "@/actions/admin/users";
import { logUserActivity } from "@/actions/admin/audit";
type ProfilePageProps = {
currentUser: AdminUserRow;
@@ -21,53 +19,24 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
const [newEmail, setNewEmail] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
// Profile / email mutations used to call Supabase directly. With the
// platform moved off Supabase entirely, those handlers are stubbed out
// — the page remains read-only until a server-action equivalent ships.
// See the final YOLO report for the broader Supabase → pg data-fetch
// migration that covers the rest of the admin pages.
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const { error: rpcError } = await supabase.rpc("update_admin_user", {
p_id: currentUser.id,
p_display_name: displayName || null,
p_phone_number: phoneNumber || null,
});
if (rpcError) {
setError(rpcError.message);
return;
}
await logUserActivity({
user_id: currentUser.user_id,
activity_type: "profile_update",
details: { fields: ["display_name", "phone_number"] },
});
setEditing(false);
} finally {
setSaving(false);
}
setError("Profile editing is temporarily unavailable. Contact a platform admin.");
setSaving(false);
}
async function handleEmailChange(e: React.FormEvent) {
e.preventDefault();
setChangingEmail(true);
setEmailError(null);
try {
const { error: updateError } = await supabase.auth.updateUser({
email: newEmail,
});
if (updateError) {
setEmailError(updateError.message);
return;
}
await logUserActivity({
user_id: currentUser.user_id,
activity_type: "email_change",
details: { new_email: newEmail },
});
setEmailChangeSent(true);
setChangingEmail(false);
} finally {
setChangingEmail(false);
}
setEmailError("Email changes are temporarily unavailable. Contact a platform admin.");
setChangingEmail(false);
}
return (
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
// Uses cookies() via getAdminUser — must be dynamic to avoid the
// "couldn't be rendered statically" build error.
export const dynamic = "force-dynamic";
export default async function SquareSyncSettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
-90
View File
@@ -1,90 +0,0 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export default async function TestAuthPage() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
let adminUser = null;
let error: string | null = null;
try {
adminUser = await getAdminUser();
} catch (e: unknown) {
error = e instanceof Error ? e.message : String(e);
}
return (
<div className="min-h-screen bg-zinc-950 p-8 text-white">
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
<p className="text-lg font-mono">
{allCookies.some(c => c.name === "rc_auth_uid")
? <span className="text-emerald-400">SET {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
: <span className="text-red-400">NOT SET</span>
}
</p>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
<p className="text-lg font-mono">
{allCookies.some(c => c.name === "rc_access_token")
? <span className="text-yellow-400">SET (not needed)</span>
: <span className="text-zinc-500">NOT SET (OK)</span>
}
</p>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
{error ? (
<div>
<p className="text-red-400 font-bold">ERROR</p>
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
</div>
) : adminUser ? (
<div>
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
{JSON.stringify({
id: adminUser.id,
user_id: adminUser.user_id,
role: adminUser.role,
brand_id: adminUser.brand_id,
active: adminUser.active,
}, null, 2)}
</pre>
</div>
) : (
<p className="text-red-400">NOT AUTHENTICATED null returned</p>
)}
</div>
<div className="mt-8 pt-4 border-t border-zinc-800">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
<div className="flex gap-4">
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
Go to Admin
</a>
<form action="/api/logout" method="POST">
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
Logout
</button>
</form>
</div>
</div>
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export default async function TestPage() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
let adminUser = null;
let adminUserError: string | null = null;
try {
adminUser = await getAdminUser();
} catch (e: any) {
adminUserError = e?.message ?? String(e);
}
return (
<div className="min-h-screen bg-zinc-950 p-8 text-white">
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Server cookies ({allCookies.length})</h2>
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}...`).join("\n") || "(none)"}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token present?</h2>
<p className="text-lg font-mono">
{allCookies.some(c => c.name === "rc_access_token")
? <span className="text-emerald-400">YES {allCookies.find(c => c.name === "rc_access_token")?.value.length} chars</span>
: <span className="text-red-400">NO</span>
}
</p>
</div>
<div>
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
{adminUserError ? (
<p className="text-red-400">ERROR: {adminUserError}</p>
) : (
<pre className="bg-black/50 p-4 rounded-xl text-sm overflow-auto">
{JSON.stringify(adminUser, null, 2)}
</pre>
)}
</div>
</div>
);
}
+2 -14
View File
@@ -1,16 +1,4 @@
// Auth.js v5 route handler — re-exports the GET/POST handlers from src/lib/auth.ts
// Mounted at /api/auth/* (signin, signout, callback, session, csrf, providers, etc.)
import { handlers } from "@/lib/auth";
/**
* Auth.js v5 catch-all route handler. Exposes:
* GET /api/auth/signin
* GET /api/auth/signout
* GET /api/auth/session
* GET /api/auth/csrf
* GET /api/auth/providers
* POST /api/auth/callback/:provider
* POST /api/auth/signin/:provider
* POST /api/auth/signout
*
* The actual OAuth + session logic is in `src/lib/auth.ts`.
*/
export const { GET, POST } = handlers;
-11
View File
@@ -1,11 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const uid =
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value ??
null;
return NextResponse.json({ uid });
}
-45
View File
@@ -1,45 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
if (!serviceKey || !supabaseUrl) {
return NextResponse.json({ error: "Missing env vars", serviceKey: !!serviceKey, supabaseUrl: !!supabaseUrl }, { status: 500 });
}
// Test 1: just a simple health endpoint that doesn't require the key
let healthResult = null;
try {
const res = await fetch(`${supabaseUrl}/rest/v1/`, {
headers: { apikey: serviceKey },
});
healthResult = { status: res.status, ok: res.ok };
} catch (e: any) {
healthResult = { error: e?.message };
}
// Test 2: try admin_users with POST (bypasses RLS select policies)
let adminResult = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?select=id,user_id,role,email,display_name&limit=5`,
{
method: "GET",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
}
);
const body = await res.text();
let parsed = null;
try { parsed = JSON.parse(body); } catch { parsed = body; }
adminResult = { status: res.status, body: parsed };
} catch (e: any) {
adminResult = { error: e?.message };
}
return NextResponse.json({ healthResult, adminResult, serviceKeyLen: serviceKey.length });
}
-48
View File
@@ -1,48 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
let adminUsersStatus = "not_tried";
let adminUsersResult: string | null = null;
if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && serviceKey) {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
adminUsersStatus = String(res.status);
const data = await res.json().catch(() => null);
adminUsersResult = JSON.stringify(data);
} catch (e) {
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
}
} else {
adminUsersStatus = "missing_env_vars";
}
} else {
adminUsersStatus = "no_rc_auth_uid_cookie";
}
return NextResponse.json({
cookies: {
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
rc_access_token: rcAccessToken ? "present" : null,
all_cookie_names: allCookies.map(c => c.name),
},
admin_users: {
status: adminUsersStatus,
result: adminUsersResult,
},
});
}
-18
View File
@@ -1,18 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
return NextResponse.json({
received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })),
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING",
rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING",
raw_rc_auth_uid: rcAuthUid ?? null,
});
}
-31
View File
@@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const nodeEnv = process.env.NODE_ENV;
const result = {
NODE_ENV: nodeEnv,
NEXT_PUBLIC_SUPABASE_URL: url ? `${url.substring(0, 40)}... (SET)` : "MISSING",
NEXT_PUBLIC_SUPABASE_ANON_KEY: key ? `${key.substring(0, 20)}... (SET)` : "MISSING",
SUPABASE_SERVICE_ROLE_KEY: serviceKey ? `${serviceKey.substring(0, 20)}... (SET)` : "MISSING",
supabaseClientCanCreate: false as boolean,
error: null as string | null,
};
if (url && key) {
try {
const { createClient } = await import("@supabase/supabase-js");
const client = createClient(url, key);
result.supabaseClientCanCreate = true;
} catch (e: any) {
result.error = e?.message ?? String(e);
}
} else {
result.error = "Missing env vars";
}
return NextResponse.json(result, { status: 200 });
}
@@ -1,34 +0,0 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
export async function GET() {
// Test the REST call directly from this endpoint
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uid = "c023efb3-e3ef-4156-bed6-d17b92ea8aca";
let restResult = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
const data = await res.json().catch(() => null);
restResult = { status: res.status, data, keyLen: serviceKey.length };
} catch (e: any) {
restResult = { error: e?.message };
}
const adminUser = await getAdminUser();
return NextResponse.json({
adminUser: adminUser ? {
id: adminUser.id,
user_id: adminUser.user_id,
role: adminUser.role,
brand_id: adminUser.brand_id,
active: adminUser.active,
} : null,
restResult,
supabaseUrl: supabaseUrl ? "SET" : "MISSING",
});
}
-9
View File
@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
ts: new Date().toISOString(),
deployment: "debug-hello",
msg: "hello from latest build"
});
}
-80
View File
@@ -1,80 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
if (!uid) {
return NextResponse.json({ error: "No uid cookie found" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) {
return NextResponse.json({
uid,
error: "Missing env vars",
supabaseUrl: supabaseUrl ?? "MISSING",
serviceKeyPresent: !!serviceKey,
});
}
// Exact same lookup as getAdminUser
const lookupRes = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
let adminUsers: unknown[] = [];
let lookupOk = lookupRes.ok;
let lookupStatus = lookupRes.status;
let lookupData: unknown = null;
if (lookupRes.ok) {
lookupData = await lookupRes.json().catch(() => []);
adminUsers = Array.isArray(lookupData) ? lookupData : [];
} else {
lookupData = await lookupRes.text().catch(() => "unknown error");
}
if (adminUsers.length > 0) {
return NextResponse.json({
uid,
result: "found",
adminUser: adminUsers[0],
});
}
// Try auto-create
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 NextResponse.json({ uid, result: "invalid_uuid" });
}
const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({
user_id: uid, role: "platform_admin", brand_id: null, 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
}),
});
return NextResponse.json({
uid,
result: "auto_created",
lookupOk,
lookupStatus,
adminUsersFound: adminUsers.length,
postStatus: postRes.status,
postOk: postRes.ok,
postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null),
});
}
-21
View File
@@ -1,21 +0,0 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
response.cookies.set("dev_session", "platform_admin", {
path: "/",
sameSite: "lax",
httpOnly: false,
});
return response;
}
export async function GET() {
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
response.cookies.set("dev_session", "platform_admin", {
path: "/",
sameSite: "lax",
httpOnly: false,
});
return response;
}
-31
View File
@@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"];
export async function GET(request: Request) {
const url = new URL(request.url);
const role = url.searchParams.get("role") ?? "platform_admin";
const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin";
const origin = url.origin;
const response = NextResponse.redirect(new URL("/admin", origin));
const cookieOptions = {
path: "/",
maxAge: 60 * 60 * 24 * 30,
sameSite: "lax" as const,
};
response.cookies.set("dev_session", safeRole, {
...cookieOptions,
httpOnly: false,
});
response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, {
...cookieOptions,
httpOnly: false,
});
return response;
}
-47
View File
@@ -1,47 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const { email, password } = await request.json().catch(() => ({}));
if (!email || !password) {
return NextResponse.json({ error: "Email and password are required." }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
}
const authRes = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { "Content-Type": "application/json", apikey: supabaseAnonKey },
body: JSON.stringify({ email, password }),
});
const authData = await authRes.json().catch(() => null);
if (!authRes.ok || !authData?.access_token) {
const msg = authData?.error_description ?? authData?.error ?? "Invalid credentials.";
return NextResponse.json({ ok: false, error: msg }, { status: 401 });
}
const userId = authData.user?.id ?? authData.user_id;
if (!userId) {
return NextResponse.json({ ok: false, error: "Auth succeeded but user ID missing." }, { status: 500 });
}
// Set cookie + return JSON — client reads this and navigates
const isProd = process.env.NODE_ENV === "production";
const response = NextResponse.json({ ok: true });
response.cookies.set("rc_auth_uid", userId, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: isProd,
});
return response;
}
-12
View File
@@ -1,12 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST() {
const cookieStore = await cookies();
// Clear all auth cookies (new + legacy)
cookieStore.delete("rc_access_token");
cookieStore.delete("rc_uid");
cookieStore.delete("rc_auth_uid");
cookieStore.delete("rc_auth_token");
return NextResponse.json({ success: true });
}
-22
View File
@@ -1,22 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const { userId } = await request.json().catch(() => ({}));
if (!userId) {
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
}
const cookieStore = await cookies();
const isProd = process.env.NODE_ENV === "production";
cookieStore.set("rc_auth_uid", userId, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: isProd ? "strict" : "lax",
secure: isProd,
});
return NextResponse.json({ ok: true });
}
-43
View File
@@ -1,43 +0,0 @@
import { createClient } from "@supabase/supabase-js";
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
export async function GET() {
// Try creating supabase client — if it throws, capture the exact error
let status: string;
let canCreate = false;
let errMessage = "";
if (!url || !key) {
status = "MISSING_ENV_VARS";
errMessage = `url=${url ? "SET" : "MISSING"}, key=${key ? "SET" : "MISSING"}`;
} else {
try {
createClient(url, key);
canCreate = true;
status = "OK";
} catch (e: any) {
errMessage = e?.message ?? String(e);
status = "CREATE_CLIENT_FAILED";
}
}
const body = JSON.stringify({
status,
canCreate,
errMessage,
envVars: {
NODE_ENV: process.env.NODE_ENV,
urlSet: !!url,
urlPrefix: url ? url.substring(0, 30) : null,
keySet: !!key,
keyPrefix: key ? key.substring(0, 10) : null,
},
}, null, 2);
return new Response(body, {
status: status === "OK" ? 200 : 500,
headers: { "Content-Type": "application/json" },
});
}
-54
View File
@@ -1,54 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
export default function AuthCallback() {
const router = useRouter();
useEffect(() => {
const url = new URL(window.location.href);
// Supabase sends token via query params (not hash) on redirect
const accessToken = url.searchParams.get("token") || url.searchParams.get("access_token");
const type = url.searchParams.get("type");
const error = url.searchParams.get("error");
if (error) {
router.replace(`/login?error=${error}`);
return;
}
if (!accessToken) {
router.replace("/login?error=no_token");
return;
}
// Validate token by fetching user info from Supabase
fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/user`, {
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
Authorization: `Bearer ${accessToken}`,
},
})
.then(r => r.json())
.then(data => {
if (data?.id) {
// Set rc_auth_uid cookie via API route
return fetch("/api/set-auth-cookie", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: data.id }),
});
}
throw new Error("No user ID in response");
})
.then(() => router.replace("/admin"))
.catch(() => router.replace("/login?error=token_invalid"));
}, [router]);
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="text-zinc-400">Verifying reset link...</div>
</div>
);
}
+8 -37
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { updatePasswordAction } from "@/actions/admin/password";
@@ -12,22 +12,6 @@ export default function ChangePasswordPage() {
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [checkingSession, setCheckingSession] = useState(true);
const [userId, setUserId] = useState<string | null>(null);
useEffect(() => {
fetch("/api/auth/uid")
.then((r) => r.json())
.then((data) => {
if (!data.uid) {
router.push("/login");
} else {
setUserId(data.uid);
setCheckingSession(false);
}
})
.catch(() => router.push("/login"));
}, [router]);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -51,30 +35,17 @@ export default function ChangePasswordPage() {
return;
}
if (userId) {
logUserActivity({
user_id: userId,
activity_type: "password_change",
details: {},
});
}
// Audit log (best-effort — the password change itself was the source of truth)
logUserActivity({
user_id: result.userId ?? "unknown",
activity_type: "password_change",
details: {},
}).catch(() => {});
router.push("/admin");
router.refresh();
}
if (checkingSession) {
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 text-center">
<p className="text-zinc-500 text-sm">Checking session...</p>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
<div className="w-full max-w-md">
@@ -147,4 +118,4 @@ export default function ChangePasswordPage() {
</div>
</main>
);
}
}
-20
View File
@@ -1,20 +0,0 @@
"use client";
export default function DevLoginPage() {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 shadow-xl">
<h1 className="text-2xl font-bold text-zinc-100 mb-4">Dev Login</h1>
<p className="text-zinc-400 mb-6">Click below to login as platform admin:</p>
<form action="/api/dev-login" method="POST">
<button
type="submit"
className="w-full rounded-xl bg-emerald-600 px-6 py-4 text-base font-bold text-white hover:bg-emerald-500 transition-colors"
>
Login as Platform Admin
</button>
</form>
</div>
</div>
);
}
+168 -506
View File
@@ -1,546 +1,208 @@
"use client";
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
import { useState, useTransition } from "react";
import { signInWithGoogle, signInWithCredentials } from "@/actions/auth-actions";
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [globalError, setGlobalError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [forgotPassword, setForgotPassword] = useState(false);
const [forgotEmail, setForgotEmail] = useState("");
const [forgotSent, setForgotSent] = useState(false);
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
type LoginClientProps = {
hasGoogle: boolean;
hasCredentials: boolean;
/** Server-rendered error message, if any (from ?error=...) */
error: string | null;
/** Pre-fill the email in dev (for the seeded admin). */
seededEmail?: string;
/** Where to send the user after a successful sign-in. */
redirectTo?: string;
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setGlobalError(null);
if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
if (!password.trim()) { setGlobalError("Please enter your password."); return; }
setLoading(true);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), password }),
});
const data = await res.json().catch(() => ({ error: "Login failed" }));
if (res.ok && data?.ok) {
window.location.replace("/admin");
} else {
setGlobalError(data?.error || `Login failed (${res.status})`);
}
} catch {
setGlobalError("Network error. Please try again.");
} finally {
setLoading(false);
}
}, [email, password]);
const handleForgotPassword = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!forgotEmail.trim()) return;
setForgotLoading(true);
setForgotError(null);
const fd = new FormData();
fd.set("email", forgotEmail.trim());
const result = await fetch("/api/forgot-password", {
method: "POST",
body: fd,
}).then(r => r.json()).catch(() => ({ error: "Network error" }));
setForgotLoading(false);
if (result.error) {
setForgotError(result.error);
} else {
setForgotSent(true);
}
}, [forgotEmail]);
function GoogleSignIn({ hasGoogle }: { hasGoogle: boolean }) {
if (!hasGoogle) {
return (
<div className="rounded-xl border border-stone-200/80 bg-stone-50 p-4 text-sm text-stone-700">
<p className="font-medium">Google sign-in is not configured.</p>
<p className="mt-1 text-stone-600">
Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
<code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
environment to enable it.
</p>
</div>
);
}
return (
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
{/* Google Fonts */}
<form action={signInWithGoogle}>
<button
type="submit"
className="w-full flex items-center justify-center gap-3 rounded-xl bg-white border border-stone-200/80 px-6 py-3.5 text-sm font-semibold text-stone-800 hover:bg-stone-50 active:scale-[0.98] transition-all shadow-sm"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0012 23z" />
<path fill="#FBBC05" d="M5.84 14.1A6.6 6.6 0 015.5 12c0-.73.13-1.44.34-2.1V7.07H2.18A10.99 10.99 0 001 12c0 1.77.43 3.45 1.18 4.93l3.66-2.83z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.83C6.71 7.31 9.14 5.38 12 5.38z" />
</svg>
Continue with Google
</button>
</form>
);
}
function CredentialsForm({
seededEmail,
error,
}: {
seededEmail?: string;
error: string | null;
}) {
const [isPending, startTransition] = useTransition();
const [localError, setLocalError] = useState<string | null>(null);
return (
<form
action={(formData) => {
setLocalError(null);
startTransition(async () => {
try {
await signInWithCredentials(formData);
} catch (err) {
// Auth.js throws NEXT_REDIRECT on success — that's the normal
// flow. We only care about non-redirect errors here.
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes("NEXT_REDIRECT")) {
setLocalError("Sign-in failed. Please try again.");
}
}
});
}}
className="space-y-3"
>
<label className="block">
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Email
</span>
<input
name="email"
type="email"
required
autoComplete="username"
defaultValue={seededEmail ?? ""}
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
placeholder="you@example.com"
/>
</label>
<label className="block">
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Password
</span>
<input
name="password"
type="password"
required
autoComplete="current-password"
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
placeholder="••••••••"
/>
</label>
{(error || localError) && (
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
{error ?? localError}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
background: isPending
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
}}
>
{isPending ? "Signing in…" : "Sign in with email"}
</button>
</form>
);
}
function Divider() {
return (
<div className="flex items-center gap-3 my-5" aria-hidden="true">
<div className="flex-1 h-px bg-stone-200/80" />
<span className="text-xs uppercase tracking-wider text-stone-400" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
or
</span>
<div className="flex-1 h-px bg-stone-200/80" />
</div>
);
}
export default function LoginClient({
hasGoogle,
hasCredentials,
error,
seededEmail,
}: LoginClientProps) {
// Render the Google button first (or a setup message), then the divider,
// then the credentials form if dev login is enabled. The order is the
// most-common-first progression: prod users see Google; dev users
// see Google at top, email/password below as the fast path.
return (
<main
className="min-h-screen flex flex-col relative overflow-hidden"
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
>
<style jsx global>{`
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
html, body { overflow: hidden; }
`}</style>
{/* Organic background elements */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} />
</div>
{/* Header */}
<header className="w-full py-6 px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
Route Commerce
</span>
</Link>
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
Back to home
</Link>
</div>
</header>
{/* Login Card */}
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
<div className={`w-full max-w-sm transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"}`}>
{/* Card */}
<div className="w-full max-w-sm">
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
{/* Subtle top accent */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
<div className="p-8 sm:p-10">
{/* Logo & Title */}
<div className="text-center mb-8">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
<div
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
style={{
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
}}
>
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
<h1
className="text-3xl font-semibold text-stone-900"
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
>
Welcome back
</h1>
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
<p
className="mt-2 text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
>
Sign in to your account
</p>
</div>
{/* Auth.js v5 — primary sign-in: Google OAuth */}
<form action={signInWithGoogle} className="space-y-3">
<button
type="submit"
className="w-full inline-flex items-center justify-center gap-3 rounded-xl border border-stone-200/80 bg-white px-6 py-3.5 text-sm font-semibold text-stone-900 shadow-sm transition-all hover:bg-stone-50 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
aria-label="Sign in with Google"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"
/>
</svg>
<span>Sign in with Google</span>
</button>
</form>
{/* Dev login (only visible in development) */}
{process.env.NODE_ENV !== "production" && (
<form action={signInWithDev} className="space-y-3">
<div className="rounded-xl bg-amber-50/70 border border-amber-200/60 px-3 py-2 text-xs text-amber-900">
<strong>Dev login</strong> only available in development.
Set <code>ALLOW_DEV_LOGIN=false</code> in <code>.env.local</code> to hide.
</div>
<div className="grid grid-cols-2 gap-2">
<input
name="username"
type="text"
defaultValue="admin"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Username"
aria-label="Dev username"
/>
<input
name="password"
type="password"
defaultValue="dev"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Password"
aria-label="Dev password"
/>
</div>
<button
type="submit"
className="w-full rounded-xl bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
>
Dev sign in (no Google required)
</button>
</form>
)}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-stone-200/70" />
</div>
<div className="relative flex justify-center text-xs uppercase tracking-wider">
<span className="bg-white/0 px-2 text-stone-400" style={{ background: "linear-gradient(to right, transparent, white 30%, white 70%, transparent)" }}>
or sign in with email
</span>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
{globalError && (
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{globalError}
</div>
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
disabled={loading}
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
placeholder="you@company.com"
aria-required="true"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Password</label>
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 pr-12 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
placeholder="••••••••"
aria-required="true"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600 p-1 transition-colors"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
>
{loading ? (
<>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</>
) : "Sign in"}
</button>
{!forgotPassword && !forgotSent && (
<button
type="button"
onClick={() => { setForgotPassword(true); setGlobalError(null); }}
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
>
Forgot password?
</button>
)}
</form>
{/* Password Reset Form */}
{forgotPassword && !forgotSent && (
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4 border-t border-stone-200/50 pt-6" aria-label="Password reset form">
<p className="text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b6b6b" }}>Enter your email and we&apos;ll send you a reset link.</p>
{forgotError && (
<div role="alert" className="rounded-xl bg-red-50/80 p-3 text-sm text-red-600 border border-red-100">
{forgotError}
</div>
)}
<input
type="email"
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
required
className="w-full rounded-xl border border-stone-200/80 bg-white/90 px-4 py-3.5 text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400 transition-all"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
placeholder="you@company.com"
aria-required="true"
/>
<button
type="submit"
disabled={forgotLoading}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
>
{forgotLoading ? "Sending..." : "Send Reset Link"}
</button>
<button
type="button"
onClick={() => { setForgotPassword(false); setForgotEmail(""); setForgotError(null); }}
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
>
Back to sign in
</button>
</form>
)}
{/* Reset Email Sent */}
{forgotSent && (
<div className="mt-6 border-t border-stone-200/50 pt-6" role="status" aria-live="polite">
<div className="rounded-xl p-4 text-sm border" style={{ backgroundColor: "#f0fdf4", color: "#166534", borderColor: "#bbf7d0" }}>
<strong>Check your inbox.</strong> If an account exists for <span className="font-medium">{forgotEmail}</span>, a reset link has been sent.
</div>
<button
type="button"
onClick={() => { setForgotPassword(false); setForgotSent(false); setForgotEmail(""); }}
className="mt-4 w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
>
Back to sign in
</button>
</div>
)}
<GoogleSignIn hasGoogle={hasGoogle} />
{hasCredentials && hasGoogle && <Divider />}
{hasCredentials && <CredentialsForm seededEmail={seededEmail} error={error} />}
</div>
{/* Security Trust Badges */}
<div className="border-t border-stone-100/50 px-8 py-5" style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-center gap-4 text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>256-bit SSL</span>
</div>
<span className="text-stone-300"></span>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span>SOC 2</span>
</div>
<span className="text-stone-300"></span>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#6b8f71"/>
</svg>
<span>Powered by Supabase</span>
</div>
</div>
</div>
</div>
</div>
{/* Back link */}
<div className="text-center mt-6">
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
View Farms
</Link>
</div>
</div>
</div>
{/* Footer */}
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
© {new Date().getFullYear()} Route Commerce
</span>
</div>
<nav className="flex items-center gap-6">
<Link href="/privacy-policy" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
Privacy
</Link>
<Link href="/terms-and-conditions" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
Terms
</Link>
</nav>
</div>
</footer>
</main>
);
}
// Demo mode wrapper
function DemoMode() {
return (
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
{/* Organic background elements */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
</div>
{/* Header */}
<header className="w-full py-6 px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
Route Commerce
</span>
</Link>
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
Back to home
</Link>
</div>
</header>
{/* Demo Card */}
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
<div className="w-full max-w-sm">
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden p-10">
<div className="text-center mb-8">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
Demo Mode
</h1>
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
Select a role to explore the platform
</p>
</div>
<div className="flex flex-col gap-3">
<button
onClick={() => { document.cookie = "dev_session=platform_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
>
Platform Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=brand_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#c97a3e" }}
>
Brand Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=store_employee; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#6b8f71" }}
>
Store Employee
</button>
</div>
</div>
{/* Back link */}
<div className="text-center mt-6">
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
View Farms
</Link>
</div>
</div>
</div>
{/* Footer */}
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
© {new Date().getFullYear()} Route Commerce
</span>
</div>
</div>
</footer>
</main>
);
}
// Inner component that uses useSearchParams - must be wrapped in Suspense
function LoginPageInner() {
const searchParams = useSearchParams();
const isDemo = searchParams.get("demo") === "1";
if (isDemo) return <DemoMode />;
return <LoginForm />;
}
export default function LoginClient() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-emerald-600 to-emerald-500 animate-pulse" />
<p className="text-stone-500">Loading...</p>
</div>
</div>
}>
<LoginPageInner />
</Suspense>
);
}
+30 -3
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import LoginClient from "./LoginClient";
import { isDevLoginEnabled } from "@/auth.config";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
@@ -21,6 +22,32 @@ export const metadata: Metadata = {
},
};
export default function LoginPage() {
return <LoginClient />;
}
type SearchParams = { error?: string; redirect?: string };
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
// The Google provider is only added to the Auth.js config when these
// two env vars are set. Pass the flag down so the client can hide the
// button (and surface a helpful message) when Google is unavailable.
const hasGoogle = !!(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET);
const hasCredentials = isDevLoginEnabled();
const params = await searchParams;
const error =
params?.error === "CredentialsSignin" || params?.error === "MissingCredentials"
? "Invalid email or password."
: params?.error
? "Sign-in failed. Please try again."
: null;
return (
<LoginClient
hasGoogle={hasGoogle}
hasCredentials={hasCredentials}
error={error}
seededEmail={hasCredentials ? "admin@route-commerce.local" : undefined}
redirectTo={params?.redirect ?? "/admin"}
/>
);
}
-105
View File
@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function LoginPage2() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (!email.trim()) { setError("Email is required."); return; }
if (!password.trim()) { setError("Password is required."); return; }
setLoading(true);
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), password }),
});
setLoading(false);
if (res.status === 303) {
window.location.href = "/admin";
} else {
const data = await res.json().catch(() => ({ error: "Login failed" }));
setError(data.error || "Login failed.");
}
}
return (
<main className="min-h-screen bg-slate-100 px-6 py-12">
<div className="mx-auto max-w-md">
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200">
<h1 className="text-2xl font-bold text-slate-900">Admin Login</h1>
<p className="mt-1 text-sm text-slate-500">Sign in to your account.</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div role="alert" className="rounded-xl bg-red-50 p-4 text-sm text-red-700 border border-red-100">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
disabled={loading}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
placeholder="admin@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50 flex items-center justify-center gap-3"
>
{loading ? (
<>
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</>
) : "Sign In"}
</button>
</form>
</div>
<Link href="/" className="mt-4 block text-sm text-slate-500 hover:text-slate-700">
Back to storefront
</Link>
</div>
</main>
);
}
+7 -39
View File
@@ -1,41 +1,9 @@
"use client";
// Server-side logout. Signs the user out of the Auth.js v5 session and
// redirects to /login. The previous client-side implementation (which
// called Supabase auth.signOut) was replaced with this so logout goes
// through the same auth path the rest of the app uses.
import { signOut } from "@/lib/auth";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
export default function LogoutPage() {
const router = useRouter();
useEffect(() => {
// Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
document.cookie = "dev_session=;path=/;max-age=0";
document.cookie = "rc_auth_uid=;path=/;max-age=0";
document.cookie = "rc_auth_token=;path=/;max-age=0";
// Clear shopping cart on logout
localStorage.removeItem("route_commerce_cart");
localStorage.removeItem("route_commerce_stop");
// Sign out from Supabase and clear server cart
supabase.auth.getUser().then(async ({ data }) => {
if (data.user?.id) {
const { clearServerCart } = await import("@/actions/checkout");
clearServerCart(data.user.id).catch(() => {});
}
supabase.auth.signOut().then(() => {
router.push("/login");
router.refresh();
});
});
}, [router]);
return (
<main className="min-h-screen bg-slate-100 px-6 py-12">
<div className="mx-auto max-w-md">
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center">
<p className="text-slate-500">Signing out...</p>
</div>
</div>
</main>
);
export default async function LogoutPage() {
await signOut({ redirectTo: "/login" });
}
+67 -64
View File
@@ -1,105 +1,108 @@
import type { NextAuthConfig } from "next-auth";
import type { NextAuthConfig, DefaultSession } from "next-auth";
import Google from "next-auth/providers/google";
/**
* Edge-compatible Auth.js v5 configuration.
* Edge-safe Auth.js v5 configuration.
*
* This file is imported by `src/middleware.ts`, which runs in the Edge runtime.
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
* This file is imported by `src/middleware.ts`, which runs in the Edge
* runtime. It must NOT import:
* - `pg` / `@auth/pg-adapter` (Node-only)
* - `next-auth/providers/credentials` (uses Node `crypto` internally)
* - The Drizzle client
* - Anything else that touches the database or Node-only APIs
*
* If you need to add a provider that uses Node-only APIs (e.g. an adapter
* implementation), define it in `src/lib/auth.ts` instead and add a thin
* placeholder here so the middleware can still reference it.
* Provider definitions, callbacks, and pages all live here. The full
* server-side handler in `src/lib/auth.ts` extends this with the
* Credentials provider (Node-only) for email + password sign-in.
*
* Both instances share the same session cookie, so the middleware can
* read JWTs minted by the server-side handler.
*/
const isDev = process.env.NODE_ENV !== "production";
const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}
const hasGoogleCreds = !!(
process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
);
const googleProvider = hasGoogleCreds
? [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
]
: [];
export const authConfig = {
// Custom sign-in page (must exist at /login)
pages: {
signIn: "/login",
},
// Trust the host header in dev for callback URLs
trustHost: true,
// Providers — referenced from middleware edge runtime.
// The Google provider only needs the env vars at runtime; it does not pull
// in any Node-only code. The dev Credentials provider is added in
// `src/lib/auth.ts` (server-side only) — it's not safe to import
// `next-auth/providers/credentials` from the edge runtime.
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
clientSecret:
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
// No `authorization` override — we want the default scopes (openid email profile)
}),
],
// New users are persisted in the database (handled in src/lib/auth.ts)
// Default to JWT here so middleware can run in edge runtime; the full
// server-side handler in src/lib/auth.ts switches this to "database".
session: { strategy: "jwt" },
pages: { signIn: "/login" },
session: { strategy: "jwt" as const },
providers: googleProvider,
callbacks: {
/**
* Gate /admin routes. Anything not on the public list and not signed in
* gets redirected to /login. This mirrors what the page-level checks do,
* but runs first at the edge so unauthorized requests never hit the
* server component tree.
* Gate /admin, /wholesale, and /protected-example. Anything on those
* paths and not signed in is redirected to /login (the default
* pages.signIn). Public storefronts and the homepage pass through.
*
* The actual role-based gating still happens in `getAdminUser()` —
* this callback only ensures the user is *signed in*.
*/
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnWholesale = nextUrl.pathname.startsWith("/wholesale");
const isOnProtectedExample = nextUrl.pathname.startsWith(
"/protected-example"
);
if (isOnAdmin) {
if (isLoggedIn) return true;
return false; // Redirect to /login
if (isOnAdmin || isOnWholesale || isOnProtectedExample) {
return isLoggedIn;
}
if (isOnProtectedExample) {
if (isLoggedIn) return true;
return false;
}
return true;
},
/**
* Forward the user id from the database user record into the JWT on
* initial sign-in. With database sessions this is what populates
* `session.user.id` for downstream server actions.
* Forward the user id into the JWT on initial sign-in. With
* `session.strategy: "jwt"` this is the field downstream code reads
* via `session.user.id`.
*/
async jwt({ token, user }) {
if (user) {
token.id = (user as { id?: string }).id ?? token.sub;
if (user.id) token.id = user.id;
if (user.email) token.email = user.email;
}
return token;
},
async session({ session, token }) {
if (session.user && token?.sub) {
(session.user as { id?: string }).id = token.sub;
if (session.user) {
session.user.id =
(typeof token.id === "string" && token.id) ||
(typeof token.sub === "string" && token.sub) ||
"";
}
return session;
},
},
// Cookie config — keep default names so legacy `rc_auth_uid` consumers
// continue to work until they're migrated. New Auth.js cookies default to
// `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod).
} satisfies NextAuthConfig;
/**
* Helper: are we in development AND allowed to use the dev credentials
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
* include the provider in its provider list.
* Is the dev Credentials provider enabled?
* - Disabled in production (NODE_ENV === "production")
* - Can be force-disabled by setting `ALLOW_DEV_LOGIN=false`
*
* Surfaced so `src/lib/auth.ts` can decide whether to include the
* Credentials provider in its providers list.
*/
export function isDevLoginEnabled(): boolean {
return isDev && allowDevLogin;
return (
process.env.NODE_ENV !== "production" &&
process.env.ALLOW_DEV_LOGIN !== "false"
);
}
+2 -5
View File
@@ -9,7 +9,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { supabase } from "@/lib/supabase";
import { signOutAction } from "@/actions/auth-actions";
type AdminHeaderProps = {
userRole?: string | null;
@@ -89,10 +89,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
router.push("/login");
router.refresh();
await signOutAction();
}
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
+9 -32
View File
@@ -4,8 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import BrandSelector from "./BrandSelector";
import { signOutAction } from "@/actions/auth-actions";
// Elegant warm sidebar design
// Colors: parchment 100 bg, soft linen text, powder petal accent
@@ -220,10 +219,15 @@ type SidebarProps = {
userRole?: string | null;
brandIds?: string[];
activeBrandId?: string | null;
brands?: Array<{ id: string; name: string; slug: string; logo_url: string | null }>;
brands?: { id: string; name: string; slug: string; logo_url: string | null }[];
};
export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands }: SidebarProps) {
export default function AdminSidebar({
userRole,
brandIds,
activeBrandId,
brands,
}: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
@@ -234,15 +238,9 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "multi_brand_admin" ? "Multi-Brand Manager"
: userRole === "store_employee" ? "Store Employee"
: null;
const isMultiBrandAdmin = userRole === "multi_brand_admin";
const showAllBrandsOption = userRole === "platform_admin";
const showBrandSelector =
brands && (showAllBrandsOption || (brandIds && brandIds.length >= 2));
const isActive = useCallback((href: string) => {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
@@ -306,10 +304,7 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
}, [router, mobileOpen, closeMobileMenu]);
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
router.push("/login");
router.refresh();
await signOutAction();
}
return (
@@ -404,24 +399,6 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
</Link>
</div>
{/* Brand selector (multi-brand admins + platform_admin) */}
{showBrandSelector && (
<div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
<p
className="text-[10px] font-semibold uppercase tracking-widest mb-1.5 px-1"
style={{ color: "rgba(195, 195, 193, 0.6)" }}
>
Active Brand
</p>
<BrandSelector
brands={brands!}
activeBrandId={activeBrandId ?? null}
showAllBrandsOption={showAllBrandsOption}
isMultiBrandAdmin={isMultiBrandAdmin}
/>
</div>
)}
{/* Nav links with keyboard navigation */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
<ul className="space-y-1" role="list">
@@ -22,7 +22,7 @@ type StopProductAssignmentProps = {
stopId: string;
allProducts: Product[];
assignedProducts: AssignedProduct[];
callerUid: string;
callerUid: string | null;
};
type Filter = "all" | "available" | "assigned";
-10
View File
@@ -1,10 +0,0 @@
// Auth Components for Clerk
import { UserButton } from "@clerk/nextjs";
export default function ClerkComponents() {
return (
<div className="flex items-center gap-4">
<UserButton />
</div>
);
}
@@ -1,10 +0,0 @@
// Clerk Authentication Provider
import { ClerkProvider } from "@clerk/nextjs";
export default function ClerkAuthProvider({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
{children}
</ClerkProvider>
);
}
+56 -11
View File
@@ -1,19 +1,48 @@
// Shared AdminUser type — safe to import from both server and client components
//
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
// `brand_ids` is the full list of brands the admin can act in.
// - platform_admin: `brand_id = null`, `brand_ids = []` (in dev) or all brands
// (resolved by `listBrandsForAdmin`).
// - multi_brand_admin: `brand_id` = selected/cookie brand, `brand_ids` = 2+.
// - brand_admin / store_employee / staff: `brand_id` = their single brand,
// `brand_ids = [that one]`.
// Shared AdminUser type — safe to import from both server and client
// components. The shape mirrors what `getAdminUser()` returns and
// includes both the user's role and the tenant they belong to.
export type AdminRole = "platform_admin" | "brand_admin" | "store_employee";
export type AdminUser = {
id?: string;
/** user.id from the `users` table — or "dev" for dev_session cookies */
id: string;
/** user_id (same as id) — kept for legacy callers */
user_id: string;
/** email from the `users` table, or null for dev shims */
email: string | null;
/** display name */
display_name: string | null;
/** tenant id from `tenant_users`, or null for platform_admin */
tenant_id: string | null;
/**
* @deprecated Use `tenant_id` instead. Kept for backward compat with
* call sites that haven't been migrated yet. Always mirrors
* `tenant_id`; will be removed in a later cleanup pass.
*/
brand_id: string | null;
/**
* @deprecated Use `tenant_id` instead. Kept for backward compat with
* multi-brand admin code (`AdminSidebar`, `setActiveBrand`, etc.).
* In our schema an admin belongs to at most one tenant, so this is
* always `[]` for platform_admin or `[tenant_id]` for everyone else.
* Will be removed when the multi-brand admin UI is re-thought.
*/
brand_ids: string[];
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
/** tenant slug (for storefronts) */
tenant_slug: string | null;
/** role within the tenant (or platform-wide for platform_admin) */
role: AdminRole;
/** is the user active? */
active: boolean;
/** auth provider */
auth_provider: "dev" | "google" | "email" | null;
// ── Permission flags ────────────────────────────────────────────
// Derived from the role, but exposed as individual booleans so
// existing consumer code (forms, sidebar, etc.) can read them
// directly without doing role math. See `permissionsForRole()` in
// admin-permissions.ts for the source of truth.
can_manage_products: boolean;
can_manage_stops: boolean;
can_manage_orders: boolean;
@@ -24,5 +53,21 @@ export type AdminUser = {
can_manage_water_log: boolean;
can_manage_reports: boolean;
can_manage_settings: boolean;
can_manage_billing: boolean;
can_manage_branding: boolean;
can_manage_marketing: boolean;
can_manage_team: boolean;
/** must the user change their password? (legacy; unused) */
must_change_password?: boolean;
};
export type TenantContext = {
tenant: {
id: string;
name: string;
slug: string;
status: string;
};
user: AdminUser;
};
+197 -157
View File
@@ -1,185 +1,225 @@
import { cookies } from "next/headers";
import type { AdminUser } from "./admin-permissions-types";
export type { AdminUser } from "./admin-permissions-types";
import "server-only";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { withPlatformAdmin } from "@/db/client";
import { users, tenants, tenantUsers } from "@/db/schema";
import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permissions-types";
/**
* Returns the current admin user, or `null` if not authenticated.
* Source of truth for the current admin user.
*
* 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.
* Looks up the Auth.js v5 session, then resolves the user + tenant
* from the `users` and `tenant_users` tables.
*
* `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:
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
* - store_employee: `[<first real brand>]` if a brand exists, else `[]`
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev)
* Returns `null` if:
* - No Auth.js session (caller not signed in)
* - The session email doesn't match any `users.email`
* - The user has no `tenant_users` row (not provisioned yet)
*
* Provisioning: an admin must run
* INSERT INTO users (email, ...) VALUES (...)
* INSERT INTO tenant_users (tenant_id, user_id, role) VALUES (...)
* to grant a Google-sign-in user admin access. Until provisioned, the
* layout shows "Access Denied" — correct behavior.
*
* The previous `dev_session` cookie bypass has been removed. The only
* way into the admin is through real Auth.js (Google in production;
* for local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
*/
export async function getAdminUser(): Promise<AdminUser | null> {
const cookieStore = await cookies();
// ── 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) ──────────────
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[] = [];
let sessionEmail: string | null = null;
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
}
const session = await auth();
sessionEmail = session?.user?.email ?? null;
} catch (err) {
console.error("[admin-permissions] auth() failed:", err);
return null;
}
const admin = adminUsers[0] as Record<string, unknown>;
if (!admin.active) return null;
if (!sessionEmail) return null;
// Load brand_ids from the admin_user_brands junction
const brandIds = await fetchAdminUserBrandIds(supabaseUrl, serviceKey, admin.id as string);
return await withPlatformAdmin(async (db) => {
const userRows = await db
.select()
.from(users)
.where(eq(users.email, sessionEmail))
.limit(1);
const user = userRows[0];
if (!user) return null;
return buildAdminUser(admin, brandIds);
const membershipRows = await db
.select({
tenantId: tenants.id,
tenantName: tenants.name,
tenantSlug: tenants.slug,
tenantStatus: tenants.status,
role: tenantUsers.role,
})
.from(tenantUsers)
.innerJoin(tenants, eq(tenants.id, tenantUsers.tenantId))
.where(eq(tenantUsers.userId, user.id))
.limit(1);
if (membershipRows.length === 0) {
// Signed in but not provisioned for any tenant.
return null;
}
const m = membershipRows[0];
const role = m.role as AdminRole;
return buildAdminUser({
id: user.id,
email: user.email,
displayName: user.name,
authProvider: user.authProvider,
tenantId: m.tenantId,
tenantSlug: m.tenantSlug,
tenantName: m.tenantName,
role,
active: true,
});
});
}
/**
* 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).
* Resolves the current admin user AND their tenant. Returns `null` if
* the user is not signed in or has no tenant. For platform_admin (no
* tenant), `tenant` is `null` and callers should use `withPlatformAdmin`
* to query across all tenants.
*/
async function fetchAdminUserBrandIds(
supabaseUrl: string,
serviceKey: string,
adminRowId: string
): Promise<string[]> {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_user_brands?admin_user_id=eq.${adminRowId}&select=brand_id`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
if (!res.ok) return [];
const data = await res.json().catch(() => []);
if (!Array.isArray(data)) return [];
return data
.map((row: Record<string, unknown>) => row.brand_id as string)
.filter((id): id is string => typeof id === "string");
} catch {
return [];
export async function getCurrentTenant(): Promise<TenantContext | null> {
const user = await getAdminUser();
if (!user) return null;
if (!user.tenant_id) {
// platform_admin — no specific tenant
return null;
}
return {
user,
tenant: {
id: user.tenant_id,
name: user.display_name ?? user.tenant_slug ?? "Unknown",
slug: user.tenant_slug ?? "unknown",
status: "active",
},
};
}
function buildDevAdmin(role: string): AdminUser {
// For dev sessions we don't have an admin_user_brands junction row to load.
// - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands).
// - store_employee: `brand_ids = []` (dev AdminAccessDenied is acceptable;
// this is the documented limitation — re-read spec section on getAdminUser
// step 1. We skip the spec's "fetch first real brand" complexity here in
// favour of keeping dev session cheap and DB-independent).
// - brand_admin: `brand_ids = []` (same rationale).
// `role` is narrowed to the strict union — we know the dev callers pass
// only valid values.
const base = {
// ────────────────────────────────────────────────────────────────────────
// Re-exports for backward compat
// ────────────────────────────────────────────────────────────────────────
export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permissions-types";
/**
* @deprecated Kept for unit tests that exercise the dev shim path.
* Production code should never call this — `getAdminUser()` only reads
* the Auth.js session now.
*/
export function buildDevAdmin(role: AdminRole): AdminUser {
const isPlatform = role === "platform_admin";
const tenantId = isPlatform ? null : "dev-tenant";
return {
id: "dev",
user_id: "dev",
brand_id: null,
brand_ids: [] as string[],
role: role as AdminUser["role"],
email: null,
display_name: "Demo Admin",
tenant_id: tenantId,
brand_id: tenantId, // legacy alias
brand_ids: tenantId ? [tenantId] : [], // legacy array alias
tenant_slug: isPlatform ? null : "tuxedo",
role,
active: true,
auth_provider: "dev",
...permissionsForRole(role),
must_change_password: false,
};
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
}
return { ...base, 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 };
}
function buildAdminUser(r: Record<string, unknown>, brandIds: string[]): AdminUser {
// The DB column is TEXT (per CLAUDE.md) so the runtime value is a string.
// We narrow it to the known union here. If the DB has an unknown role
// (e.g. a future role), the migration's CHECK constraint will reject it
// before it ever reaches this function.
const role = r.role as AdminUser["role"];
// `brand_id` is the *legacy* single-brand column — preserved here as-is.
// The canonical "active brand" is resolved by `getActiveBrandId` on each
// page/action, which considers URL params, the active_brand_id cookie,
// and this legacy fallback. Setting `brand_id` here to a sensible default
// (legacy → first of brand_ids) keeps the AdminUser shape useful even
// for callers that haven't migrated to `getActiveBrandId` yet.
const legacyBrandId = (r.brand_id as string | null) ?? null;
const base = {
id: r.id as string,
user_id: r.user_id as string,
brand_id: legacyBrandId ?? brandIds[0] ?? null,
brand_ids: brandIds,
role,
active: r.active as boolean,
must_change_password: Boolean(r.must_change_password),
function buildAdminUser(input: {
id: string;
email: string | null;
displayName: string | null;
authProvider: "dev" | "google" | "email" | null;
tenantId: string;
tenantSlug: string;
tenantName: string;
role: AdminRole;
active: boolean;
}): AdminUser {
return {
id: input.id,
user_id: input.id,
email: input.email,
display_name: input.displayName,
tenant_id: input.tenantId,
brand_id: input.tenantId, // legacy alias
brand_ids: [input.tenantId], // legacy array alias
tenant_slug: input.tenantSlug,
role: input.role,
active: input.active,
auth_provider: input.authProvider,
...permissionsForRole(input.role),
};
}
/**
* Single source of truth for "what can a role do". Used by both the
* dev shim and the real user lookup so the demo and the real thing
* behave identically.
*/
export function permissionsForRole(role: AdminRole) {
if (role === "platform_admin") {
return {
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,
can_manage_billing: true,
can_manage_branding: true,
can_manage_marketing: true,
can_manage_team: true,
};
}
if (role === "brand_admin") {
return {
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: false,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
can_manage_billing: true,
can_manage_branding: true,
can_manage_marketing: true,
can_manage_team: true,
};
}
// store_employee
return {
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
can_manage_settings: false,
can_manage_billing: false,
can_manage_branding: false,
can_manage_marketing: false,
can_manage_team: false,
};
if (role === "platform_admin") {
return { ...base, 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 };
}
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
}
return { ...base, can_manage_products: Boolean(r.can_manage_products), can_manage_stops: Boolean(r.can_manage_stops),
can_manage_orders: Boolean(r.can_manage_orders), can_manage_pickup: Boolean(r.can_manage_pickup),
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) };
}
+73 -120
View File
@@ -1,136 +1,89 @@
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 "server-only";
/**
* Build the dev Credentials provider. Lives here (Node-only) because
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
* that the middleware uses.
* Auth.js (NextAuth v5) — server-side configuration.
*
* This file is Node-only. It is imported by:
* - `src/app/api/auth/[...nextauth]/route.ts` (the OAuth + credentials handlers)
* - Server actions that call `signIn` / `signOut`
* - `src/lib/admin-permissions.ts` (reads `auth()` for the current user)
*
* The middleware imports a separate, edge-safe instance built from
* `src/auth.config.ts`. Both instances share the same JWT cookie, so the
* middleware can read sessions minted here.
*
* Providers:
* - Google OAuth — active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set.
* - Email + password (Credentials) — active in dev only; backed by the
* `users.password_hash` column. In production, set ALLOW_DEV_LOGIN=false
* (the default) and the provider is omitted entirely.
*
* For local dev, run `npm run db:seed` to create the seeded admin user
* (`admin@route-commerce.local` / `admin`). The `authorize` function
* looks up the user by email, verifies the password against the stored
* hash, and returns the real user record. No `dev_session` cookie
* bypass; this is real Auth.js sign-in.
*/
function buildDevCredentialsProvider() {
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { eq } from "drizzle-orm";
import { authConfig, isDevLoginEnabled } from "@/auth.config";
import { withDb } from "@/db/client";
import { users } from "@/db/schema";
import { verifyPassword } from "@/lib/passwords";
function buildCredentialsProvider() {
return Credentials({
id: "dev-login",
name: "Dev login",
id: "credentials",
name: "Email + password",
credentials: {
username: { label: "Username", type: "text" },
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
/**
* Returns the user on success, or `null` on any failure. Auth.js
* never throws from `authorize` — a throw is treated as a 500.
*/
async authorize(creds) {
if (!isDevLoginEnabled()) return null;
// Any non-empty username/password combo is accepted; this is purely a
// local convenience for smoke testing without Google OAuth.
const username = String(creds?.username ?? "").trim();
const email = String(creds?.email ?? "").trim().toLowerCase();
const password = String(creds?.password ?? "");
if (!username || !password) return null;
if (!email || !password) return null;
return {
id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
name: username,
email: `${username}@dev.local`,
// Custom field surfaced via `jwt` callback if needed
devRole: "platform_admin",
} as unknown as { id: string; name: string; email: string };
},
});
}
/**
* 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
* 2. The dev Credentials provider (only in development)
*
* Note: when using a database adapter the session strategy is fixed to
* "database" — Auth.js will persist sessions in the `sessions` table.
*/
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
// Use JWT sessions to match the edge-friendly config in `authConfig`.
// The middleware (running on the edge) cannot reach the database, so it
// 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()),
// `session.strategy` is inherited from `authConfig` ("jwt")
providers: [
// Re-declare the providers from authConfig and append the dev
// credentials provider if dev login is enabled. (NextAuth merges by
// provider id, so this overrides the edge stubs.)
...authConfig.providers,
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
],
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.
*/
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`,
[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) {
// The `users` table is global (not tenant-scoped), so we use
// `withDb` rather than `withTenant` — no GUC to set.
const u = await withDb(async (db) => {
const rows = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
return rows[0] ?? null;
});
if (!u || !u.passwordHash) return null;
if (!verifyPassword(password, u.passwordHash)) return null;
return {
id: u.id,
name: u.name ?? undefined,
email: u.email,
};
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[auth] signIn event error (non-fatal):", e);
console.error("[auth] credentials authorize failed:", err);
return null;
}
},
},
});
}
const providers = [
...authConfig.providers,
...(isDevLoginEnabled() ? [buildCredentialsProvider()] : []),
];
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers,
});
-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();
}
}
+67
View File
@@ -0,0 +1,67 @@
import "server-only";
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
/**
* Password hashing + verification.
*
* Format: `scrypt$N$salt$hash` (hex-encoded).
*
* - scrypt: algorithm identifier (future-proof — easy to migrate to
* argon2id by adding a new branch in `verifyPassword`)
* - N: scrypt cost parameter (CPU/memory). 2^14 (16384) is a
* reasonable default for an interactive login on modern
* hardware (~50ms per hash on a typical server)
* - salt: 16 random bytes, hex-encoded
* - hash: 64-byte derived key, hex-encoded
*
* Why scrypt and not bcrypt / argon2?
* - No extra dependency. Node's `crypto` module has it built in.
* - Argon2id is the modern recommendation but requires a native module
* (`argon2` or `@node-rs/argon2`) which complicates the install.
* When we add a native build step, we should switch to argon2id.
* - bcrypt is fine but not better than scrypt for our use case, and
* also requires a native module.
*
* All comparisons are constant-time (`timingSafeEqual`) to defeat
* timing-based side-channel attacks.
*/
const KEY_LEN = 64;
const SALT_LEN = 16;
const DEFAULT_N = 16384; // 2^14
const ALGO = "scrypt";
export function hashPassword(plain: string): string {
if (typeof plain !== "string" || plain.length === 0) {
throw new Error("hashPassword: password must be a non-empty string");
}
const salt = randomBytes(SALT_LEN).toString("hex");
const hash = scryptSync(plain, salt, KEY_LEN, { N: DEFAULT_N }).toString("hex");
return `${ALGO}$${DEFAULT_N}$${salt}$${hash}`;
}
export function verifyPassword(plain: string, stored: string): boolean {
if (typeof plain !== "string" || typeof stored !== "string") return false;
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== ALGO) return false;
const n = Number.parseInt(parts[1], 10);
if (!Number.isFinite(n) || n < 1024 || n > 1_000_000) return false;
const salt = parts[2];
const expectedHex = parts[3];
if (!salt || !expectedHex) return false;
let actual: Buffer;
let expected: Buffer;
try {
actual = scryptSync(plain, salt, KEY_LEN, { N: n });
expected = Buffer.from(expectedHex, "hex");
} catch {
return false;
}
if (actual.length !== expected.length) return false;
try {
return timingSafeEqual(actual, expected);
} catch {
return false;
}
}
+61
View File
@@ -0,0 +1,61 @@
// NextAuth v5 middleware
//
// Runs on every non-static request in the Edge runtime. It uses a
// lightweight NextAuth instance built from the edge-safe `authConfig` —
// NOT the full `src/lib/auth.ts` (which uses `pg` and is Node-only).
//
// Responsibilities:
// 1. Allow Auth.js to read/write its own session cookie
// 2. Protect /admin/*, /wholesale/*, /protected-example — redirect to
// /login if not authenticated
// 3. Redirect away from /login when the user already has a session
// 4. Add a handful of baseline security headers
//
// The legacy `dev_session` cookie bypass has been removed. The only way
// into the admin is through real Auth.js — Google in production, or the
// seeded Credentials provider in dev (see `src/lib/auth.ts`).
import NextAuth from "next-auth";
import { authConfig } from "@/auth.config";
import { NextResponse } from "next/server";
const { auth } = NextAuth(authConfig);
export default auth((req) => {
const { pathname } = req.nextUrl;
const isAuthed = !!req.auth;
const isAdmin = pathname.startsWith("/admin");
const isLogin = pathname === "/login";
if (isAdmin && !isAuthed) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("redirect", pathname);
return addSecurityHeaders(NextResponse.redirect(url));
}
if (isLogin && isAuthed) {
const url = req.nextUrl.clone();
url.pathname = "/admin";
return addSecurityHeaders(NextResponse.redirect(url));
}
return addSecurityHeaders(NextResponse.next());
});
function addSecurityHeaders(res: NextResponse): NextResponse {
res.headers.set("X-Content-Type-Options", "nosniff");
res.headers.set("X-Frame-Options", "DENY");
res.headers.set("X-XSS-Protection", "1; mode=block");
res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
return res;
}
export const config = {
matcher: [
// Skip Next.js internals and static files
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
],
};