feat: remove dev_session, add Drizzle schema + RLS + real auth
BREAKING: dev_session cookie bypass removed. Admin access now requires a real Auth.js v5 session (Google OAuth in production). Provision users by inserting into users + tenant_users tables. New in this commit: - db/migrations/0001_init.sql: 18-table SaaS schema with RLS (tenants, users, tenant_users, plans, add_ons, subscriptions, tenant_add_ons, products, product_images, stops, customers, orders, order_items, brand_settings, email_templates, campaigns, files, audit_log) - db/schema/: Drizzle TypeScript mirror of every table - db/client.ts: withTenant() / withPlatformAdmin() query wrappers that set Postgres GUCs (app.current_tenant_id, app.platform_admin) for RLS enforcement. Never query a tenant-scoped table without one. - db/seed.ts: seeds 3 plans, 6 add-ons, 2 tenants (Tuxedo, Indian River Direct), brand_settings, sample products/stops/customers - scripts/migrate.js: applies migrations in lexical order with tracking - scripts/db-reset.js: drops + recreates DB, runs migrate + seed - DATABASE_URL now uses rc_app (non-superuser, NOBYPASSRLS). RLS is enforced even for the app user. DATABASE_ADMIN_URL for migrations. - src/lib/admin-permissions.ts: getAdminUser() reads Auth.js session, looks up user + tenant in Postgres. brand_id kept as alias for backward compat. - src/middleware.ts: Auth.js-only route protection, dev_session gone - src/app/login/LoginClient.tsx: Google OAuth only, no demo mode - src/components/admin/AdminSidebar.tsx + AdminHeader.tsx: signOutAction replaces supabase signout - @/db/* path aliases in tsconfig.json + vitest.config.ts - drizzle.config.ts added - db/auth_schema.sql removed (was a stub; replaced by real schema) - src/app/api/dev-login/route.ts deleted - tests: updated to remove dev_session coverage
This commit is contained in:
+243
-541
@@ -1,18 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
import { supabase as publicSupabase } from "@/lib/supabase";
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { pool, query } from "@/lib/db";
|
||||
import { getMockTableData, mockBrands } from "@/lib/mock-data";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type AdminUserRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_id: string | null;
|
||||
display_name: string | null;
|
||||
email: string;
|
||||
phone_number: string | null;
|
||||
@@ -75,165 +72,17 @@ export type UpdateAdminUserInput = {
|
||||
phone_number?: string | null;
|
||||
};
|
||||
|
||||
// ─── SSR client for authenticated requests ─────────────────────────────────
|
||||
|
||||
async function getAuthClient() {
|
||||
const cookieStore = await cookies();
|
||||
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 });
|
||||
|
||||
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));
|
||||
},
|
||||
},
|
||||
});
|
||||
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, devSession } = await getAuthClient();
|
||||
|
||||
// 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();
|
||||
if (userError || !userData.user) {
|
||||
return { data: null, error: "Not authenticated" };
|
||||
}
|
||||
const { data, error } = await supabase.rpc(fn, params as Record<string, unknown>);
|
||||
if (error) { /* RPC error handled silently */ }
|
||||
return { data: data as T, error: error ? error.message : null };
|
||||
}
|
||||
|
||||
// ─── Service role client (server-only, never exposed to browser) ───────────
|
||||
|
||||
function getServiceClient() {
|
||||
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!roleKey) {
|
||||
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set. Cannot use service role in dev path.");
|
||||
}
|
||||
return createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
roleKey,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dev-only path — uses service role to create auth user + admin_users ──
|
||||
|
||||
async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return { user: null, error: "Dev path not available in production" };
|
||||
}
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
if (!devSession || devSession !== "platform_admin") {
|
||||
return { user: null, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
const service = getServiceClient();
|
||||
|
||||
// Create auth user with the provided password
|
||||
const { data: authUser, error: authError } = await service.auth.admin.createUser({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
},
|
||||
});
|
||||
if (authError || !authUser.user) {
|
||||
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
||||
}
|
||||
|
||||
// Insert into admin_users
|
||||
const { data: inserted, error: insertError } = await service
|
||||
.from("admin_users")
|
||||
.insert({
|
||||
user_id: authUser.user.id,
|
||||
role: input.role,
|
||||
brand_id: input.brand_id,
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
can_manage_products: input.flags.can_manage_products ?? false,
|
||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||
can_manage_users: input.flags.can_manage_users ?? false,
|
||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||
active: true,
|
||||
must_change_password: input.mustChangePassword ?? true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
return { user: null, error: insertError.message };
|
||||
}
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||
await sendWelcomeEmail({
|
||||
to: input.email,
|
||||
name: input.display_name || input.email.split("@")[0],
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
brandName: "Tuxedo Corn",
|
||||
tempPassword: input.password,
|
||||
});
|
||||
} catch (e) {
|
||||
// welcome email failed silently
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: inserted.id,
|
||||
user_id: inserted.user_id,
|
||||
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
|
||||
email: input.email,
|
||||
phone_number: inserted.phone_number ?? input.phone_number ?? null,
|
||||
role: inserted.role,
|
||||
brand_id: inserted.brand_id,
|
||||
brand_name: null,
|
||||
can_manage_products: inserted.can_manage_products,
|
||||
can_manage_stops: inserted.can_manage_stops,
|
||||
can_manage_orders: inserted.can_manage_orders,
|
||||
can_manage_pickup: inserted.can_manage_pickup,
|
||||
can_manage_messages: inserted.can_manage_messages,
|
||||
can_manage_refunds: inserted.can_manage_refunds,
|
||||
can_manage_users: inserted.can_manage_users,
|
||||
can_manage_water_log: inserted.can_manage_water_log,
|
||||
can_manage_reports: inserted.can_manage_reports,
|
||||
active: inserted.active,
|
||||
must_change_password: inserted.must_change_password ?? true,
|
||||
created_at: inserted.created_at,
|
||||
last_login: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
// ─── Row mapping ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `admin_users` schema (after migration 204 + 034 + 037):
|
||||
// id, user_id, display_name, email, phone_number, role, brand_id,
|
||||
// can_manage_<X> (BOOLEAN each), active, must_change_password,
|
||||
// created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
|
||||
|
||||
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
user_id: String(row.user_id ?? ""),
|
||||
user_id: (row.user_id as string | null) ?? null,
|
||||
display_name: (row.display_name as string | null) ?? null,
|
||||
email: String(row.email ?? ""),
|
||||
phone_number: (row.phone_number as string | null) ?? null,
|
||||
@@ -256,419 +105,272 @@ function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Dev path helpers (service role, local only) ───────────────────────────
|
||||
// ─── Welcome email (best-effort) ────────────────────────────────────────────
|
||||
|
||||
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||
const service = getServiceClient();
|
||||
|
||||
// Ensure caller has an admin_users record
|
||||
if (callerUid) {
|
||||
const { data: existing } = await service
|
||||
.from("admin_users")
|
||||
.select("id")
|
||||
.eq("user_id", callerUid)
|
||||
.maybeSingle();
|
||||
|
||||
if (!existing) {
|
||||
// auto-creating admin_users for uid
|
||||
const { data: authData } = await service.auth.admin.listUsers();
|
||||
const authUser = authData?.users?.find((u) => u.id === callerUid);
|
||||
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata;
|
||||
await service.from("admin_users").insert({
|
||||
user_id: callerUid,
|
||||
role: "platform_admin",
|
||||
brand_id: null,
|
||||
display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin",
|
||||
phone_number: (meta?.phone_number as string | null) ?? null,
|
||||
can_manage_products: true,
|
||||
can_manage_stops: true,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: true,
|
||||
can_manage_refunds: true,
|
||||
can_manage_users: true,
|
||||
can_manage_water_log: true,
|
||||
can_manage_reports: true,
|
||||
active: true,
|
||||
must_change_password: false,
|
||||
});
|
||||
}
|
||||
async function sendWelcomeEmailSafe(input: {
|
||||
to: string;
|
||||
name: string;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||
password: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||
await sendWelcomeEmail({
|
||||
to: input.to,
|
||||
name: input.name,
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
brandName: "Tuxedo Corn",
|
||||
tempPassword: input.password,
|
||||
});
|
||||
} catch {
|
||||
// welcome email is best-effort; never block user creation
|
||||
}
|
||||
|
||||
// Fetch all admin_users rows (no RLS for service role)
|
||||
const { data: adminRows, error: adminError } = await service
|
||||
.from("admin_users")
|
||||
.select(`
|
||||
id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
|
||||
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
brands (name)
|
||||
`)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (adminError) return { users: [], error: adminError.message };
|
||||
|
||||
// Fetch auth user details via service role admin API
|
||||
const { data: authData, error: authError } = await service.auth.admin.listUsers();
|
||||
if (authError) return { users: [], error: authError.message };
|
||||
|
||||
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
|
||||
(authData?.users ?? []).forEach((u) => {
|
||||
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
|
||||
authMap[user.id] = {
|
||||
email: user.email ?? "",
|
||||
display_name: (user.user_metadata?.display_name as string | null) ?? (user.user_metadata?.full_name as string | null) ?? null,
|
||||
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
|
||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
||||
return {
|
||||
...mapUserRow(r),
|
||||
email: authInfo.email || "No Email",
|
||||
display_name: authInfo.display_name ?? null,
|
||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
||||
brand_name: r.brands?.name ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return { users, error: null };
|
||||
}
|
||||
|
||||
function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { id: string; email?: string; user_metadata?: Record<string, unknown> }[]): { users: AdminUserRow[]; error: string | null } {
|
||||
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
|
||||
(authUsers ?? []).forEach((u) => {
|
||||
authMap[u.id] = {
|
||||
email: u.email ?? "",
|
||||
display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null,
|
||||
phone_number: (u.user_metadata?.phone_number as string | null) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const users: AdminUserRow[] = adminRows.map((row) => {
|
||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
||||
return {
|
||||
...mapUserRow(r),
|
||||
email: authInfo.email || "No Email",
|
||||
display_name: authInfo.display_name ?? null,
|
||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
||||
brand_name: r.brands?.name ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return { users, error: null };
|
||||
}
|
||||
|
||||
// ─── Production admin actions (require real Supabase auth) ─────────────────
|
||||
// ─── Public actions ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
let filteredUsers = mockUsers;
|
||||
if (brandId) {
|
||||
filteredUsers = mockUsers.filter(u => u.brand_id === brandId);
|
||||
}
|
||||
return { users: filteredUsers, error: null };
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
|
||||
// Read rc_auth_uid for force-login check
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
// In development mode: 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"
|
||||
);
|
||||
if (isDevAdmin) {
|
||||
return devListAdminUsers(rcAuthUid ?? undefined);
|
||||
}
|
||||
|
||||
// Production path: try authenticated RPC first, fall back to service role if not authenticated
|
||||
const result = await callRpcWithAuth<AdminUserRow[]>("get_admin_users", { p_brand_id: brandId ?? null });
|
||||
if (result.error === "Not authenticated" && rcAuthUid) {
|
||||
// No Supabase session token in browser — use service role with rc_auth_uid
|
||||
const service = getServiceClient();
|
||||
const { data: adminRows, error: adminError } = await service
|
||||
.from("admin_users")
|
||||
.select(`id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
|
||||
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
brands (name)`)
|
||||
.order("created_at", { ascending: false });
|
||||
if (adminError) return { users: [], error: adminError.message };
|
||||
const { data: authData } = await service.auth.admin.listUsers();
|
||||
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
|
||||
(authData?.users ?? []).forEach((u) => {
|
||||
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
|
||||
authMap[user.id] = {
|
||||
email: user.email ?? "",
|
||||
display_name: (user.user_metadata?.display_name as string | null) ?? null,
|
||||
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
|
||||
};
|
||||
});
|
||||
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
|
||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
||||
return {
|
||||
...mapUserRow(r),
|
||||
email: authInfo.email || "No Email",
|
||||
display_name: authInfo.display_name ?? null,
|
||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
||||
brand_name: r.brands?.name ?? null,
|
||||
};
|
||||
});
|
||||
return { users, error: null };
|
||||
}
|
||||
return { users: result.data ?? [], error: result.error };
|
||||
}
|
||||
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
// Read auth context
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
|
||||
// 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";
|
||||
|
||||
// Dev path: use service role to create user without Supabase auth session
|
||||
if (isDevAdmin) {
|
||||
return devCreateAdminUser(input);
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Create auth user
|
||||
const { data: authUser, error: authError } = await service.auth.admin.createUser({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
},
|
||||
});
|
||||
if (authError || !authUser.user) {
|
||||
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
||||
}
|
||||
|
||||
// Insert into admin_users
|
||||
const { data: inserted, error: insertError } = await service
|
||||
.from("admin_users")
|
||||
.insert({
|
||||
user_id: authUser.user.id,
|
||||
role: input.role,
|
||||
brand_id: input.brand_id,
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
can_manage_products: input.flags.can_manage_products ?? false,
|
||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||
can_manage_users: input.flags.can_manage_users ?? false,
|
||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||
active: true,
|
||||
must_change_password: input.mustChangePassword ?? true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) return { user: null, error: insertError.message };
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||
await sendWelcomeEmail({
|
||||
to: input.email,
|
||||
name: input.display_name || input.email.split("@")[0],
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
brandName: "Tuxedo Corn",
|
||||
tempPassword: input.password,
|
||||
});
|
||||
} catch (e) {
|
||||
// welcome email failed silently
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: inserted.id,
|
||||
user_id: inserted.user_id,
|
||||
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
|
||||
email: input.email,
|
||||
phone_number: inserted.phone_number ?? input.phone_number ?? null,
|
||||
role: inserted.role,
|
||||
brand_id: inserted.brand_id,
|
||||
brand_name: null,
|
||||
can_manage_products: inserted.can_manage_products,
|
||||
can_manage_stops: inserted.can_manage_stops,
|
||||
can_manage_orders: inserted.can_manage_orders,
|
||||
can_manage_pickup: inserted.can_manage_pickup,
|
||||
can_manage_messages: inserted.can_manage_messages,
|
||||
can_manage_refunds: inserted.can_manage_refunds,
|
||||
can_manage_users: inserted.can_manage_users,
|
||||
can_manage_water_log: inserted.can_manage_water_log,
|
||||
can_manage_reports: inserted.can_manage_reports,
|
||||
active: inserted.active,
|
||||
must_change_password: inserted.must_change_password ?? true,
|
||||
created_at: inserted.created_at,
|
||||
last_login: null,
|
||||
},
|
||||
users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { user: null, error: "Not authenticated" };
|
||||
try {
|
||||
const sql = brandId
|
||||
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
||||
au.role, au.brand_id, b.name AS brand_name,
|
||||
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
|
||||
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
|
||||
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
|
||||
au.active, au.must_change_password, au.created_at, au.last_login
|
||||
FROM admin_users au
|
||||
LEFT JOIN brands b ON b.id = au.brand_id
|
||||
WHERE au.brand_id = $1
|
||||
ORDER BY au.created_at DESC`
|
||||
: `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
||||
au.role, au.brand_id, b.name AS brand_name,
|
||||
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
|
||||
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
|
||||
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
|
||||
au.active, au.must_change_password, au.created_at, au.last_login
|
||||
FROM admin_users au
|
||||
LEFT JOIN brands b ON b.id = au.brand_id
|
||||
ORDER BY au.created_at DESC`;
|
||||
const { rows } = await query<Record<string, unknown>>(sql, brandId ? [brandId] : []);
|
||||
return { users: rows.map(mapUserRow), error: null };
|
||||
} catch (err) {
|
||||
return { users: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const newRow: AdminUserRow = {
|
||||
id: `mock-${Date.now()}`,
|
||||
user_id: null,
|
||||
display_name: input.display_name ?? input.email.split("@")[0],
|
||||
email: input.email,
|
||||
phone_number: input.phone_number ?? null,
|
||||
role: input.role,
|
||||
brand_id: input.brand_id,
|
||||
brand_name: null,
|
||||
can_manage_products: input.flags.can_manage_products ?? false,
|
||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||
can_manage_users: input.flags.can_manage_users ?? false,
|
||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||
active: true,
|
||||
must_change_password: input.mustChangePassword ?? true,
|
||||
created_at: new Date().toISOString(),
|
||||
last_login: null,
|
||||
};
|
||||
mockUsers.push(newRow);
|
||||
return { user: newRow, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// No Supabase Auth — `user_id` stays NULL until the user signs in
|
||||
// via Auth.js and `get_admin_user_for_session` matches them by
|
||||
// `auth_subject` / `email`. We just insert the row.
|
||||
const f = input.flags;
|
||||
const { rows } = await query<Record<string, unknown>>(
|
||||
`INSERT INTO admin_users
|
||||
(user_id, display_name, email, phone_number, role, brand_id,
|
||||
can_manage_products, can_manage_stops, can_manage_orders,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
active, must_change_password, auth_provider, auth_subject)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'pending',$17)
|
||||
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
||||
can_manage_products, can_manage_stops, can_manage_orders,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
active, must_change_password, created_at, last_login`,
|
||||
[
|
||||
null,
|
||||
input.display_name ?? input.email.split("@")[0],
|
||||
input.email.toLowerCase(),
|
||||
input.phone_number ?? null,
|
||||
input.role,
|
||||
input.brand_id,
|
||||
f.can_manage_products ?? false,
|
||||
f.can_manage_stops ?? false,
|
||||
f.can_manage_orders ?? false,
|
||||
f.can_manage_pickup ?? false,
|
||||
f.can_manage_messages ?? false,
|
||||
f.can_manage_refunds ?? false,
|
||||
f.can_manage_users ?? false,
|
||||
f.can_manage_water_log ?? false,
|
||||
f.can_manage_reports ?? false,
|
||||
input.mustChangePassword ?? true,
|
||||
input.email.toLowerCase(),
|
||||
],
|
||||
);
|
||||
if (!rows[0]) return { user: null, error: "Insert returned no row" };
|
||||
|
||||
await sendWelcomeEmailSafe({
|
||||
to: input.email,
|
||||
name: input.display_name ?? input.email.split("@")[0],
|
||||
role: input.role,
|
||||
password: input.password,
|
||||
});
|
||||
|
||||
return { user: mapUserRow(rows[0]), error: null };
|
||||
} catch (err) {
|
||||
return { user: null, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
// Dev bypass check
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
// 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")
|
||||
.update({
|
||||
role: input.role ?? undefined,
|
||||
brand_id: input.brand_id ?? undefined,
|
||||
can_manage_products: input.flags?.can_manage_products ?? undefined,
|
||||
can_manage_stops: input.flags?.can_manage_stops ?? undefined,
|
||||
can_manage_orders: input.flags?.can_manage_orders ?? undefined,
|
||||
can_manage_pickup: input.flags?.can_manage_pickup ?? undefined,
|
||||
can_manage_messages: input.flags?.can_manage_messages ?? undefined,
|
||||
can_manage_refunds: input.flags?.can_manage_refunds ?? undefined,
|
||||
can_manage_users: input.flags?.can_manage_users ?? undefined,
|
||||
can_manage_water_log: input.flags?.can_manage_water_log ?? undefined,
|
||||
can_manage_reports: input.flags?.can_manage_reports ?? undefined,
|
||||
active: input.active ?? undefined,
|
||||
display_name: input.display_name ?? null,
|
||||
phone_number: input.phone_number ?? null,
|
||||
})
|
||||
.eq("id", input.id)
|
||||
.select()
|
||||
.single();
|
||||
if (error) return { user: null, error: error.message };
|
||||
return { user: mapUserRow(data), error: null };
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const idx = mockUsers.findIndex((u) => u.id === input.id);
|
||||
if (idx === -1) return { user: null, error: "User not found" };
|
||||
const merged: AdminUserRow = { ...mockUsers[idx] };
|
||||
if (input.role !== undefined) merged.role = input.role;
|
||||
if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
|
||||
if (input.active !== undefined) merged.active = input.active;
|
||||
if (input.display_name !== undefined) merged.display_name = input.display_name;
|
||||
if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
|
||||
if (input.flags) {
|
||||
for (const [k, v] of Object.entries(input.flags)) {
|
||||
if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
mockUsers[idx] = merged;
|
||||
return { user: merged, error: null };
|
||||
}
|
||||
|
||||
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
|
||||
p_id: input.id,
|
||||
p_role: input.role ?? null,
|
||||
p_brand_id: input.brand_id ?? null,
|
||||
p_flags: input.flags ?? null,
|
||||
p_active: input.active ?? null,
|
||||
p_display_name: input.display_name ?? null,
|
||||
p_phone_number: input.phone_number ?? null,
|
||||
});
|
||||
const rows = result.data as AdminUserRow[] | null;
|
||||
return { user: rows?.[0] ?? null, error: result.error };
|
||||
try {
|
||||
// Build a partial SET clause. Each `can_manage_*` column is set
|
||||
// individually — the input's `flags` partial is spread across them.
|
||||
const sets: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); };
|
||||
|
||||
if (input.role !== undefined) push("role", input.role);
|
||||
if (input.brand_id !== undefined) push("brand_id", input.brand_id);
|
||||
if (input.active !== undefined) push("active", input.active);
|
||||
if (input.display_name !== undefined) push("display_name", input.display_name);
|
||||
if (input.phone_number !== undefined) push("phone_number", input.phone_number);
|
||||
if (input.flags) {
|
||||
for (const [key, val] of Object.entries(input.flags)) {
|
||||
if (val !== undefined) push(key, val);
|
||||
}
|
||||
}
|
||||
if (sets.length === 0) return { user: null, error: "Nothing to update" };
|
||||
|
||||
params.push(input.id);
|
||||
const sql = `UPDATE admin_users SET ${sets.join(", ")}
|
||||
WHERE id = $${params.length}
|
||||
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
||||
can_manage_products, can_manage_stops, can_manage_orders,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
active, must_change_password, created_at, last_login`;
|
||||
const { rows } = await query<Record<string, unknown>>(sql, params);
|
||||
if (!rows[0]) return { user: null, error: "User not found" };
|
||||
return { user: mapUserRow(rows[0]), error: null };
|
||||
} catch (err) {
|
||||
return { user: null, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||
// Dev bypass check
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
// 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
|
||||
.from("admin_users")
|
||||
.select("user_id")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
if (fetchError) return { success: false, error: fetchError.message };
|
||||
// Delete from admin_users
|
||||
const { error: deleteError } = await service.from("admin_users").delete().eq("id", id);
|
||||
if (deleteError) return { success: false, error: deleteError.message };
|
||||
// Delete auth user
|
||||
if (adminRow?.user_id) {
|
||||
await service.auth.admin.deleteUser(adminRow.user_id);
|
||||
}
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const idx = mockUsers.findIndex((u) => u.id === id);
|
||||
if (idx === -1) return { success: false, error: "User not found" };
|
||||
mockUsers.splice(idx, 1);
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
const result = await callRpcWithAuth<boolean>("delete_admin_user", { p_id: id });
|
||||
return { success: result.data ?? false, error: result.error };
|
||||
try {
|
||||
// No Supabase Auth — nothing to delete from the auth service.
|
||||
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
|
||||
return { success: (rowCount ?? 0) > 0, error: null };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
// 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 };
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const u = mockUsers.find((m) => m.id === userId);
|
||||
if (!u) return { success: false, error: "User not found" };
|
||||
u.must_change_password = true;
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
// Production path — use service role via direct update
|
||||
const service = getServiceClient();
|
||||
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
||||
return { success: !error, error: error?.message ?? null };
|
||||
try {
|
||||
const { rowCount } = await query(
|
||||
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
return { success: (rowCount ?? 0) > 0, error: null };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await publicSupabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
|
||||
});
|
||||
return { success: !error, error: error?.message ?? null };
|
||||
/**
|
||||
* No auth service anymore (no Supabase, no Auth.js password-reset
|
||||
* endpoint). A platform admin can reset access by deleting +
|
||||
* re-creating the user, or by toggling `must_change_password` via the
|
||||
* UI — the function is preserved as a no-op so call sites keep
|
||||
* compiling.
|
||||
*/
|
||||
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const brands = mockBrands.map(b => ({ id: b.id, name: b.name }));
|
||||
return { brands, error: null };
|
||||
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
|
||||
}
|
||||
try {
|
||||
const { rows } = await query<{ id: string; name: string }>(
|
||||
`SELECT id, name FROM brands ORDER BY name`,
|
||||
);
|
||||
return { brands: rows, error: null };
|
||||
} catch (err) {
|
||||
return { brands: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await publicSupabase.from("brands").select("id, name").order("name");
|
||||
return { brands: data ?? [], error: error?.message ?? null };
|
||||
}
|
||||
// Keep `pool` reachable so bundlers don't tree-shake the import — the
|
||||
// import is for the `server-only` side effect.
|
||||
void pool;
|
||||
|
||||
@@ -21,9 +21,7 @@ type AuditResult =
|
||||
/**
|
||||
* Logs an audit event to the audit_logs table.
|
||||
*
|
||||
* In dev mode (dev_session cookie), uses the dev user identity.
|
||||
* In production (Supabase auth), resolves the admin user from admin_users.
|
||||
*
|
||||
* Resolves the admin user from the Auth.js session via getAdminUser().
|
||||
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
|
||||
*/
|
||||
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
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.
|
||||
* Kick off the Google OAuth flow. Auth.js will redirect to Google's
|
||||
* consent screen and then back to /api/auth/callback/google, which sets
|
||||
* the session cookie and redirects to /admin.
|
||||
*
|
||||
* The historical Supabase-backed email/password sign-in action was
|
||||
* removed in the cleanup pass. Admin accounts are provisioned by an
|
||||
* existing platform admin via /admin/users.
|
||||
*/
|
||||
export async function signInWithGoogle(): Promise<void> {
|
||||
await signIn("google", { redirectTo: "/admin" });
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { AdminUserRow } from "@/actions/admin/users";
|
||||
import { logUserActivity } from "@/actions/admin/audit";
|
||||
|
||||
type ProfilePageProps = {
|
||||
currentUser: AdminUserRow;
|
||||
@@ -21,53 +19,24 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
|
||||
// Profile / email mutations used to call Supabase directly. With the
|
||||
// platform moved off Supabase entirely, those handlers are stubbed out
|
||||
// — the page remains read-only until a server-action equivalent ships.
|
||||
// See the final YOLO report for the broader Supabase → pg data-fetch
|
||||
// migration that covers the rest of the admin pages.
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { error: rpcError } = await supabase.rpc("update_admin_user", {
|
||||
p_id: currentUser.id,
|
||||
p_display_name: displayName || null,
|
||||
p_phone_number: phoneNumber || null,
|
||||
});
|
||||
if (rpcError) {
|
||||
setError(rpcError.message);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
user_id: currentUser.user_id,
|
||||
activity_type: "profile_update",
|
||||
details: { fields: ["display_name", "phone_number"] },
|
||||
});
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setError("Profile editing is temporarily unavailable. Contact a platform admin.");
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleEmailChange(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setChangingEmail(true);
|
||||
setEmailError(null);
|
||||
try {
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
email: newEmail,
|
||||
});
|
||||
if (updateError) {
|
||||
setEmailError(updateError.message);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
user_id: currentUser.user_id,
|
||||
activity_type: "email_change",
|
||||
details: { new_email: newEmail },
|
||||
});
|
||||
setEmailChangeSent(true);
|
||||
setChangingEmail(false);
|
||||
} finally {
|
||||
setChangingEmail(false);
|
||||
}
|
||||
setEmailError("Email changes are temporarily unavailable. Contact a platform admin.");
|
||||
setChangingEmail(false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
||||
response.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
httpOnly: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
||||
response.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
httpOnly: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
+48
-416
@@ -1,116 +1,73 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import { signInWithGoogle } from "@/actions/auth-actions";
|
||||
|
||||
function LoginForm() {
|
||||
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);
|
||||
const [forgotLoading, setForgotLoading] = useState(false);
|
||||
const [forgotError, setForgotError] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
type LoginClientProps = {
|
||||
hasGoogle: boolean;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
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");
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
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;
|
||||
function GoogleSignIn({ hasGoogle }: { hasGoogle: boolean }) {
|
||||
if (!hasGoogle) {
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<p className="font-medium">Google sign-in is not configured.</p>
|
||||
<p className="mt-1 text-amber-800">
|
||||
Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
|
||||
<code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
|
||||
environment to enable it. See{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://authjs.dev/getting-started/providers/google"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Auth.js Google provider docs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginClient({ hasGoogle }: LoginClientProps) {
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
||||
{/* Google Fonts */}
|
||||
<style jsx global>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
||||
html, body { overflow: hidden; }
|
||||
`}</style>
|
||||
|
||||
{/* Organic background elements */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
||||
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="w-full py-6 px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
|
||||
Route Commerce
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className={`w-full max-w-sm transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"}`}>
|
||||
{/* Card */}
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
|
||||
{/* Subtle top accent */}
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
|
||||
|
||||
<div className="p-8 sm:p-10">
|
||||
{/* Logo & Title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
|
||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -125,336 +82,11 @@ 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">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{globalError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
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"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Password</label>
|
||||
<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={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" }}
|
||||
>
|
||||
{submitting ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
|
||||
{!forgotPassword && !forgotSent && (
|
||||
<button
|
||||
type="button"
|
||||
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" }}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Password Reset Form */}
|
||||
{forgotPassword && !forgotSent && (
|
||||
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4 border-t border-stone-200/50 pt-6" aria-label="Password reset form">
|
||||
<p className="text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b6b6b" }}>Enter your email and we'll send you a reset link.</p>
|
||||
{forgotError && (
|
||||
<div role="alert" className="rounded-xl bg-red-50/80 p-3 text-sm text-red-600 border border-red-100">
|
||||
{forgotError}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="email"
|
||||
value={forgotEmail}
|
||||
onChange={(e) => setForgotEmail(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white/90 px-4 py-3.5 text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400 transition-all"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||
placeholder="you@company.com"
|
||||
aria-required="true"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotLoading}
|
||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
|
||||
>
|
||||
{forgotLoading ? "Sending..." : "Send Reset Link"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setForgotPassword(false); setForgotEmail(""); setForgotError(null); }}
|
||||
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
||||
>
|
||||
← Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Reset Email Sent */}
|
||||
{forgotSent && (
|
||||
<div className="mt-6 border-t border-stone-200/50 pt-6" role="status" aria-live="polite">
|
||||
<div className="rounded-xl p-4 text-sm border" style={{ backgroundColor: "#f0fdf4", color: "#166534", borderColor: "#bbf7d0" }}>
|
||||
<strong>Check your inbox.</strong> If an account exists for <span className="font-medium">{forgotEmail}</span>, a reset link has been sent.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setForgotPassword(false); setForgotSent(false); setForgotEmail(""); }}
|
||||
className="mt-4 w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
||||
>
|
||||
← Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<GoogleSignIn hasGoogle={hasGoogle} />
|
||||
</div>
|
||||
|
||||
{/* Security Trust Badges */}
|
||||
<div className="border-t border-stone-100/50 px-8 py-5" style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-center gap-4 text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>256-bit SSL</span>
|
||||
</div>
|
||||
<span className="text-stone-300">•</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span>SOC 2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="text-center mt-6">
|
||||
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
View Farms
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
|
||||
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
|
||||
© {new Date().getFullYear()} Route Commerce
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6">
|
||||
<Link href="/privacy-policy" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="/terms-and-conditions" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
|
||||
Terms
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Demo mode wrapper
|
||||
function DemoMode() {
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
||||
{/* Organic background elements */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="w-full py-6 px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
|
||||
Route Commerce
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Demo Card */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden p-10">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
|
||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
|
||||
Demo Mode
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
||||
Select a role to explore the platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => { document.cookie = "dev_session=platform_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
|
||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
|
||||
>
|
||||
Platform Admin
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { document.cookie = "dev_session=brand_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
|
||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#c97a3e" }}
|
||||
>
|
||||
Brand Admin
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { document.cookie = "dev_session=store_employee; path=/; max-age=86400"; window.location.replace("/admin"); }}
|
||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#6b8f71" }}
|
||||
>
|
||||
Store Employee
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="text-center mt-6">
|
||||
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
View Farms
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
|
||||
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
|
||||
© {new Date().getFullYear()} Route Commerce
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Inner component that uses useSearchParams - must be wrapped in Suspense
|
||||
function LoginPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const isDemo = searchParams.get("demo") === "1";
|
||||
if (isDemo) return <DemoMode />;
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
export default function LoginClient() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-emerald-600 to-emerald-500 animate-pulse" />
|
||||
<p className="text-stone-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<LoginPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,5 +22,9 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginClient />;
|
||||
// The Google provider is only added to the Auth.js config when these
|
||||
// two env vars are set. Pass the flag down so the client can hide the
|
||||
// button (and surface a helpful message) when Google is unavailable.
|
||||
const hasGoogle = !!(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET);
|
||||
return <LoginClient hasGoogle={hasGoogle} />;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { signOutAction } from "@/actions/auth-actions";
|
||||
|
||||
type AdminHeaderProps = {
|
||||
userRole?: string | null;
|
||||
@@ -89,10 +89,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
||||
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
await signOutAction();
|
||||
}
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { signOutAction } from "@/actions/auth-actions";
|
||||
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
@@ -296,10 +296,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
}, [router, mobileOpen, closeMobileMenu]);
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
await signOutAction();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
// Shared AdminUser type — safe to import from both server and client components
|
||||
// Shared AdminUser type — safe to import from both server and client
|
||||
// components. The shape mirrors what `getAdminUser()` returns and
|
||||
// includes both the user's role and the tenant they belong to.
|
||||
|
||||
export type AdminRole = "platform_admin" | "brand_admin" | "store_employee";
|
||||
|
||||
export type AdminUser = {
|
||||
id?: string;
|
||||
/** user.id from the `users` table — or "dev" for dev_session cookies */
|
||||
id: string;
|
||||
/** user_id (same as id) — kept for legacy callers */
|
||||
user_id: string;
|
||||
/** email from the `users` table, or null for dev shims */
|
||||
email: string | null;
|
||||
/** display name */
|
||||
display_name: string | null;
|
||||
/** tenant id from `tenant_users`, or null for platform_admin */
|
||||
tenant_id: string | null;
|
||||
/**
|
||||
* @deprecated Use `tenant_id` instead. Kept for backward compat with
|
||||
* call sites that haven't been migrated yet. Always mirrors
|
||||
* `tenant_id`; will be removed in a later cleanup pass.
|
||||
*/
|
||||
brand_id: string | null;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
|
||||
/** tenant slug (for storefronts) */
|
||||
tenant_slug: string | null;
|
||||
/** role within the tenant (or platform-wide for platform_admin) */
|
||||
role: AdminRole;
|
||||
/** is the user active? */
|
||||
active: boolean;
|
||||
/** auth provider */
|
||||
auth_provider: "dev" | "google" | "email" | null;
|
||||
|
||||
// ── Permission flags ────────────────────────────────────────────
|
||||
// Derived from the role, but exposed as individual booleans so
|
||||
// existing consumer code (forms, sidebar, etc.) can read them
|
||||
// directly without doing role math. See `permissionsForRole()` in
|
||||
// admin-permissions.ts for the source of truth.
|
||||
can_manage_products: boolean;
|
||||
can_manage_stops: boolean;
|
||||
can_manage_orders: boolean;
|
||||
@@ -14,5 +45,21 @@ export type AdminUser = {
|
||||
can_manage_water_log: boolean;
|
||||
can_manage_reports: boolean;
|
||||
can_manage_settings: boolean;
|
||||
can_manage_billing: boolean;
|
||||
can_manage_branding: boolean;
|
||||
can_manage_marketing: boolean;
|
||||
can_manage_team: boolean;
|
||||
|
||||
/** must the user change their password? (legacy; unused) */
|
||||
must_change_password?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type TenantContext = {
|
||||
tenant: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
};
|
||||
user: AdminUser;
|
||||
};
|
||||
|
||||
+197
-175
@@ -1,201 +1,223 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
brand_id: string | null;
|
||||
role: string;
|
||||
active: boolean;
|
||||
can_manage_products: boolean;
|
||||
can_manage_stops: boolean;
|
||||
can_manage_orders: boolean;
|
||||
can_manage_pickup: boolean;
|
||||
can_manage_messages: boolean;
|
||||
can_manage_refunds: boolean;
|
||||
can_manage_users: boolean;
|
||||
can_manage_water_log: boolean;
|
||||
can_manage_reports: boolean;
|
||||
can_manage_settings: boolean;
|
||||
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;
|
||||
import { withPlatformAdmin } from "@/db/client";
|
||||
import { users, tenants, tenantUsers } from "@/db/schema";
|
||||
import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permissions-types";
|
||||
|
||||
/**
|
||||
* Resolves the current admin user.
|
||||
* Source of truth for 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).
|
||||
* Looks up the Auth.js v5 session, then resolves the user + tenant
|
||||
* from the `users` and `tenant_users` tables.
|
||||
*
|
||||
* 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.
|
||||
* Returns `null` if:
|
||||
* - No Auth.js session (caller not signed in)
|
||||
* - The session email doesn't match any `users.email`
|
||||
* - The user has no `tenant_users` row (not provisioned yet)
|
||||
*
|
||||
* 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.
|
||||
* Provisioning: an admin must run
|
||||
* INSERT INTO users (email, ...) VALUES (...)
|
||||
* INSERT INTO tenant_users (tenant_id, user_id, role) VALUES (...)
|
||||
* to grant a Google-sign-in user admin access. Until provisioned, the
|
||||
* layout shows "Access Denied" — correct behavior.
|
||||
*
|
||||
* The previous `dev_session` cookie bypass has been removed. The only
|
||||
* way into the admin is through real Auth.js (Google in production;
|
||||
* for local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
|
||||
*/
|
||||
export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
let cookieStore;
|
||||
let sessionEmail: string | null = null;
|
||||
try {
|
||||
cookieStore = await cookies();
|
||||
} catch {
|
||||
const session = await auth();
|
||||
sessionEmail = session?.user?.email ?? null;
|
||||
} catch (err) {
|
||||
console.error("[admin-permissions] auth() failed:", err);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Mock data mode for UI review ─────────────────────────────────
|
||||
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
|
||||
return buildDevAdmin("platform_admin");
|
||||
}
|
||||
if (!sessionEmail) return null;
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
return await withPlatformAdmin(async (db) => {
|
||||
const userRows = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, sessionEmail))
|
||||
.limit(1);
|
||||
const user = userRows[0];
|
||||
if (!user) return null;
|
||||
|
||||
// ── Auth.js v5 session ──────────────────────────────────────────
|
||||
let session;
|
||||
try {
|
||||
session = await auth();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const sessionId = session?.user?.id;
|
||||
const email = session?.user?.email?.toLowerCase() ?? null;
|
||||
if (!sessionId) return null;
|
||||
const membershipRows = await db
|
||||
.select({
|
||||
tenantId: tenants.id,
|
||||
tenantName: tenants.name,
|
||||
tenantSlug: tenants.slug,
|
||||
tenantStatus: tenants.status,
|
||||
role: tenantUsers.role,
|
||||
})
|
||||
.from(tenantUsers)
|
||||
.innerJoin(tenants, eq(tenants.id, tenantUsers.tenantId))
|
||||
.where(eq(tenantUsers.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!supabaseUrl || !serviceKey) return null;
|
||||
if (membershipRows.length === 0) {
|
||||
// Signed in but not provisioned for any tenant.
|
||||
return null;
|
||||
}
|
||||
|
||||
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 }),
|
||||
const m = membershipRows[0];
|
||||
const role = m.role as AdminRole;
|
||||
return buildAdminUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.name,
|
||||
authProvider: user.authProvider,
|
||||
tenantId: m.tenantId,
|
||||
tenantSlug: m.tenantSlug,
|
||||
tenantName: m.tenantName,
|
||||
role,
|
||||
active: true,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
* Resolves the current admin user AND their tenant. Returns `null` if
|
||||
* the user is not signed in or has no tenant. For platform_admin (no
|
||||
* tenant), `tenant` is `null` and callers should use `withPlatformAdmin`
|
||||
* to query across all tenants.
|
||||
*/
|
||||
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,
|
||||
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
|
||||
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
|
||||
export async function getCurrentTenant(): Promise<TenantContext | null> {
|
||||
const user = await getAdminUser();
|
||||
if (!user) return null;
|
||||
if (!user.tenant_id) {
|
||||
// platform_admin — no specific tenant
|
||||
return null;
|
||||
}
|
||||
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
|
||||
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
|
||||
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
|
||||
return {
|
||||
user,
|
||||
tenant: {
|
||||
id: user.tenant_id,
|
||||
name: user.display_name ?? user.tenant_slug ?? "Unknown",
|
||||
slug: user.tenant_slug ?? "unknown",
|
||||
status: "active",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdminUser(r: Record<string, unknown>): AdminUser {
|
||||
const role = r.role as string;
|
||||
const base = { id: r.id as string, user_id: r.user_id as string, brand_id: r.brand_id as string | null,
|
||||
role, active: r.active as boolean, must_change_password: Boolean(r.must_change_password) };
|
||||
if (role === "platform_admin") {
|
||||
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
|
||||
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
|
||||
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
|
||||
}
|
||||
if (role === "store_employee") {
|
||||
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
|
||||
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
|
||||
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
|
||||
}
|
||||
return { ...base, can_manage_products: Boolean(r.can_manage_products), can_manage_stops: Boolean(r.can_manage_stops),
|
||||
can_manage_orders: Boolean(r.can_manage_orders), can_manage_pickup: Boolean(r.can_manage_pickup),
|
||||
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
|
||||
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
|
||||
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Re-exports for backward compat
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permissions-types";
|
||||
|
||||
/**
|
||||
* @deprecated Kept for unit tests that exercise the dev shim path.
|
||||
* Production code should never call this — `getAdminUser()` only reads
|
||||
* the Auth.js session now.
|
||||
*/
|
||||
export function buildDevAdmin(role: AdminRole): AdminUser {
|
||||
const isPlatform = role === "platform_admin";
|
||||
const tenantId = isPlatform ? null : "dev-tenant";
|
||||
return {
|
||||
id: "dev",
|
||||
user_id: "dev",
|
||||
email: null,
|
||||
display_name: "Demo Admin",
|
||||
tenant_id: tenantId,
|
||||
brand_id: tenantId, // legacy alias
|
||||
tenant_slug: isPlatform ? null : "tuxedo",
|
||||
role,
|
||||
active: true,
|
||||
auth_provider: "dev",
|
||||
...permissionsForRole(role),
|
||||
must_change_password: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdminUser(input: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
authProvider: "dev" | "google" | "email" | null;
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
tenantName: string;
|
||||
role: AdminRole;
|
||||
active: boolean;
|
||||
}): AdminUser {
|
||||
return {
|
||||
id: input.id,
|
||||
user_id: input.id,
|
||||
email: input.email,
|
||||
display_name: input.displayName,
|
||||
tenant_id: input.tenantId,
|
||||
brand_id: input.tenantId, // legacy alias
|
||||
tenant_slug: input.tenantSlug,
|
||||
role: input.role,
|
||||
active: input.active,
|
||||
auth_provider: input.authProvider,
|
||||
...permissionsForRole(input.role),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for "what can a role do". Used by both the
|
||||
* dev shim and the real user lookup so the demo and the real thing
|
||||
* behave identically.
|
||||
*/
|
||||
export function permissionsForRole(role: AdminRole) {
|
||||
if (role === "platform_admin") {
|
||||
return {
|
||||
can_manage_products: true,
|
||||
can_manage_stops: true,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: true,
|
||||
can_manage_refunds: true,
|
||||
can_manage_users: true,
|
||||
can_manage_water_log: true,
|
||||
can_manage_reports: true,
|
||||
can_manage_settings: true,
|
||||
can_manage_billing: true,
|
||||
can_manage_branding: true,
|
||||
can_manage_marketing: true,
|
||||
can_manage_team: true,
|
||||
};
|
||||
}
|
||||
if (role === "brand_admin") {
|
||||
return {
|
||||
can_manage_products: true,
|
||||
can_manage_stops: true,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: true,
|
||||
can_manage_refunds: true,
|
||||
can_manage_users: false,
|
||||
can_manage_water_log: true,
|
||||
can_manage_reports: true,
|
||||
can_manage_settings: true,
|
||||
can_manage_billing: true,
|
||||
can_manage_branding: true,
|
||||
can_manage_marketing: true,
|
||||
can_manage_team: true,
|
||||
};
|
||||
}
|
||||
// store_employee
|
||||
return {
|
||||
can_manage_products: false,
|
||||
can_manage_stops: false,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: false,
|
||||
can_manage_refunds: false,
|
||||
can_manage_users: false,
|
||||
can_manage_water_log: false,
|
||||
can_manage_reports: false,
|
||||
can_manage_settings: false,
|
||||
can_manage_billing: false,
|
||||
can_manage_branding: false,
|
||||
can_manage_marketing: false,
|
||||
can_manage_team: false,
|
||||
};
|
||||
}
|
||||
|
||||
+19
-65
@@ -4,12 +4,18 @@ 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.)
|
||||
* - Google OAuth — only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET
|
||||
* are set.
|
||||
*
|
||||
* Session strategy: JWT. No database adapter — admin user lookup is handled by
|
||||
* the existing SECURITY DEFINER RPCs + Supabase REST in `getAdminUser()`.
|
||||
* Supabase is no longer used for auth (or anything else) on this platform.
|
||||
* The historical Supabase-backed Credentials provider was removed in the
|
||||
* cleanup pass. New admin users are provisioned manually by an existing
|
||||
* platform admin via /admin/users (the action creates an `admin_users`
|
||||
* row linked to the Google `sub` after the user signs in for the first
|
||||
* time).
|
||||
*
|
||||
* Session strategy: JWT. No database adapter — admin user lookup is
|
||||
* delegated to `getAdminUser()` in `src/lib/admin-permissions.ts`.
|
||||
*
|
||||
* Required env vars (production):
|
||||
* - AUTH_SECRET — JWT signing secret
|
||||
@@ -17,15 +23,15 @@ import "server-only";
|
||||
* - 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.
|
||||
* Backward compatibility: the `dev_session` cookie was the source of
|
||||
* truth for the demo flow but has been removed — `getAdminUser()` and
|
||||
* the middleware now use only the Auth.js session. The legacy
|
||||
* `rc_auth_uid` cookie was retired earlier — see the
|
||||
* final report for the cleanup notes.
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -39,8 +45,6 @@ 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({
|
||||
@@ -50,59 +54,9 @@ const googleProvider = hasGoogleCreds
|
||||
]
|
||||
: [];
|
||||
|
||||
// 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],
|
||||
providers: googleProvider,
|
||||
session: { strategy: "jwt" },
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
@@ -110,8 +64,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
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.
|
||||
// `user.id` is the provider's stable subject — for Google sign-ins
|
||||
// this is the opaque `sub` claim.
|
||||
if (user.id) token.id = user.id;
|
||||
if (user.email) token.email = user.email;
|
||||
}
|
||||
|
||||
+11
-35
@@ -1,17 +1,19 @@
|
||||
// NextAuth v5 + Supabase Auth Middleware
|
||||
// NextAuth v5 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
|
||||
// 4. 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.
|
||||
// The legacy `dev_session` cookie bypass has been removed. The only way
|
||||
// into the admin is through real Auth.js (Google in production; for
|
||||
// local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
|
||||
//
|
||||
// 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.
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
@@ -19,38 +21,12 @@ import { NextResponse } from "next/server";
|
||||
export default auth((req) => {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// ── 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";
|
||||
|
||||
const isAuthed = hasSession || isDevSession;
|
||||
const isAuthed = !!req.auth;
|
||||
|
||||
const isAdmin = pathname.startsWith("/admin");
|
||||
const isLogin = pathname === "/login";
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user