feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s

- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
  deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
  new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
  Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
  brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
  bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
  TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
  public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
  wholesale_settings lookup
This commit is contained in:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit 916ad39176
349 changed files with 6706 additions and 3343 deletions
+12 -3
View File
@@ -1,5 +1,5 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getAdminOrderDetail } from "@/actions/orders";
import { getAdminOrderDetail, type AdminOrder } from "@/actions/orders";
import OrderEditForm from "@/components/admin/OrderEditForm";
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
import OrderPickupAction from "@/components/admin/OrderPickupAction";
@@ -14,6 +14,15 @@ type OrderDetailPageProps = {
}>;
};
type OrderItem = {
id: string;
quantity: number;
price: number | string;
products?: {
name: string;
} | null;
};
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
@@ -139,7 +148,7 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
{order.order_items && order.order_items.length > 0 ? (
<div className="mt-4 divide-y divide-stone-100">
{order.order_items.map((item: any) => (
{order.order_items.map((item: OrderItem) => (
<div
key={item.id}
className="flex items-center justify-between py-3"
@@ -214,7 +223,7 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p>
<div className="mt-5">
<OrderEditForm order={order as any} brandId={brandId} />
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
</div>
</div>
+5 -5
View File
@@ -53,12 +53,12 @@ export default async function AdminOrdersPage() {
}
const { data: prods } = await prodQuery;
brandProducts = (prods ?? []).map((p: any) => ({
id: p.id,
name: p.name,
brandProducts = (prods ?? []).map((p) => ({
id: String(p.id),
name: String(p.name ?? ""),
price: Number(p.price),
type: p.type ?? null,
active: p.active ?? true,
type: p.type as string | null ?? null,
active: Boolean(p.active ?? true),
}));
} catch {
// non-fatal for the orders list
+1 -1
View File
@@ -38,7 +38,7 @@ export default async function AdminPage() {
.limit(1)
.single();
if (firstBrand?.id) {
dashboardBrandId = firstBrand.id;
dashboardBrandId = String(firstBrand.id);
}
} catch (err) {
console.error("[admin/page] supabase brands lookup failed:", err);
+5 -2
View File
@@ -4,6 +4,10 @@ import { getAdminOrders } from "@/actions/orders";
export const dynamic = "force-dynamic";
// Pre-compute cutoff time to avoid impure function in render
const PICKUP_WINDOW_MS = 72 * 60 * 60 * 1000;
const pickedUpCutoff = new Date(Date.now() - PICKUP_WINDOW_MS);
export default async function DriverPickupPage() {
const adminUser = await getAdminUser();
const { orders, stops } = await getAdminOrders();
@@ -26,8 +30,7 @@ export default async function DriverPickupPage() {
(o) =>
o.pickup_complete &&
o.pickup_completed_at &&
new Date(o.pickup_completed_at) >
new Date(Date.now() - 72 * 60 * 60 * 1000)
new Date(o.pickup_completed_at) > pickedUpCutoff
);
return (
+26 -6
View File
@@ -11,12 +11,32 @@ type ProductDetailPageProps = {
}>;
};
interface Product {
id: string;
brand_id: string;
name: string;
description: string | null;
price: number;
active: boolean;
type: string | null;
image_url: string | null;
is_taxable: boolean;
pickup_type: string | null;
brands?: { name: string; slug: string };
}
interface Brand {
id: string;
name: string;
slug: string;
}
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
const { id } = await params;
const [{ data: product, error }, { data: brands }] = await Promise.all([
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single(),
supabase.from("brands").select("id, name, slug"),
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as { data: Product | null; error: { message: string } | null },
supabase.from("brands").select("id, name, slug") as unknown as { data: Brand[] | null },
]);
const adminUser = await getAdminUser();
@@ -109,14 +129,14 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
product={{
id: product.id,
name: product.name,
description: product.description,
description: product.description ?? "",
price: Number(product.price),
type: product.type,
type: product.type ?? "pickup",
active: product.active,
brand_id: product.brand_id,
image_url: product.image_url,
image_url: product.image_url ?? "",
is_taxable: product.is_taxable,
pickup_type: product.pickup_type,
pickup_type: product.pickup_type ?? "pickup",
}}
brands={brands ?? []}
/>
+12 -12
View File
@@ -1,6 +1,6 @@
import { asc, eq, sql } from "drizzle-orm";
import { withPlatformAdmin, withTenant } from "@/db/client";
import { products, productImages, tenants } from "@/db/schema";
import { withPlatformAdmin, withBrand } from "@/db/client";
import { products, productImages, brands } from "@/db/schema";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
@@ -53,14 +53,14 @@ export default async function AdminProductsPage() {
const isPlatformAdmin = adminUser.role === "platform_admin";
// Platform admins need a brand picker for new products
let brands: { id: string; name: string }[] = [];
let brandOptions: { id: string; name: string }[] = [];
if (isPlatformAdmin) {
const result = await getBrands();
brands = result.brands ?? [];
brandOptions = result.brands ?? [];
}
// Query products + their first image. The new SaaS schema:
// * renamed `brand_id` → `tenant_id` on products
// * renamed `brand_id` → `brand_id` on products
// * renamed `price` (numeric) → `price_cents` (integer)
// * moved `image_url` off the products table into a separate
// `product_images` table (keyed by `product_id` + `position`)
@@ -71,7 +71,7 @@ export default async function AdminProductsPage() {
let productsList: ProductRow[] = [];
let queryError: string | null = null;
try {
const baseQuery = (db: Parameters<Parameters<typeof withTenant>[1]>[0]) =>
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
db
.select({
id: products.id,
@@ -79,13 +79,13 @@ export default async function AdminProductsPage() {
description: products.description,
priceCents: products.priceCents,
active: products.active,
tenantId: products.tenantId,
tenantName: tenants.name,
brandId: products.brandId,
brandName: brands.name,
firstImageKey: productImages.storageKey,
firstImagePosition: productImages.position,
})
.from(products)
.leftJoin(tenants, eq(tenants.id, products.tenantId))
.leftJoin(brands, eq(brands.id, products.brandId))
// Pull only the lowest-position image per product. The
// `position` index on product_images (product_id, position) makes
// this cheap; a real-world scale would replace it with a
@@ -105,7 +105,7 @@ export default async function AdminProductsPage() {
const rows = isPlatformAdmin
? await withPlatformAdmin((db) => baseQuery(db))
: brandId
? await withTenant(brandId, (db) => baseQuery(db))
? await withBrand(brandId, (db) => baseQuery(db))
: [];
// Resolve each row's first image to a public URL. Until object-store
@@ -120,7 +120,7 @@ export default async function AdminProductsPage() {
type: "pickup", // legacy column not in new schema
active: r.active,
image_url: r.firstImageKey, // storage key, not a public URL yet
brand_id: r.tenantId,
brand_id: r.brandId,
is_taxable: false, // legacy column not in new schema
available_from: null,
available_until: null,
@@ -147,7 +147,7 @@ export default async function AdminProductsPage() {
<ProductsClient
products={productsList}
brandId={brandId ?? undefined}
brands={brands}
brands={brandOptions}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
+1 -1
View File
@@ -16,7 +16,7 @@ export default async function ReportsPage() {
const { data: brands } = await supabase
.from("brands")
.select("id, name")
.order("name");
.order("name") as unknown as { data: { id: string; name: string }[] | null };
const initialBrandId = isPlatformAdmin
? null
+2 -7
View File
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
@@ -26,13 +25,9 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
const [detailResult, ordersResult] = await Promise.all([
@@ -68,4 +63,4 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
</div>
</div>
);
}
}
+2 -7
View File
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { getRouteTraceLots } from "@/actions/route-trace/lots";
@@ -22,13 +21,9 @@ export default async function LotsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
@@ -62,4 +57,4 @@ export default async function LotsPage() {
lots={allLots}
/>
);
}
}
+2 -8
View File
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import {
@@ -41,14 +40,9 @@ export default async function RouteTraceDashboardPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
// Bypass feature check in dev mode (dev_session cookie present)
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
@@ -110,4 +104,4 @@ export default async function RouteTraceDashboardPage() {
</div>
</main>
);
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function BillingPage({ params }: Props) {
.limit(1)
.single();
if (firstBrand?.id) {
resolvedBrandId = firstBrand.id;
resolvedBrandId = String(firstBrand.id);
} else {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
+1 -1
View File
@@ -18,7 +18,7 @@ export default async function IntegrationsPage() {
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
: [];
return (
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function ShippingSettingsPage() {
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
: [];
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
@@ -65,7 +65,7 @@ export default function SquareSyncSettingsClient({ settings, logs, brandId }: Pr
setError(null);
const result = await savePaymentSettings({
brandId,
provider: (settings?.provider as any) || null,
provider: (settings?.provider ?? null) as "stripe" | "square" | "manual" | null,
squareSyncEnabled,
squareInventoryMode,
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { getPaymentSettings, type PaymentSettings } from "@/actions/payments";
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
@@ -26,7 +26,7 @@ export default async function SquareSyncSettingsPage() {
return (
<SquareSyncSettingsClient
settings={settings as any}
settings={settings as PaymentSettings}
logs={logs}
brandId={brandId}
/>
+46 -5
View File
@@ -13,6 +13,38 @@ type StopDetailPageProps = {
}>;
};
interface Stop {
id: string;
brand_id: string;
city: string;
state: string;
address: string | null;
zip: string | null;
date: string;
time: string;
cutoff_date: string | null;
cutoff_time: string | null;
status: string;
location: string;
slug: string;
active: boolean;
brands?: { name: string; slug: string };
}
interface Product {
id: string;
name: string;
type: string;
price: number;
image_url?: string | null;
}
interface ProductStop {
id: string;
product_id: string;
products?: Product;
}
export default async function StopDetailPage({ params }: StopDetailPageProps) {
const { id } = await params;
@@ -21,8 +53,8 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
.from("stops")
.select("*, brands(name, slug)")
.eq("id", id)
.single(),
supabase.from("brands").select("id, name, slug"),
.single() as unknown as { data: Stop | null; error: { message: string } | null },
supabase.from("brands").select("id, name, slug") as unknown as { data: { id: string; name: string; slug: string }[] | null },
]);
const adminUser = await getAdminUser();
@@ -61,15 +93,24 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
.from("products")
.select("id, name, type, price")
.eq("brand_id", stop.brand_id)
.eq("active", true),
.eq("active", true) as unknown as { data: Product[] | null },
supabase
.from("product_stops")
.select("id, product_id, products(id, name, type, price)")
.eq("stop_id", id),
.eq("stop_id", id) as unknown as { data: ProductStop[] | null },
]);
const assignedProducts = (productStops ?? [])
.map((ps: any) => ps)
.map((ps: ProductStop) => ({
id: ps.id,
product_id: ps.product_id,
products: ps.products ? {
name: ps.products.name,
type: ps.products.type,
price: ps.products.price,
image_url: ps.products.image_url,
} : null,
}))
.filter(Boolean);
return (
+2 -2
View File
@@ -36,7 +36,7 @@ export default async function NewStopPage({
.from("stops")
.select("city, state, location, date, time, brand_id, active, address, zip, cutoff_time")
.eq("id", duplicate)
.single();
.single() as unknown as { data: Stop | null };
duplicateFrom = data;
}
@@ -48,7 +48,7 @@ export default async function NewStopPage({
.from("products")
.select("id, name, type, price")
.eq("brand_id", brandId)
.eq("active", true),
.eq("active", true) as unknown as { data: { id: string; name: string; type: string; price: number }[] | null },
]);
return (
+18 -1
View File
@@ -6,6 +6,23 @@ import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
interface Stop {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
deleted_at: string | null;
brand_id: string;
address: string | null;
zip: string | null;
cutoff_time: string | null;
status: string;
brands: { name: string } | { name: string }[];
}
const StopIcon = () => (
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
@@ -49,7 +66,7 @@ export default async function AdminStopsPage() {
query = query.eq("brand_id", adminUser.brand_id);
}
const { data: stops, error } = await query;
const { data: stops, error } = await query as unknown as { data: Stop[] | null; error: { message: string } | null };
if (error) {
return (
+2 -2
View File
@@ -29,9 +29,9 @@ export default async function TaxesPage({ params }: Props) {
.from("brands")
.select("id")
.limit(1)
.single();
.single() as unknown as { data: { id: string } | null };
if (firstBrand?.id) {
resolvedBrandId = firstBrand.id;
resolvedBrandId = String(firstBrand.id);
} else {
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
+3 -11
View File
@@ -1,6 +1,5 @@
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
import { getAdminUser } from "@/lib/admin-permissions";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/admin/design-system";
@@ -11,16 +10,9 @@ export const dynamic = "force-dynamic";
export default async function AdminTimeTrackingPage() {
const adminUser = await getAdminUser();
// Dev session bypass for platform admin
if (!adminUser) {
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
if (devSession === "platform_admin" || devSession === "brand_admin") {
// Allow access in dev mode
} else {
redirect("/login");
}
redirect("/login");
}
const isPlatformAdmin = adminUser?.role === "platform_admin";
@@ -45,4 +37,4 @@ export default async function AdminTimeTrackingPage() {
</div>
</div>
);
}
}
+10 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings";
import { useRouter } from "next/navigation";
@@ -24,11 +24,7 @@ export default function WaterLogSettingsPage() {
const [alertPhone, setAlertPhone] = useState("");
const [alertsEnabled, setAlertsEnabled] = useState(false);
useEffect(() => {
loadSettings();
}, []);
async function loadSettings() {
const loadSettings = useCallback(async () => {
setLoading(true);
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
if (data) {
@@ -42,7 +38,14 @@ export default function WaterLogSettingsPage() {
setAlertsEnabled(data.alerts_enabled ?? false);
}
setLoading(false);
}
}, []);
useEffect(() => {
const init = async () => {
await loadSettings();
};
init();
}, [loadSettings]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
+23 -15
View File
@@ -41,6 +41,15 @@ import { PageHeader, AdminButton, AdminSearchInput, AdminFilterTabs, AdminBadge
type Tab = "dashboard" | "customers" | "products" | "orders" | "settings";
// SVG Icon for Wholesale - defined at module level to prevent recreation on each render
function WholesaleIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
);
}
export default function WholesaleClient({ brandId }: { brandId: string }) {
const [tab, setTab] = useState<Tab>("dashboard");
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
@@ -101,13 +110,6 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
);
}
// SVG Icon for Wholesale
const WholesaleIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
);
const tabs = [
{ value: "dashboard", label: "Dashboard", count: undefined },
{ value: "customers", label: "Customers", count: customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length },
@@ -116,10 +118,13 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
{ value: "settings", label: "Settings" },
];
// Declare icon reference for PageHeader
const pageIcon = <WholesaleIcon />;
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<PageHeader
icon={<WholesaleIcon />}
icon={pageIcon}
title="Wholesale Portal"
subtitle="Manage wholesale orders, customers, and products"
className="mb-0"
@@ -1843,13 +1848,7 @@ function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }:
onRefresh: () => void;
canManageSettings: boolean;
}) {
if (!canManageSettings) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-slate-400">You do not have permission to manage settings.</p>
</div>
);
}
// Hooks must be called unconditionally - always declare them first
const [form, setForm] = useState({
requireApproval: settings?.require_approval ?? true,
minOrderAmount: settings?.min_order_amount ?? "",
@@ -1868,6 +1867,15 @@ function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }:
});
const [saving, setSaving] = useState(false);
// Permission check after hooks
if (!canManageSettings) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-slate-400">You do not have permission to manage settings.</p>
</div>
);
}
async function handleSave() {
setSaving(true);
const result = await saveWholesaleSettings({
+5 -18
View File
@@ -1,25 +1,12 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import WholesaleClient from "./WholesaleClient";
export default async function WholesalePage() {
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode =
devSession === "platform_admin" ||
devSession === "brand_admin" ||
devSession === "store_employee";
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
if (!isDevMode) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const activeBrandId = await getActiveBrandId(adminUser);
return <WholesaleClient brandId={activeBrandId ?? ""} />;
}
// Dev mode: platform_admin sees all brands, use first brand as default
const brandId = devSession === "platform_admin" ? "" : (cookieStore.get("dev_brand_id")?.value ?? "64294306-5f42-463d-a5e8-2ad6c81a96de");
return <WholesaleClient brandId={brandId} />;
}
const activeBrandId = await getActiveBrandId(adminUser);
return <WholesaleClient brandId={activeBrandId ?? ""} />;
}
+1 -1
View File
@@ -61,7 +61,7 @@ Use "trending" for product popularity questions.
Use "top_customers" for customer ranking questions.
Use "recent_orders" for recent order questions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -58,7 +58,7 @@ Historical Order Data: ${JSON.stringify(historicalData ?? [])}
Analyze demand patterns and return JSON with currentTrend, prediction (nextStopVolume, nextWeekVolume, confidence, confidenceReason), recommendedStock (units, reasoning), seasonalFactors array, and riskFlags array.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -58,7 +58,7 @@ Historical Sales (last 30-90 days): ${JSON.stringify(historicalSales ?? [])}
Analyze pricing and return JSON with currentState, recommendations array, opportunities, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -63,7 +63,7 @@ ${JSON.stringify(sampleRows, null, 2)}
Return JSON with summary, keyInsights (array), and suggestedActions (array).`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -62,7 +62,7 @@ ${stopsList}
Optimize the route for efficiency. Return JSON with optimizedSequence, totalEstimatedDistance, totalEstimatedDriveTime, warnings, and suggestions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -64,7 +64,7 @@ Customer Count: ${customerCount ?? "unknown"}
Analyze this stop's context and order history to recommend the optimal stop blast message.
Return JSON with timingRecommendation, subjectLine, bodyPreview, audienceSize, audienceRecommendation, contentAngles array, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+13 -4
View File
@@ -1,4 +1,13 @@
// Auth.js v5 route handler — re-exports the GET/POST handlers from src/lib/auth.ts
// Mounted at /api/auth/* (signin, signout, callback, session, csrf, providers, etc.)
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
// Neon Auth API route handles all auth endpoints via Better Auth server proxy.
// Mounted at /api/auth/* (sign-in, sign-out, session, callbacks, etc.)
import { NextRequest } from "next/server";
import { handlersGET, handlersPOST } from "@/lib/auth";
// Re-export as GET and POST for Next.js route handler
export const GET = async (req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) => {
return handlersGET(req as unknown as Request, ctx as unknown as { params: Promise<{ path: string[] }> });
};
export const POST = async (req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) => {
return handlersPOST(req as unknown as Request, ctx as unknown as { params: Promise<{ path: string[] }> });
};
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { getSession, setUserPassword } from "@/lib/auth";
interface ChangePasswordBody {
password: string;
userId?: string;
}
export async function POST(req: NextRequest) {
let body: ChangePasswordBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { password, userId } = body;
if (!password || password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters." },
{ status: 400 }
);
}
// Get the current session
const { data: session } = await getSession();
const sessionUserId = session?.user?.id;
if (!sessionUserId) {
return NextResponse.json(
{ error: "Not authenticated." },
{ status: 401 }
);
}
// If userId is provided, require admin privileges (handled by updatePasswordAction)
// If no userId, the user is changing their own password
const targetUserId = userId ?? sessionUserId;
// Non-admin users can only change their own password
if (userId && userId !== sessionUserId) {
// For now, only admins can change other users' passwords
// This is handled by the updatePasswordAction in admin/password.ts
return NextResponse.json(
{ error: "Use the admin panel to change other users' passwords." },
{ status: 403 }
);
}
try {
const result = await setUserPassword({
userId: targetUserId,
newPassword: password,
});
if (result.error) {
console.error("[auth/change-password] Failed:", result.error);
return NextResponse.json(
{
error: result.error.code ?? "ChangeFailed",
message: result.error.message ?? "Failed to change password.",
},
{ status: 400 }
);
}
console.log("[auth/change-password] Password changed for user:", targetUserId);
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/change-password] Unexpected error:", err);
return NextResponse.json(
{ error: "InternalError", message: "An unexpected error occurred." },
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { requestPasswordReset } from "@/lib/auth";
interface ForgotPasswordBody {
email: string;
}
export async function POST(req: NextRequest) {
let body: ForgotPasswordBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { email } = body;
const trimmedEmail = typeof email === "string" ? email.trim().toLowerCase() : "";
if (!trimmedEmail) {
return NextResponse.json(
{ error: "Email is required." },
{ status: 400 }
);
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
try {
const result = await requestPasswordReset({
email: trimmedEmail,
redirectTo: `${siteUrl}/reset-password`,
});
if (result.error) {
console.error("[auth/forgot-password] Request failed:", result.error);
// Don't reveal whether the email exists or not for security
return NextResponse.json({ success: true });
}
console.log("[auth/forgot-password] Reset email sent to:", trimmedEmail);
// Always return success to prevent email enumeration
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/forgot-password] Unexpected error:", err);
// Don't reveal error details to the client
return NextResponse.json({ success: true });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { resetPassword } from "@/lib/auth";
interface ResetPasswordBody {
password: string;
}
export async function POST(req: NextRequest) {
let body: ResetPasswordBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { password } = body;
if (!password || password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters." },
{ status: 400 }
);
}
try {
const result = await resetPassword({
newPassword: password,
});
if (result.error) {
console.error("[auth/reset-password] Reset failed:", result.error);
return NextResponse.json(
{
error: result.error.code ?? "ResetFailed",
message: result.error.message ?? "Failed to reset password. The link may have expired.",
},
{ status: 400 }
);
}
console.log("[auth/reset-password] Password reset successful");
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/reset-password] Unexpected error:", err);
return NextResponse.json(
{ error: "InternalError", message: "An unexpected error occurred." },
{ status: 500 }
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { signIn } from "@/lib/auth";
interface SignInBody {
email: string;
password: string;
}
export async function POST(req: NextRequest) {
let body: SignInBody;
try {
body = await req.json();
} catch (err) {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const email = body?.email;
const password = body?.password;
if (!email || !password) {
return NextResponse.json(
{ error: "MissingCredentials", message: "Email and password are required." },
{ status: 400 }
);
}
try {
const result = await signIn.email({
email,
password,
});
console.log("[sign-in] signIn.email result:", JSON.stringify(result));
if (result.error) {
console.log("[sign-in] sign-in error:", result.error);
return NextResponse.json(
{ error: result.error.code ?? "SignInFailed", message: result.error.message ?? "Invalid email or password." },
{ status: 401 }
);
}
console.log("[sign-in] sign-in success, session should be set");
return NextResponse.json({ success: true });
} catch (err) {
console.error("[sign-in] Unexpected error:", err);
return NextResponse.json(
{ error: "ServiceError", message: "An unexpected error occurred." },
{ status: 500 }
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { signOut } from "@/lib/auth";
export async function POST() {
try {
await signOut();
// This line won't be reached because signOut() redirects
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/sign-out] Error:", err);
// Even if signOut throws, redirect to login
return NextResponse.redirect(new URL("/login", process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000"));
}
}
+89 -28
View File
@@ -1,35 +1,96 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
import { sendCampaignEmail } from "@/lib/email-service";
export async function GET() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
return NextResponse.json({ error: "Missing configuration" }, { status: 500 });
/**
* Cron: /api/cron/send-scheduled
* Runs daily at 09:00 (declared in vercel.json).
* Finds all campaigns with status='scheduled' and scheduled_at <= now(),
* sends each one to all opted-in contacts for that brand, then marks
* the campaign as 'sent'.
*
* Auth: Bearer token via CRON_SECRET header.
*/
export async function GET(request: Request) {
const authHeader = request.headers.get("authorization") ?? "";
const expected = `Bearer ${process.env.CRON_SECRET ?? ""}`;
if (!expected || authHeader !== expected) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const response = await fetch(
`${supabaseUrl}/functions/v1/send-scheduled-campaigns`,
{ headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" } }
);
const text = await response.text();
if (!response.ok) {
return NextResponse.json({ error: "Failed to send campaigns", detail: text }, { status: 500 });
}
let results: unknown[] = [];
try {
const data = JSON.parse(text);
if (data && typeof data === "object") {
const d = data as Record<string, unknown>;
if (Array.isArray(d.results)) results = d.results as unknown[];
else if (Array.isArray(d.send_scheduled_campaigns)) results = d.send_scheduled_campaigns as unknown[];
else if (Array.isArray(data)) results = data as unknown[];
}
} catch { /* ignore */ }
// 1. Find campaigns due to be sent
const { rows: campaigns } = await pool.query<{
id: string;
brand_id: string;
name: string;
subject: string | null;
body_html: string | null;
brand_name: string | null;
}>(
`SELECT id, brand_id, name, subject, body_html, brand_name
FROM communication_campaigns
WHERE status = 'scheduled'
AND scheduled_at IS NOT NULL
AND scheduled_at <= now()
ORDER BY scheduled_at ASC`
);
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
if (campaigns.length === 0) {
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results: [] });
}
const results: Array<{ campaignId: string; name: string; sent: number; errors: number }> = [];
for (const campaign of campaigns) {
if (!campaign.body_html) {
await pool.query(
`UPDATE communication_campaigns SET status = 'sent', sent_at = now() WHERE id = $1`,
[campaign.id]
);
results.push({ campaignId: campaign.id, name: campaign.name, sent: 0, errors: 0 });
continue;
}
// 2. Find opted-in contacts for this brand
const { rows: contacts } = await pool.query<{ id: string; email: string | null; full_name: string | null }>(
`SELECT id, email, full_name
FROM communication_contacts
WHERE brand_id = $1
AND email_opt_in = true
AND email IS NOT NULL
AND unsubscribed_at IS NULL`,
[campaign.brand_id]
);
let sent = 0;
let errors = 0;
for (const contact of contacts) {
if (!contact.email) continue;
const ok = await sendCampaignEmail({
to: contact.email,
subject: campaign.subject ?? campaign.name,
html: campaign.body_html,
});
if (ok) sent++;
else errors++;
}
// 3. Mark campaign as sent
await pool.query(
`UPDATE communication_campaigns
SET status = 'sent', sent_at = now(), recipient_count = $2
WHERE id = $1`,
[campaign.id, sent]
);
results.push({ campaignId: campaign.id, name: campaign.name, sent, errors });
}
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
} catch (err) {
console.error("[cron/send-scheduled] Error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
+29 -28
View File
@@ -1,36 +1,37 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { requestPasswordReset } from "@/lib/auth";
export async function POST(request: Request) {
const formData = await request.formData();
const email = formData.get("email") as string;
export async function POST(req: NextRequest) {
let body: { email?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email) {
return NextResponse.json({ error: "Email is required." }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
try {
const result = await requestPasswordReset({
email,
redirectTo: `${siteUrl}/reset-password`,
});
if (result.error) {
console.error("[auth/forgot-password] Neon Auth error:", result.error);
}
// Always return success to prevent email enumeration
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/forgot-password] Unexpected error:", err);
return NextResponse.json({ success: true });
}
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
const res = await fetch(`${supabaseUrl}/auth/v1/recover`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAnonKey,
},
body: JSON.stringify({ email, redirectTo: `${origin}/auth/callback` }),
});
// Always return 200 to avoid email enumeration — Supabase may still send or not
if (!res.ok) {
const data = await res.json().catch(() => null);
return NextResponse.json({ error: data?.message ?? "Failed to send reset link." }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
@@ -9,7 +9,7 @@ export async function GET() {
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single();
.single() as unknown as { data: { id: string; name: string } | null };
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
@@ -20,13 +20,13 @@ export async function GET() {
.select("*")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true });
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single();
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
+5 -5
View File
@@ -41,9 +41,9 @@ export async function GET(req: NextRequest) {
db
.select({
id: orders.id,
tenantId: orders.tenantId,
brandId: orders.brandId,
customerId: orders.customerId,
customerName: customers.name,
customerName: customers.fullName,
customerEmail: customers.email,
status: orders.status,
totalCents: orders.totalCents,
@@ -51,7 +51,7 @@ export async function GET(req: NextRequest) {
})
.from(orders)
.leftJoin(customers, eq(customers.id, orders.customerId))
.where(eq(orders.tenantId, brandId))
.where(eq(orders.brandId, brandId))
.orderBy(desc(orders.placedAt))
.limit(1000),
)
@@ -59,9 +59,9 @@ export async function GET(req: NextRequest) {
db
.select({
id: orders.id,
tenantId: orders.tenantId,
brandId: orders.brandId,
customerId: orders.customerId,
customerName: customers.name,
customerName: customers.fullName,
customerEmail: customers.email,
status: orders.status,
totalCents: orders.totalCents,
+2 -2
View File
@@ -74,8 +74,8 @@ export async function GET(req: NextRequest) {
// Collect all logs across brands
let allLogs: LogEntry[] = [];
let allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
let allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
const allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
const allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
for (const brandId of brandIds) {
const [workers, settings, logs] = await Promise.all([
+4 -4
View File
@@ -17,7 +17,7 @@ export async function GET() {
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single();
.single() as unknown as { data: { id: string; name: string } | null };
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
@@ -25,16 +25,16 @@ export async function GET() {
const { data: stops } = await supabase
.from("stops")
.select("*")
.select("city, state, date, time, location")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true });
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single();
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
+13
View File
@@ -5,6 +5,7 @@ import { emailLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from
import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -36,6 +37,12 @@ const createCampaignSchema = z.object({
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(emailLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -83,6 +90,12 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const brand_id = searchParams.get("brand_id");
+17 -4
View File
@@ -7,6 +7,7 @@ import { captureError } from "@/lib/sentry";
import { withDb, withPlatformAdmin } from "@/db/client";
import { products, type Product } from "@/db/schema";
import { and, eq, ilike, or, sql } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -36,6 +37,12 @@ const getProductsSchema = z.object({
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -60,10 +67,10 @@ export async function GET(req: NextRequest) {
// Build the WHERE conditions. We use `withDb` (no tenant GUC) here
// because this is a public API — RLS isn't enforced via the new
// schema's app.current_tenant_id GUC, so we filter explicitly.
// schema's app.current_brand_id GUC, so we filter explicitly.
const whereParts = [];
if (validation.data.brand_id) {
whereParts.push(eq(products.tenantId, validation.data.brand_id));
whereParts.push(eq(products.brandId, validation.data.brand_id));
}
if (validation.data.is_active !== undefined) {
whereParts.push(eq(products.active, validation.data.is_active));
@@ -82,7 +89,7 @@ export async function GET(req: NextRequest) {
void validation.data.category;
// Public read across all tenants (this is a public catalog API, not
// an admin endpoint), so use `withDb` rather than `withTenant`.
// an admin endpoint), so use `withDb` rather than `withBrand`.
const rows = await withDb(async (db) =>
db
.select()
@@ -107,6 +114,12 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -139,7 +152,7 @@ export async function POST(req: NextRequest) {
const [row] = await db
.insert(products)
.values({
tenantId: brand_id,
brandId: brand_id,
name,
description: description ?? null,
priceCents: Math.round(price * 100),
+13
View File
@@ -5,6 +5,7 @@ import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "
import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -31,6 +32,12 @@ const createReferralSchema = z.object({
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -69,6 +76,12 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const brand_id = searchParams.get("brand_id");
+7
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -31,6 +32,12 @@ const reportFiltersSchema = z.object({
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
+13
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -35,6 +36,12 @@ const createWaterLogSchema = z.object({
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -77,6 +84,12 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const brand_id = searchParams.get("brand_id");
+13 -28
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers";
import { uploadObject, BUCKETS } from "@/lib/storage";
export async function POST(request: Request) {
try {
@@ -15,8 +15,8 @@ export async function POST(request: Request) {
const file = formData.get("file") as File | null;
const bucket = formData.get("bucket") as string | null;
if (!file || !bucket) {
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
if (!file) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
if (file.size > 5 * 1024 * 1024) {
@@ -27,34 +27,19 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabasePat = process.env.SUPABASE_PAT!;
const ext = file.type === "image/jpeg" ? "jpg" : "png";
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const key = `water-logs/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const uint8 = new Uint8Array(arrayBuffer);
// bucket param is accepted for backwards compat but ignored — always use WATER_LOGS
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadObject({
bucket: BUCKETS.WATER_LOGS,
key,
body: buffer,
contentType: file.type,
});
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
{
method: "POST",
headers: {
...svcHeaders(supabasePat),
"Content-Type": file.type,
"x-upsert": "true",
},
body: uint8,
}
);
if (!uploadRes.ok) {
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
return NextResponse.json({ url: publicUrl });
return NextResponse.json({ url });
} catch (err) {
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
@@ -2,6 +2,59 @@ import { NextResponse } from "next/server";
import crypto from "crypto";
import { pool } from "@/lib/db";
/**
* Validates a webhook URL to prevent SSRF attacks.
* Blocks:
* - Non-HTTPS URLs
* - localhost and 127.0.0.1
* - Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
* - Link-local IPv6 addresses (fc00::/7, fe80::/10)
* - AWS metadata endpoint (169.254.169.254)
*/
function validateWebhookUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Only allow HTTPS URLs
if (parsed.protocol !== "https:") {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Block localhost and loopback
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return false;
}
// Block private IP ranges using regex patterns
const blockedPatterns = [
// 10.0.0.0/8 - Class A private network
/^10\./,
// 172.16.0.0/12 - Class B private network (172.16.x.x - 172.31.x.x)
/^172\.(1[6-9]|2[0-9]|3[01])\./,
// 192.168.0.0/16 - Class C private network
/^192\.168\./,
// AWS metadata endpoint
/^169\.254\.169\.254$/,
// Link-local IPv6 (fc00::/7 and fe80::/10)
/^fc00:/i,
/^fe80:/i,
];
for (const pattern of blockedPatterns) {
if (pattern.test(hostname)) {
return false;
}
}
return true;
} catch {
// Invalid URL format
return false;
}
}
// POST /api/wholesale/webhooks/dispatch
// Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs.
// Called by a cron job or manually after enqueue_wholesale_webhook has queued events.
@@ -33,6 +86,19 @@ export async function POST() {
let dispatched = 0;
for (const webhook of pending) {
// Validate URL to prevent SSRF attacks
if (!validateWebhookUrl(webhook.url)) {
console.error("[SSRF_BLOCKED]", {
webhookId: webhook.id,
brandId: webhook.brand_id,
url: webhook.url,
eventType: webhook.event_type,
timestamp: new Date().toISOString(),
});
await markFailed(webhook.id, "SSRF attempt blocked: invalid webhook URL");
continue;
}
const payload = webhook.payload ?? {};
const payloadString = JSON.stringify(payload);
+30 -16
View File
@@ -2,8 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updatePasswordAction } from "@/actions/admin/password";
import { logUserActivity } from "@/actions/admin/audit";
import Link from "next/link";
export default function ChangePasswordForm({ userId }: { userId: string }) {
const router = useRouter();
@@ -26,22 +25,28 @@ export default function ChangePasswordForm({ userId }: { userId: string }) {
}
setLoading(true);
const result = await updatePasswordAction(password);
setLoading(false);
if (result.error) {
setError(result.error);
return;
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, password }),
});
const data = await res.json();
if (!res.ok || data.error) {
setError(data.message ?? data.error ?? "Failed to update password.");
setLoading(false);
return;
}
router.push("/admin");
router.refresh();
} catch {
setError("An unexpected error occurred. Please try again.");
setLoading(false);
}
logUserActivity({
user_id: userId,
activity_type: "password_change",
details: {},
}).catch(() => {});
router.push("/admin");
router.refresh();
}
return (
@@ -113,6 +118,15 @@ export default function ChangePasswordForm({ userId }: { userId: string }) {
{loading ? "Updating..." : "Update Password"}
</button>
</form>
<div className="mt-4 border-t border-zinc-800 pt-4">
<Link
href="/logout"
className="block text-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
Sign out instead
</Link>
</div>
</div>
</div>
</main>
+21 -17
View File
@@ -3,8 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { updatePasswordAction } from "@/actions/admin/password";
import { logUserActivity } from "@/actions/admin/audit";
import { getCurrentUserId } from "@/actions/admin/password";
export default function ChangePasswordPage() {
const router = useRouter();
@@ -27,23 +26,28 @@ export default function ChangePasswordPage() {
}
setLoading(true);
const result = await updatePasswordAction(password);
setLoading(false);
if (result.error) {
setError(result.error);
return;
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok || data.error) {
setError(data.message ?? data.error ?? "Failed to update password.");
setLoading(false);
return;
}
router.push("/admin");
router.refresh();
} catch {
setError("An unexpected error occurred. Please try again.");
setLoading(false);
}
// Audit log (best-effort — the password change itself was the source of truth)
logUserActivity({
user_id: result.userId ?? "unknown",
activity_type: "password_change",
details: {},
}).catch(() => {});
router.push("/admin");
router.refresh();
}
return (
+13 -6
View File
@@ -37,10 +37,17 @@ export default function CheckoutClient() {
const [hostedError, setHostedError] = useState<string | null>(null);
// Stable idempotency key per checkout session — survives retries
const idempotencyKeyRef = useRef<string | null>(null);
if (typeof window !== "undefined" && !idempotencyKeyRef.current) {
idempotencyKeyRef.current = crypto.randomUUID();
}
const [idempotencyKey, setIdempotencyKey] = useState<string>("");
useEffect(() => {
// Initialize idempotency key in useEffect, not during render
const init = async () => {
if (typeof window !== "undefined" && !idempotencyKey) {
setIdempotencyKey(crypto.randomUUID());
}
};
init();
}, [idempotencyKey]);
useEffect(() => {
if (!cartBrandId) return;
@@ -112,7 +119,7 @@ export default function CheckoutClient() {
shippingAddress: hasShipItems
? { state: shippingState, postal_code: shippingPostal, city: shippingCity }
: undefined,
idempotencyKey: idempotencyKeyRef.current,
idempotencyKey: idempotencyKey,
})
);
}
@@ -408,7 +415,7 @@ export default function CheckoutClient() {
shippingState={shippingState}
shippingPostal={shippingPostal}
shippingCity={shippingCity}
checkoutSessionKey={idempotencyKeyRef.current ?? undefined}
checkoutSessionKey={idempotencyKey || undefined}
onHostedCheckout={() => void handleHostedCheckout()}
/>
</div>
+9 -8
View File
@@ -74,14 +74,14 @@ function SuccessContent() {
// embedded Stripe Elements (?payment_intent=...). Both paths store the
// same payload in `pending_checkout` before payment is initiated.
useEffect(() => {
if (!sessionId && !paymentIntentId) return;
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
setError("Payment was not completed. Please try again.");
setCreating(false);
return;
}
const init = async () => {
if (!sessionId && !paymentIntentId) return;
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
setError("Payment was not completed. Please try again.");
setCreating(false);
return;
}
(async () => {
const pendingStr = sessionStorage.getItem("pending_checkout");
if (!pendingStr) {
setError("No pending order found. Please start checkout again.");
@@ -133,7 +133,8 @@ function SuccessContent() {
setError("Failed to create order. Please contact support.");
setCreating(false);
}
})();
};
init();
}, [sessionId, paymentIntentId, redirectStatus]);
if (!order || !orderIdParam) {
@@ -38,10 +38,10 @@ export default function IndianRiverContactPage() {
if (!brand?.id) return;
supabase.from("wholesale_settings")
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
.eq("brand_id", brand.id).single().then(({ data }) => setBrandSettings(data ?? null));
.eq("brand_id", brand.id).single().then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
supabase.from("brand_settings").select("logo_url, custom_footer_text, email, phone")
.eq("brand_id", brand.id).single().then(({ data: s }) => {
if (s) { setLogoUrl(s.logo_url ?? null); setCustomFooterText(s.custom_footer_text ?? null); setContactEmail(s.email ?? null); setContactPhone(s.phone ?? null); }
if (s) { setLogoUrl((s as { logo_url: string | null }).logo_url ?? null); setCustomFooterText((s as { custom_footer_text: string | null }).custom_footer_text ?? null); setContactEmail((s as { email: string | null }).email ?? null); setContactPhone((s as { phone: string | null }).phone ?? null); }
});
try { import("@/actions/admin-user").then(({ getCurrentAdminUser }) => { getCurrentAdminUser().then((u: unknown) => setIsAdmin(!!u)); }); } catch { /* not logged in */ }
});
@@ -139,7 +139,7 @@ export default function IndianRiverFAQPage() {
supabase.from("brand_settings").select("phone").eq("brand_id",
"b1cb7a96-d82b-40b1-80b1-d6dd26c56e28"
).single().then(({ data }) => {
if (data?.phone) setFaqCategories(buildFaqCategories(data.phone));
if (data && (data as { phone: string | null }).phone) setFaqCategories(buildFaqCategories((data as { phone: string | null }).phone!));
});
}, []);
+3 -3
View File
@@ -90,7 +90,7 @@ export default function IndianRiverDirectPage() {
getBrandSettingsPublic(slug),
]);
const brandData = brandResult.data;
const brandData = brandResult.data as Brand | null;
setBrand(brandData);
if (settingsResult.success && settingsResult.settings) {
@@ -111,8 +111,8 @@ export default function IndianRiverDirectPage() {
if (brandData?.id) {
const [{ data: stopsData }, { data: productsData }] = await Promise.all([
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true),
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true),
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
]);
setStops(stopsData ?? []);
setProducts(productsData ?? []);
@@ -24,6 +24,16 @@ type Product = {
pickup_type?: "scheduled_stop" | "shed";
};
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
export default function StopPage() {
const params = useParams();
const slug = params.slug as string;
@@ -48,7 +58,7 @@ export default function StopPage() {
.select("*")
.eq("slug", slug)
.eq("active", true)
.single();
.single() as unknown as { data: Stop | null };
if (!stopData) return;
@@ -61,7 +71,7 @@ export default function StopPage() {
.from("products")
.select("*")
.eq("brand_id", stopData.brand_id)
.eq("active", true);
.eq("active", true) as unknown as { data: Product[] | null };
setProducts(productsData ?? []);
}
load();
+7 -2
View File
@@ -1,4 +1,5 @@
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
const BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
const BRAND_NAME = "Indian River Direct";
@@ -8,12 +9,16 @@ export const metadata = {
title: "Worker Clock — Indian River Direct",
};
export default function IRDTimeClockPage() {
export default async function IRDTimeClockPage() {
const settings = await getBrandSettingsPublic("indian-river-direct");
const logoUrl = settings.success ? (settings.settings?.logo_url ?? null) : null;
return (
<TimeTrackingFieldClient
brandId={BRAND_ID}
brandName={BRAND_NAME}
brandAccent={BRAND_ACCENT}
logoUrl={logoUrl}
/>
);
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
<html lang="en" suppressHydrationWarning data-scroll-behavior="smooth" className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
<body className="font-sans">
<Providers>{children}</Providers>
<ToastNotificationContainer />
+142 -141
View File
@@ -1,153 +1,59 @@
"use client";
import { useState, useTransition } from "react";
import { signInWithGoogle, signInWithCredentials } from "@/actions/auth-actions";
import { useState } from "react";
type LoginClientProps = {
hasGoogle: boolean;
hasCredentials: boolean;
/** Server-rendered error message, if any (from ?error=...) */
error: string | null;
/** Pre-fill the email in dev (for the seeded admin). */
seededEmail?: string;
/** Where to send the user after a successful sign-in. */
redirectTo?: string;
};
function GoogleSignIn({ hasGoogle }: { hasGoogle: boolean }) {
if (!hasGoogle) {
return (
<div className="rounded-xl border border-stone-200/80 bg-stone-50 p-4 text-sm text-stone-700">
<p className="font-medium">Google sign-in is not configured.</p>
<p className="mt-1 text-stone-600">
Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
<code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
environment to enable it.
</p>
</div>
);
}
const REDIRECT_URL = "/admin";
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>
);
}
// Development mode detection
const isDev = process.env.NODE_ENV !== "production";
function CredentialsForm({
seededEmail,
error,
}: {
seededEmail?: string;
error: string | null;
}) {
const [isPending, startTransition] = useTransition();
export default function LoginClient({ error }: LoginClientProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
return (
<form
action={(formData) => {
setLocalError(null);
startTransition(async () => {
try {
await signInWithCredentials(formData);
} catch (err) {
// Auth.js throws NEXT_REDIRECT on success — that's the normal
// flow. We only care about non-redirect errors here.
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes("NEXT_REDIRECT")) {
setLocalError("Sign-in failed. Please try again.");
}
}
});
}}
className="space-y-3"
>
<label className="block">
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Email
</span>
<input
name="email"
type="email"
required
autoComplete="username"
defaultValue={seededEmail ?? ""}
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
placeholder="you@example.com"
/>
</label>
<label className="block">
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Password
</span>
<input
name="password"
type="password"
required
autoComplete="current-password"
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
placeholder="••••••••"
/>
</label>
{(error || localError) && (
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
{error ?? localError}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
background: isPending
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
}}
>
{isPending ? "Signing in…" : "Sign in with email"}
</button>
</form>
);
}
async function handleSignIn() {
if (!email || !password) {
setLocalError("Email and password are required.");
return;
}
setLocalError(null);
setLoading(true);
function Divider() {
return (
<div className="flex items-center gap-3 my-5" aria-hidden="true">
<div className="flex-1 h-px bg-stone-200/80" />
<span className="text-xs uppercase tracking-wider text-stone-400" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
or
</span>
<div className="flex-1 h-px bg-stone-200/80" />
</div>
);
}
try {
const res = await fetch("/api/auth/sign-in", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim().toLowerCase(), password: password.trim() }),
});
const data = await res.json();
if (!res.ok || data.error) {
setLocalError(data.message ?? "Sign-in failed. Please check your email and password.");
setLoading(false);
return;
}
// Cookie is set server-side via next/headers — just redirect on success.
window.location.href = REDIRECT_URL;
} catch {
setLocalError("Sign-in failed. Please try again.");
setLoading(false);
}
}
function handleDevLogin(role: string) {
// Set the dev_session cookie for local development bypass
document.cookie = `dev_session=${role}; path=/; max-age=86400`;
window.location.href = REDIRECT_URL;
}
export default function LoginClient({
hasGoogle,
hasCredentials,
error,
seededEmail,
}: LoginClientProps) {
// Render the Google button first (or a setup message), then the divider,
// then the credentials form if dev login is enabled. The order is the
// most-common-first progression: prod users see Google; dev users
// see Google at top, email/password below as the fast path.
return (
<main
className="min-h-screen flex flex-col relative overflow-hidden"
@@ -196,13 +102,108 @@ export default function LoginClient({
</p>
</div>
<GoogleSignIn hasGoogle={hasGoogle} />
{hasCredentials && hasGoogle && <Divider />}
{hasCredentials && <CredentialsForm seededEmail={seededEmail} error={error} />}
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Email
</label>
<input
type="email"
required
autoComplete="username"
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Password
</label>
<input
type="password"
required
autoComplete="current-password"
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
/>
</div>
{(error || localError) && (
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
{error ?? localError}
</p>
)}
<button
type="button"
onClick={handleSignIn}
disabled={loading}
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
background: loading
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
}}
>
{loading ? "Signing in…" : "Sign in with email"}
</button>
</div>
{/* Development bypass buttons */}
{isDev && (
<div className="mt-6 pt-6 border-t border-stone-200">
<p className="text-xs text-stone-500 text-center mb-3" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Dev Mode Quick Access
</p>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() => handleDevLogin("platform_admin")}
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
background: "#1a4d2e",
}}
>
Platform Admin
</button>
<button
type="button"
onClick={() => handleDevLogin("brand_admin")}
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
background: "#2d6a45",
}}
>
Brand Admin
</button>
<button
type="button"
onClick={() => handleDevLogin("store_employee")}
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
background: "#6b8f71",
}}
>
Store Employee
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</main>
);
}
}
+8 -27
View File
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import LoginClient from "./LoginClient";
import { isDevLoginEnabled } from "@/auth.config";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
@@ -29,37 +28,19 @@ export default async function LoginPage({
}: {
searchParams: Promise<SearchParams>;
}) {
// The Google provider is only added to the Auth.js config when these
// two env vars are set. Pass the flag down so the client can hide the
// button (and surface a helpful message) when Google is unavailable.
//
// We also require the client ID to look like a real Google OAuth client
// ID (ends in `.apps.googleusercontent.com`). This guards against
// dev/CI environments where the env vars are set to placeholder strings
// like "dummy-google-client-id" — those would otherwise surface a
// Google button that immediately 401s on Google's end.
const googleId = process.env.AUTH_GOOGLE_ID ?? "";
const googleSecret = process.env.AUTH_GOOGLE_SECRET ?? "";
const hasGoogle = !!(
googleId &&
googleSecret &&
googleId.endsWith(".apps.googleusercontent.com")
);
const hasCredentials = isDevLoginEnabled();
const params = await searchParams;
const error =
params?.error === "CredentialsSignin" || params?.error === "MissingCredentials"
? "Invalid email or password."
: params?.error
? "Sign-in failed. Please try again."
: null;
params?.error === "SignInFailed"
? "Sign-in failed. Please check your email and password."
: params?.error === "MissingCredentials"
? "Email and password are required."
: params?.error
? "Sign-in failed. Please try again."
: null;
return (
<LoginClient
hasGoogle={hasGoogle}
hasCredentials={hasCredentials}
error={error}
seededEmail={hasCredentials ? "admin@route-commerce.local" : undefined}
redirectTo={params?.redirect ?? "/admin"}
/>
);
}
+29 -8
View File
@@ -1,9 +1,30 @@
// Server-side logout. Signs the user out of the Auth.js v5 session and
// redirects to /login. The previous client-side implementation (which
// called Supabase auth.signOut) was replaced with this so logout goes
// through the same auth path the rest of the app uses.
import { signOut } from "@/lib/auth";
"use client";
export default async function LogoutPage() {
await signOut({ redirectTo: "/login" });
}
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function LogoutPage() {
const router = useRouter();
useEffect(() => {
// Sign out by calling the sign-out API endpoint
fetch("/api/auth/sign-out", { method: "POST" })
.then(() => {
// Redirect to login after sign-out
router.push("/login");
})
.catch(() => {
// Even if sign-out fails, redirect to login
router.push("/login");
});
}, [router]);
return (
<div className="min-h-screen flex items-center justify-center bg-stone-50">
<div className="text-center">
<div className="w-12 h-12 rounded-full border-4 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
<p className="text-stone-600">Signing out...</p>
</div>
</div>
);
}
+10 -18
View File
@@ -1,21 +1,21 @@
import { auth, signOut } from "@/lib/auth";
import { getSession, signOut } from "@/lib/auth";
import { redirect } from "next/navigation";
/**
* /protected-example
*
* Smoke-test page that demonstrates the new Auth.js v5 pattern. Calling
* `auth()` server-side returns the current session (null if not signed
* Smoke-test page that demonstrates the Neon Auth pattern. Calling
* `getSession()` server-side returns the current session (null if not signed
* in). The middleware in `../middleware.ts` already redirects
* unauthenticated visitors to `/login`, so by the time this page renders
* we always have a session.
*
* The page shows:
* • The user's name, email, and provider
* • The session token (first 8 chars only — never expose the whole thing)
* • A "Sign out" form action that calls `signOut()` from `next-auth`
* • A "Sign out" form action that calls `signOut()` from Neon Auth
*/
export default async function ProtectedExamplePage() {
const session = await auth();
const { data: session } = await getSession();
// Defensive: middleware should have already redirected. Render a
// friendly hint if we ever reach here unauthenticated.
@@ -39,12 +39,7 @@ export default async function ProtectedExamplePage() {
}
const user = session.user;
const expires = session.expires
? new Date(session.expires).toLocaleString()
: "(no expiry)";
// The raw session token isn't on the session object in v5 (only the
// csrfToken is exposed client-side). We surface what we do have.
return (
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6 py-12">
<div className="w-full max-w-xl space-y-6">
@@ -56,7 +51,7 @@ export default async function ProtectedExamplePage() {
Protected example
</h1>
<p className="mt-1 text-sm text-stone-500">
You are signed in. This page is guarded by the Auth.js
You are signed in. This page is guarded by the Neon Auth
middleware in <code className="text-xs">middleware.ts</code>.
</p>
</header>
@@ -81,13 +76,9 @@ export default async function ProtectedExamplePage() {
<div>
<dt className="text-stone-500">User id</dt>
<dd className="mt-1 font-mono text-xs text-stone-700 break-all">
{(user as { id?: string }).id ?? "(none)"}
{user.id ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">Session expires</dt>
<dd className="mt-1 font-medium text-stone-900">{expires}</dd>
</div>
</dl>
</section>
@@ -106,7 +97,8 @@ export default async function ProtectedExamplePage() {
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/login" });
await signOut();
redirect("/login");
}}
className="mt-4"
>
+18 -19
View File
@@ -1,12 +1,9 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { supabase } from "@/lib/supabase";
export default function ResetPasswordPage() {
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -28,25 +25,27 @@ export default function ResetPasswordPage() {
setLoading(true);
const { error: authError } = await supabase.auth.updateUser({
password,
});
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (authError) {
setError(authError.message);
const data = await res.json();
if (!res.ok || data.error) {
setError(data.message ?? "Failed to reset password. The link may have expired.");
setLoading(false);
return;
}
setDone(true);
} catch {
setError("An unexpected error occurred. Please try again.");
} finally {
setLoading(false);
return;
}
// Clear must_change_password flag in admin_users so the user
// is not forced through change-password again after re-login.
const { error: clearError } = await supabase.rpc("clear_must_change_password");
if (clearError) {
console.error("[reset-password] clear_must_change_password error:", clearError.message);
}
setDone(true);
setLoading(false);
}
if (done) {
+9 -11
View File
@@ -4,7 +4,6 @@ import { useEffect, useState, Suspense, lazy } from "react";
import { motion } from "framer-motion";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
// Lazy load heavy sections
@@ -13,9 +12,6 @@ const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimeli
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
const CTASection = lazy(() => import("@/components/about/CTASection"));
const OLATHE_SWEET_LOGO_DARK =
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
export default function TuxedoAboutPage() {
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
@@ -44,12 +40,14 @@ export default function TuxedoAboutPage() {
setContactPhone(s.phone ?? null);
setShowWholesaleLink(s.show_wholesale_link ?? true);
if (s.brand_id) {
const { data: ws } = await supabase
.from("wholesale_settings")
.select("invoice_business_address")
.eq("brand_id", s.brand_id)
.single();
if (ws?.invoice_business_address) setAddress(ws.invoice_business_address);
try {
const { pool } = await import("@/lib/db");
const { rows } = await pool.query<{ invoice_business_address: string }>(
"SELECT invoice_business_address FROM wholesale_settings WHERE brand_id = $1",
[s.brand_id]
);
if (rows[0]?.invoice_business_address) setAddress(rows[0].invoice_business_address);
} catch { /* ignore */ }
}
}
try {
@@ -133,7 +131,7 @@ export default function TuxedoAboutPage() {
<div className="relative">
<div className="absolute -inset-6 bg-emerald-900/30 rounded-full blur-2xl" />
<img
src={olatheSweetLogoUrlDark ?? OLATHE_SWEET_LOGO_DARK}
src={olatheSweetLogoUrlDark ?? "/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"}
alt="Olathe Sweet"
className="relative w-56 md:w-72 h-auto object-contain"
/>
+1 -1
View File
@@ -35,7 +35,7 @@ export default function TuxedoContactPage() {
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
.eq("brand_id", TUXEDO_BRAND_ID)
.single()
.then(({ data }) => setBrandSettings(data ?? null));
.then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
}, []);
function handleSubmit(e: React.FormEvent) {
+4 -4
View File
@@ -453,7 +453,7 @@ export default function TuxedoPage() {
getBrandSettingsPublic(slug),
]);
const brandData = brandResult.data;
const brandData = brandResult.data as Brand | null;
setBrand(brandData);
if (settingsResult.success && settingsResult.settings) {
@@ -485,8 +485,8 @@ export default function TuxedoPage() {
if (brandData?.id) {
const [{ data: stopsData }, { data: productsData }] = await Promise.all([
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true),
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true),
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
]);
setStops(stopsData ?? []);
setProducts(productsData ?? []);
@@ -716,7 +716,7 @@ export default function TuxedoPage() {
</div>
<p className="text-lg font-semibold text-stone-800">No stops on the calendar just yet</p>
<p className="mx-auto mt-2 max-w-md text-stone-500">
The harvest is right around the corner new pickup stops go live weekly. You can still preorder below and pick a stop once they're announced.
The harvest is right around the corner new pickup stops go live weekly. You can still preorder below and pick a stop once they&#39;re announced.
</p>
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
+12 -2
View File
@@ -23,6 +23,16 @@ type Product = {
pickup_type?: "scheduled_stop" | "shed";
};
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
export default function StopPage() {
const params = useParams();
const slug = params.slug as string;
@@ -47,7 +57,7 @@ export default function StopPage() {
.select("*")
.eq("slug", slug)
.eq("active", true)
.single();
.single() as unknown as { data: Stop | null };
if (!stopData) return;
@@ -60,7 +70,7 @@ export default function StopPage() {
.from("products")
.select("*")
.eq("brand_id", stopData.brand_id)
.eq("active", true);
.eq("active", true) as unknown as { data: Product[] | null };
setProducts(productsData ?? []);
}
load();
+7 -2
View File
@@ -1,4 +1,5 @@
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const BRAND_NAME = "Tuxedo Corn";
@@ -8,12 +9,16 @@ export const metadata = {
title: "Worker Clock — Tuxedo Corn",
};
export default function TuxedoTimeClockPage() {
export default async function TuxedoTimeClockPage() {
const settings = await getBrandSettingsPublic("tuxedo");
const logoUrl = settings.success ? (settings.settings?.logo_url ?? null) : null;
return (
<TimeTrackingFieldClient
brandId={BRAND_ID}
brandName={BRAND_NAME}
brandAccent={BRAND_ACCENT}
logoUrl={logoUrl}
/>
);
}
}
-1
View File
@@ -81,7 +81,6 @@ export default function EmployeePortalPage() {
async function handleSignOut() {
document.cookie = "employee_session=; path=/; max-age=0";
document.cookie = "dev_session=; path=/; max-age=0";
router.push("/wholesale/login");
}
+3 -3
View File
@@ -286,7 +286,7 @@ export default function WholesalePortalPage() {
.from("wholesale_settings")
.select("wholesale_enabled, online_payment_enabled")
.eq("brand_id", cust.brand_id)
.single();
.single() as unknown as { data: { wholesale_enabled: boolean; online_payment_enabled: boolean } | null };
setOnlinePaymentEnabled(ws?.online_payment_enabled ?? false);
setCustomer(cust);
setPreviewMode(true);
@@ -332,7 +332,7 @@ export default function WholesalePortalPage() {
.from("wholesale_settings")
.select("wholesale_enabled, online_payment_enabled")
.eq("brand_id", cust.brand_id)
.single();
.single() as unknown as { data: { wholesale_enabled: boolean; online_payment_enabled: boolean } | null };
if (ws && ws.wholesale_enabled === false) {
router.push("/wholesale/login?error=portal_disabled");
return;
@@ -354,7 +354,7 @@ export default function WholesalePortalPage() {
.from("brands")
.select("name")
.eq("id", brandId)
.single();
.single() as unknown as { data: { name: string } | null };
if (brand) setBrandName(brand.name);
}