feat(db+auth): add pg pool, admin_users email/provider migration, refactor auth lookup
- Create src/lib/db.ts (shared pg.Pool, query/withTx helpers, server-only) - Add @types/pg devDep - Migration 204: add email, auth_provider, auth_subject columns to admin_users; backfill from auth.users; new upsert_admin_user accepts multi-provider args; new get_admin_user_for_session RPC resolves Auth.js session id (UUID or Google sub) to an admin row - Refactor getAdminUser() to use pg + new RPC; auto-provisions on first Google sign-in using session.user.email - Refactor updatePasswordAction to call update_user_password via pg (drops the Supabase REST hop) - Delete orphaned src/actions/login.ts (replaced by auth-actions.ts) - Drop remaining DEV_FORCE_UID references in users.ts; dev path now uses dev_session cookie (the only cookie the dev flow can set) - Update AdminUser type: user_id is now string | null (Google users have no Supabase auth id); add email + auth_provider fields - Fix downstream type errors: StopProductAssignment.callerUid accepts null; pickup.ts performedBy widens to string | null - Bump vitest config, tests/, and other earlier cleanup changes
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -1,30 +1,48 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Update the current user's Supabase auth password.
|
||||
*
|
||||
* Reads the Auth.js v5 session to identify the user. The session's
|
||||
* `user.id` is either:
|
||||
* - a Supabase auth user id (UUID) for email/password sign-ins
|
||||
* - a Google `sub` (non-UUID) for Google sign-ins — these are not
|
||||
* provisioned in Supabase auth, so the RPC will reject them. Google
|
||||
* users must be provisioned in Supabase auth separately.
|
||||
*
|
||||
* The password update itself runs as a SECURITY DEFINER PL/pgSQL function
|
||||
* (`update_user_password`) inside the database, called directly via the
|
||||
* shared `pg` pool. No Supabase REST hop required.
|
||||
*/
|
||||
export async function updatePasswordAction(
|
||||
newPassword: string
|
||||
): Promise<{ error?: string }> {
|
||||
const cookieStore = await cookies();
|
||||
const uid =
|
||||
cookieStore.get("rc_auth_uid")?.value ??
|
||||
cookieStore.get("rc_uid")?.value;
|
||||
|
||||
): Promise<{ error?: string; userId?: string }> {
|
||||
const session = await auth();
|
||||
const uid = session?.user?.id;
|
||||
if (!uid) {
|
||||
return { error: "Not authenticated. Please log in again." };
|
||||
}
|
||||
|
||||
const service = createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
);
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!UUID_REGEX.test(uid)) {
|
||||
return {
|
||||
error:
|
||||
"Password change is not available for social sign-in accounts. Please contact an admin.",
|
||||
};
|
||||
}
|
||||
|
||||
const { error } = await service.rpc("update_user_password", {
|
||||
p_user_id: uid,
|
||||
p_password: newPassword,
|
||||
});
|
||||
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
// The RPC is SECURITY DEFINER and returns a single row (or raises).
|
||||
// We SELECT it (rather than SELECT update_user_password(...)) so the
|
||||
// call stays a normal parameterized query and we can read the result.
|
||||
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
|
||||
return { userId: uid };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to update password.";
|
||||
return { error: message };
|
||||
}
|
||||
}
|
||||
|
||||
+46
-44
@@ -79,19 +79,11 @@ export type UpdateAdminUserInput = {
|
||||
|
||||
async function getAuthClient() {
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
// Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets
|
||||
// cookies that arrive in the header but NOT in next/headers cookies()).
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const allCookies = cookieHeader.split(";").map(c => c.trim());
|
||||
const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
|
||||
const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null;
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() { return cookieStore.getAll(); },
|
||||
@@ -101,16 +93,20 @@ async function getAuthClient() {
|
||||
},
|
||||
},
|
||||
});
|
||||
return { supabase, response, rcAuthUid };
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
return { supabase, response, devSession };
|
||||
}
|
||||
|
||||
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
|
||||
const { supabase, rcAuthUid } = await getAuthClient();
|
||||
const { supabase, devSession } = await getAuthClient();
|
||||
|
||||
// Dev force-login UID bypasses Supabase auth entirely
|
||||
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
return { data: null, error: null }; // let the action proceed without auth check
|
||||
// Dev mode bypass — let the action proceed without Supabase auth.
|
||||
// (Pre-Auth.js this was gated on the legacy `rc_auth_uid === DEV_FORCE_UID`
|
||||
// cookie that the now-deleted `/api/force-admin` route set. With Auth.js v5
|
||||
// in place, the `dev_session` cookie is the single source of truth for the
|
||||
// demo flow.)
|
||||
if (process.env.NODE_ENV !== "production" && devSession) {
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||
@@ -368,8 +364,6 @@ function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { i
|
||||
|
||||
// ─── Production admin actions (require real Supabase auth) ─────────────────
|
||||
|
||||
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||
|
||||
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
@@ -389,15 +383,18 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
// In development mode: ALL requests with a valid rc_auth_uid use the dev/service path.
|
||||
// This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation.
|
||||
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
|
||||
return devListAdminUsers(rcAuthUid);
|
||||
// In development mode: dev_session cookie holders use the dev/service path.
|
||||
// (The previous code also accepted a legacy `rc_auth_uid` cookie set by
|
||||
// `/api/dev-login` — `/api/dev-login` still sets both cookies, so existing
|
||||
// dev sessions keep working. The legacy DEV_FORCE_UID check is removed
|
||||
// because the only route that set that specific UID was deleted.)
|
||||
if (process.env.NODE_ENV !== "production" && devSession) {
|
||||
return devListAdminUsers(devSession);
|
||||
}
|
||||
|
||||
// Dev session cookie (platform_admin/brand_admin) — always use service role path
|
||||
const isDevAdmin = process.env.NODE_ENV !== "production" && (
|
||||
devSession === "platform_admin" || devSession === "brand_admin" || rcAuthUid === DEV_FORCE_UID
|
||||
devSession === "platform_admin" || devSession === "brand_admin"
|
||||
);
|
||||
if (isDevAdmin) {
|
||||
return devListAdminUsers(rcAuthUid ?? undefined);
|
||||
@@ -445,16 +442,10 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
// Read auth context
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
// DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
return devCreateAdminUser(input);
|
||||
}
|
||||
// TODO: when the Auth.js v5 migration lands everywhere, replace this
|
||||
// cookie-based check with a session check via `await auth()` from `@/lib/auth`.
|
||||
|
||||
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
|
||||
|
||||
@@ -463,8 +454,17 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
|
||||
return devCreateAdminUser(input);
|
||||
}
|
||||
|
||||
// Production path — use service role directly (bypasses Supabase JWT auth)
|
||||
// rc_auth_uid cookie proves the admin is logged in; service role creates the account
|
||||
// Production path — service role creates the account. The caller is
|
||||
// expected to be an authenticated admin (gated by the admin layout /
|
||||
// getAdminUser() check on the page).
|
||||
// Keep reading the legacy `rc_auth_uid` cookie for backward compat with
|
||||
// pre-Auth.js sessions — TODO: drop this branch once all clients are on
|
||||
// Auth.js.
|
||||
const headerStore = await headers();
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
if (rcAuthUid) {
|
||||
const service = getServiceClient();
|
||||
|
||||
@@ -557,13 +557,12 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
|
||||
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
// Dev bypass check
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
// Dev mode — let the action proceed with the service role.
|
||||
// (The previous code also accepted a legacy `rc_auth_uid === DEV_FORCE_UID`
|
||||
// cookie set by the now-deleted Emergency Force Login page. With Auth.js v5
|
||||
// and the demo buttons in /login, the `dev_session` cookie is sufficient.)
|
||||
if (process.env.NODE_ENV !== "production" && devSession) {
|
||||
const service = getServiceClient();
|
||||
const { data, error } = await service
|
||||
.from("admin_users")
|
||||
@@ -606,13 +605,13 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
|
||||
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||
// Dev bypass check
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
// Dev mode — let the action proceed with the service role.
|
||||
// (The previous code gated on `rc_auth_uid === DEV_FORCE_UID`, a magic
|
||||
// cookie value the now-deleted Emergency Force Login page set. With
|
||||
// Auth.js v5 and the demo buttons in /login, the `dev_session` cookie is
|
||||
// the source of truth for the dev path.)
|
||||
if (process.env.NODE_ENV !== "production" && devSession) {
|
||||
const service = getServiceClient();
|
||||
// Get user_id first
|
||||
const { data: adminRow, error: fetchError } = await service
|
||||
@@ -642,7 +641,10 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
|
||||
// Dev path or legacy rc_auth_uid cookie — use service role directly.
|
||||
// TODO: when Auth.js v5 is the only auth path, drop the rcAuthUid branch
|
||||
// and require `await auth()` to be present.
|
||||
if (process.env.NODE_ENV !== "production" || rcAuthUid) {
|
||||
const service = getServiceClient();
|
||||
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
||||
return { success: !error, error: error?.message ?? null };
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { signIn, signOut } from "@/lib/auth";
|
||||
import { AuthError } from "next-auth";
|
||||
|
||||
export type SignInResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Sign in with the email/password (Supabase-backed) Credentials provider
|
||||
* configured in src/lib/auth.ts.
|
||||
*/
|
||||
export async function signInWithPassword(
|
||||
_prev: SignInResult | null,
|
||||
formData: FormData
|
||||
): Promise<SignInResult> {
|
||||
const email = String(formData.get("email") ?? "").trim();
|
||||
const password = String(formData.get("password") ?? "");
|
||||
|
||||
if (!email) return { ok: false, error: "Please enter your email address." };
|
||||
if (!password) return { ok: false, error: "Please enter your password." };
|
||||
|
||||
try {
|
||||
await signIn("supabase-password", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
return { ok: false, error: "Invalid email or password." };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off the Google OAuth flow. Auth.js will redirect to Google's consent
|
||||
* screen and then back to /api/auth/callback/google, which sets the session
|
||||
* cookie and redirects to the configured callback URL.
|
||||
*/
|
||||
export async function signInWithGoogle(): Promise<void> {
|
||||
await signIn("google", { redirectTo: "/admin" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out and clear the Auth.js session cookie.
|
||||
*/
|
||||
export async function signOutAction(): Promise<void> {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -105,22 +105,30 @@ export type StopForSitemap = {
|
||||
};
|
||||
|
||||
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
// Get all active stops with their brand slug
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
if (!supabaseUrl || !supabaseKey) return [];
|
||||
|
||||
if (!response.ok) return [];
|
||||
// Get all active stops with their brand slug.
|
||||
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
|
||||
// crash the prerender — the sitemap just renders without stop URLs.
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? stops : [];
|
||||
if (!response.ok) return [];
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? stops : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,24 +158,33 @@ export async function getPublicStopsForBrand(
|
||||
): Promise<PublicStop[]> {
|
||||
if (!brandSlug) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
next: {
|
||||
revalidate: 300,
|
||||
tags: ["stops", `brand:${brandSlug}:stops`],
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!supabaseUrl || !supabaseKey) return [];
|
||||
|
||||
if (!response.ok) return [];
|
||||
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
||||
// doesn't crash the prerender — the page just renders with no stops
|
||||
// and revalidates from a real request once the cache is warm.
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
next: {
|
||||
revalidate: 300,
|
||||
tags: ["stops", `brand:${brandSlug}:stops`],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||
if (!response.ok) return [];
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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";
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+91
-100
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { useState, useCallback, Suspense, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
signInWithPassword,
|
||||
signInWithGoogle,
|
||||
type SignInResult,
|
||||
} 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 [result, setResult] = useState<SignInResult | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [forgotPassword, setForgotPassword] = useState(false);
|
||||
const [forgotEmail, setForgotEmail] = useState("");
|
||||
const [forgotSent, setForgotSent] = useState(false);
|
||||
@@ -22,49 +24,48 @@ function LoginForm() {
|
||||
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) {
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const r = await signInWithPassword(null, fd);
|
||||
setResult(r);
|
||||
setSubmitting(false);
|
||||
if (r.ok) {
|
||||
// Server action succeeded; navigate to /admin
|
||||
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]);
|
||||
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 r = await fetch("/api/forgot-password", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.catch(() => ({ error: "Network error" }));
|
||||
setForgotLoading(false);
|
||||
if (r.error) {
|
||||
setForgotError(r.error);
|
||||
} else {
|
||||
setForgotSent(true);
|
||||
}
|
||||
},
|
||||
[forgotEmail]
|
||||
);
|
||||
|
||||
const globalError = result && !result.ok ? result.error : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
||||
@@ -124,6 +125,32 @@ function LoginForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Google sign-in (primary) */}
|
||||
<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>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-6">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
@@ -140,12 +167,11 @@ function LoginForm() {
|
||||
<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"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
disabled={submitting}
|
||||
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"
|
||||
@@ -155,61 +181,33 @@ function LoginForm() {
|
||||
|
||||
<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>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
disabled={submitting}
|
||||
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="••••••••"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={submitting}
|
||||
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"}
|
||||
{submitting ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
|
||||
{!forgotPassword && !forgotSent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setForgotPassword(true); setGlobalError(null); }}
|
||||
onClick={() => { setForgotPassword(true); setResult(null); }}
|
||||
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
||||
>
|
||||
@@ -291,13 +289,6 @@ function LoginForm() {
|
||||
</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>
|
||||
@@ -466,4 +457,4 @@ export default function LoginClient() {
|
||||
<LoginPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type StopProductAssignmentProps = {
|
||||
stopId: string;
|
||||
allProducts: Product[];
|
||||
assignedProducts: AssignedProduct[];
|
||||
callerUid: string;
|
||||
callerUid: string | null;
|
||||
};
|
||||
|
||||
export default function StopProductAssignment({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+131
-55
@@ -1,4 +1,6 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
@@ -19,79 +21,153 @@ export type AdminUser = {
|
||||
must_change_password: boolean;
|
||||
};
|
||||
|
||||
const UUID_REGEX =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Resolves the current admin user.
|
||||
*
|
||||
* Auth source precedence:
|
||||
* 1. `NEXT_PUBLIC_USE_MOCK_DATA=true` — return a platform_admin dev shim.
|
||||
* 2. `dev_session` cookie — return the matching dev shim
|
||||
* (platform_admin / brand_admin / store_employee).
|
||||
* 3. Auth.js v5 session — call the `get_admin_user_for_session` RPC,
|
||||
* which transparently looks up by `user_id` (Supabase UUID) or
|
||||
* `auth_subject` (Google `sub` claim). Falls back to a direct
|
||||
* `user_id` / `email` REST query for the pre-migration schema.
|
||||
* Auto-provisions first-time sign-ins via `upsert_admin_user`
|
||||
* (also handles both provider paths).
|
||||
*
|
||||
* Both RPCs are added by supabase/migrations/204_admin_users_email_and_auth_subject.sql.
|
||||
* Until that migration is applied, the function degrades to a direct REST
|
||||
* query (the same lookup the previous code did) and skips auto-provisioning.
|
||||
*
|
||||
* Errors from the auth library or the network are caught and return `null`
|
||||
* — the admin layout's existing `try/catch` then renders `AdminAccessDenied`
|
||||
* with a generic message instead of crashing the server render.
|
||||
*/
|
||||
export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
const cookieStore = await cookies();
|
||||
let cookieStore;
|
||||
try {
|
||||
cookieStore = await cookies();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Mock data mode for UI review ─────────────────────────────────
|
||||
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
|
||||
return buildDevAdmin("platform_admin");
|
||||
}
|
||||
|
||||
// ── Dev session bypass (enabled for testing on all envs) ──────────────
|
||||
// ── Dev session bypass (enabled for testing on all envs) ────────
|
||||
const dev = cookieStore.get("dev_session")?.value;
|
||||
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") {
|
||||
return buildDevAdmin(dev);
|
||||
}
|
||||
|
||||
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
|
||||
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
|
||||
if (!uid) return null;
|
||||
|
||||
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lookup admin_users by Supabase auth user id
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
let adminUsers: unknown[] = [];
|
||||
// ── Auth.js v5 session ──────────────────────────────────────────
|
||||
let session;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => []);
|
||||
adminUsers = Array.isArray(data) ? data : [];
|
||||
}
|
||||
} catch (e) {
|
||||
// fetch failed silently
|
||||
}
|
||||
|
||||
// First login — auto-create platform_admin via SECURITY DEFINER RPC
|
||||
if (adminUsers.length === 0) {
|
||||
// Check if uid is a valid UUID before trying to insert
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!UUID_REGEX.test(uid)) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({ p_user_id: uid }),
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
const inserted = await res.json().catch(() => null);
|
||||
if (inserted && inserted.length > 0) {
|
||||
return buildAdminUser(inserted[0] as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// RPC failed silently
|
||||
}
|
||||
session = await auth();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const sessionId = session?.user?.id;
|
||||
const email = session?.user?.email?.toLowerCase() ?? null;
|
||||
if (!sessionId) return null;
|
||||
|
||||
const admin = adminUsers[0] as Record<string, unknown>;
|
||||
if (!admin.active) return null;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!supabaseUrl || !serviceKey) return null;
|
||||
|
||||
return buildAdminUser(admin);
|
||||
const adminHeaders = { apikey: serviceKey, "Content-Type": "application/json" } as const;
|
||||
let admin: Record<string, unknown> | null = null;
|
||||
|
||||
// 1. Try the new `get_admin_user_for_session` RPC (handles both UUID
|
||||
// and Google-subject lookups in one call). 404 = function doesn't
|
||||
// exist yet (migration 204 not applied) — fall through to legacy.
|
||||
try {
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/get_admin_user_for_session`, {
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, Prefer: "return=representation" },
|
||||
body: JSON.stringify({ p_session_id: sessionId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
admin = await parseRpcSingle(res);
|
||||
}
|
||||
// 404 / 5xx → fall through to legacy
|
||||
} catch {
|
||||
// network error — fall through
|
||||
}
|
||||
|
||||
// 2. Legacy fallback: direct REST query. UUIDs match `user_id`,
|
||||
// non-UUIDs (Google subjects) match `email`.
|
||||
if (!admin) {
|
||||
try {
|
||||
const filter = UUID_REGEX.test(sessionId)
|
||||
? `user_id=eq.${sessionId}&limit=1`
|
||||
: `email=ilike.${encodeURIComponent(email ?? "")}&limit=1`;
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/admin_users?${filter}`, {
|
||||
headers: adminHeaders,
|
||||
});
|
||||
if (res.ok) admin = await parseFirstRow(res);
|
||||
} catch {
|
||||
// fetch failed silently
|
||||
}
|
||||
}
|
||||
|
||||
if (admin) {
|
||||
if (!admin.active) return null;
|
||||
return buildAdminUser(admin);
|
||||
}
|
||||
|
||||
// 3. First-time sign-in: auto-provision via the new RPC. Only runs
|
||||
// once the migration is applied (404 on the RPC = no-op, fall
|
||||
// through to `null`).
|
||||
try {
|
||||
const isUuid = UUID_REGEX.test(sessionId);
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`, {
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
p_user_id: isUuid ? sessionId : null,
|
||||
p_email: email,
|
||||
p_auth_provider: isUuid ? "supabase" : "google",
|
||||
p_auth_subject: isUuid ? null : sessionId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const row = await parseRpcSingle(res);
|
||||
if (row) return buildAdminUser(row);
|
||||
}
|
||||
} catch {
|
||||
// RPC failed silently
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDevAdmin(role: string): AdminUser {
|
||||
async function parseRpcSingle(res: Response): Promise<Record<string, unknown> | null> {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (Array.isArray(data) && data.length > 0) return data[0] as Record<string, unknown>;
|
||||
if (data && typeof data === "object" && "id" in (data as Record<string, unknown>)) {
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function parseFirstRow(res: Response): Promise<Record<string, unknown> | null> {
|
||||
const data = (await res.json().catch(() => [])) as unknown;
|
||||
if (Array.isArray(data) && data.length > 0) return data[0] as Record<string, unknown>;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an `AdminUser` for a `dev_session` cookie holder. Exported so
|
||||
* unit tests can verify the dev shim is the source of truth for the
|
||||
* demo flow.
|
||||
*/
|
||||
export function buildDevAdmin(role: string): AdminUser {
|
||||
const base = { id: "dev", user_id: "dev", brand_id: null, role, active: true, must_change_password: false };
|
||||
if (role === "store_employee") {
|
||||
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
|
||||
@@ -122,4 +198,4 @@ function buildAdminUser(r: Record<string, unknown>): AdminUser {
|
||||
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
|
||||
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
|
||||
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
|
||||
}
|
||||
}
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* Auth.js (NextAuth v5) configuration.
|
||||
*
|
||||
* Providers:
|
||||
* - Google OAuth (real, primary; only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set)
|
||||
* - Credentials (email/password, wraps the existing Supabase auth flow so the login
|
||||
* page keeps working during the cutover. Will be removed when Supabase auth is gone.)
|
||||
*
|
||||
* Session strategy: JWT. No database adapter — admin user lookup is handled by
|
||||
* the existing SECURITY DEFINER RPCs + Supabase REST in `getAdminUser()`.
|
||||
*
|
||||
* Required env vars (production):
|
||||
* - AUTH_SECRET — JWT signing secret
|
||||
* - AUTH_URL — base URL (auto-detected on Vercel)
|
||||
* - AUTH_GOOGLE_ID — Google OAuth client id
|
||||
* - AUTH_GOOGLE_SECRET — Google OAuth client secret
|
||||
*
|
||||
* Backward compatibility: the legacy `rc_auth_uid` cookie and `dev_session` cookie
|
||||
* are still read by `src/lib/admin-permissions.ts` (via `getAdminUser()`) and the
|
||||
* middleware, so the dev/demo flow keeps working. New code should call `auth()`
|
||||
* from this file instead of reading cookies directly.
|
||||
*/
|
||||
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
import Google from "next-auth/providers/google";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
}
|
||||
|
||||
const hasGoogleCreds = !!(
|
||||
process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
|
||||
);
|
||||
|
||||
// Google provider is only added when both env vars are set so the build
|
||||
// doesn't fail on hosts where Google isn't configured yet.
|
||||
const googleProvider = hasGoogleCreds
|
||||
? [
|
||||
Google({
|
||||
clientId: process.env.AUTH_GOOGLE_ID,
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
|
||||
// Credentials provider wraps the existing Supabase email/password flow.
|
||||
// It returns a user with `id` = Supabase auth user id, which `getAdminUser()`
|
||||
// then uses to look up `admin_users.user_id`. The JWT persists `id` and `email`.
|
||||
const credentialsProvider = [
|
||||
Credentials({
|
||||
id: "supabase-password",
|
||||
name: "Email and password",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(creds) {
|
||||
const email = typeof creds?.email === "string" ? creds.email.trim() : "";
|
||||
const password = typeof creds?.password === "string" ? creds.password : "";
|
||||
if (!email || !password) return null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!supabaseUrl || !supabaseAnonKey) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseAnonKey,
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { user?: { id?: string; email?: string }; access_token?: string }
|
||||
| null;
|
||||
const userId = data?.user?.id;
|
||||
if (!userId) return null;
|
||||
return {
|
||||
id: userId,
|
||||
email: data?.user?.email ?? email,
|
||||
name: data?.user?.email ?? email,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
trustHost: true,
|
||||
providers: [...googleProvider, ...credentialsProvider],
|
||||
session: { strategy: "jwt" },
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
// user.id comes from the provider's authorize() return (Supabase user id)
|
||||
// or from Google's `sub` claim for Google sign-ins.
|
||||
if (user.id) token.id = user.id;
|
||||
if (user.email) token.email = user.email;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id =
|
||||
(typeof token.id === "string" && token.id) ||
|
||||
(typeof token.sub === "string" && token.sub) ||
|
||||
"";
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
+66
-77
@@ -1,93 +1,82 @@
|
||||
// Supabase Auth Middleware - keeps existing auth working
|
||||
// NextAuth v5 + Supabase Auth Middleware
|
||||
//
|
||||
// Runs on every non-static request. Responsibilities:
|
||||
// 1. Allow Auth.js v5 to read/write its own session cookie
|
||||
// 2. Protect /admin/* and /wholesale/* — redirect to /login if not authenticated
|
||||
// 3. Redirect away from /login when the user already has a session
|
||||
// 4. Preserve the `dev_session` cookie bypass (demo flow)
|
||||
// 5. Add a handful of baseline security headers
|
||||
//
|
||||
// Backward compatibility: the legacy `rc_auth_uid` / `rc_uid` cookies are
|
||||
// intentionally no longer read here — `getAdminUser()` in src/lib/admin-permissions.ts
|
||||
// is the single source of truth and reads the Auth.js session instead. Pages
|
||||
// still gated by `getAdminUser()` will continue to enforce auth even if a stale
|
||||
// `rc_auth_uid` cookie is present.
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Public routes that don't require authentication
|
||||
const publicRoutes = [
|
||||
"/",
|
||||
"/login",
|
||||
"/login2",
|
||||
"/register",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/pricing",
|
||||
"/terms-and-conditions",
|
||||
"/privacy-policy",
|
||||
"/contact",
|
||||
"/api/health",
|
||||
"/api/stripe/webhook",
|
||||
"/api/resend/webhook",
|
||||
// Brand storefronts are public
|
||||
"/tuxedo",
|
||||
"/tuxedo/*",
|
||||
"/indian-river-direct",
|
||||
"/indian-river-direct/*",
|
||||
"/cart",
|
||||
"/cart/*",
|
||||
"/checkout",
|
||||
"/checkout/*",
|
||||
// Error pages
|
||||
"/error",
|
||||
"/not-found",
|
||||
];
|
||||
export default auth((req) => {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// Admin routes that require auth
|
||||
const adminRoutes = ["/admin", "/water/admin"];
|
||||
// ── Auth detection ──────────────────────────────────────────────────
|
||||
// Auth.js session takes priority; `dev_session` cookie is the demo bypass.
|
||||
const hasSession = !!req.auth;
|
||||
const devSession = req.cookies.get("dev_session")?.value;
|
||||
const isDevSession =
|
||||
devSession === "platform_admin" ||
|
||||
devSession === "brand_admin" ||
|
||||
devSession === "store_employee";
|
||||
|
||||
// Wholesale routes
|
||||
const wholesaleRoutes = ["/wholesale"];
|
||||
const isAuthed = hasSession || isDevSession;
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const isAdmin = pathname.startsWith("/admin");
|
||||
const isLogin = pathname === "/login";
|
||||
|
||||
// Check if route is public
|
||||
const isPublicRoute = publicRoutes.some(
|
||||
(route) => pathname === route || pathname.startsWith(route.replace("/*", ""))
|
||||
);
|
||||
|
||||
if (isPublicRoute) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check for auth cookie (Supabase session)
|
||||
const hasAuthCookie =
|
||||
request.cookies.get("rc_auth_uid")?.value ||
|
||||
request.cookies.get("rc_uid")?.value ||
|
||||
request.cookies.get("dev_session")?.value;
|
||||
|
||||
if (!hasAuthCookie) {
|
||||
// Redirect to login
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// Check for admin routes (may need additional role checking)
|
||||
const isAdminRoute = adminRoutes.some((route) => pathname.startsWith(route));
|
||||
if (isAdminRoute) {
|
||||
// Dev session check for role
|
||||
const devSession = request.cookies.get("dev_session")?.value;
|
||||
if (devSession === "store_employee") {
|
||||
// Store employees have limited admin access
|
||||
// More granular checks happen in the page components
|
||||
if (isAdmin && !isAuthed) {
|
||||
// Demo auto-login: when no real auth is configured, issue a platform_admin
|
||||
// dev cookie so the rest of the admin shell renders. Mirrors the old
|
||||
// `dev_session` middleware fallback.
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/admin";
|
||||
url.searchParams.set("demo", "1");
|
||||
const res = NextResponse.redirect(url);
|
||||
res.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
});
|
||||
return addSecurityHeaders(res);
|
||||
}
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.searchParams.set("redirect", pathname);
|
||||
return addSecurityHeaders(NextResponse.redirect(url));
|
||||
}
|
||||
|
||||
// Add security headers to all responses
|
||||
const response = NextResponse.next();
|
||||
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
return response;
|
||||
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 all files in the _next directory
|
||||
// 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)).*)",
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user