feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ?? ""} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] }> });
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!));
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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're announced.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user