diff --git a/CLAUDE.md b/CLAUDE.md
index f4e9f3e..2ed0049 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,7 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
-Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4
+Tech stack: Next.js 16 (App Router) · **Postgres** (direct — Supabase is being removed) · Auth.js (NextAuth v5, in-progress migration from bespoke cookie auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
+
+> **Direction:** Supabase is being removed in favor of a direct Postgres connection. The `supabase/` directory is kept as a path for migrations tooling only (no Supabase platform/CLI/auth). Until the Auth.js migration ships, auth still flows through the `dev_session` / `rc_auth_uid` cookies — see the Authentication section. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST.
---
@@ -21,14 +23,12 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright)
```
-> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
-> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
-> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
-> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
+> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
+> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
-No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`).
+E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
---
@@ -36,15 +36,37 @@ No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.
### Authentication & Authorization
-**Dev mode** bypasses Supabase auth entirely via `dev_session` cookie set by `/login`:
+**Auth.js v5 (NextAuth)** is the active auth system. Config lives in `src/lib/auth.ts`, with the route handler at `src/app/api/auth/[...nextauth]/route.ts`. The login page (`src/app/login/LoginClient.tsx`) renders a "Continue with Google" button alongside the email/password form, both backed by Auth.js providers. Server-side code reads the session via `await auth()` from `@/lib/auth`; client code that needs to sign out can call the `signOutAction` server action from `@/actions/auth-actions`.
+
+**Providers (see `src/lib/auth.ts`):**
+- **Google OAuth** — primary sign-in. Active when `AUTH_GOOGLE_ID` + `AUTH_GOOGLE_SECRET` are set. Users auto-provision as `platform_admin` if their Google `sub` is UUID-shaped (rare) — for Google sign-ins, `admin_users` rows must be provisioned manually by an existing admin until the `email`-based provisioning flow lands.
+- **Email/password (Supabase-backed)** — wraps the existing `auth/v1/token?grant_type=password` flow. This is transitional; once Supabase auth is fully removed, this provider goes away.
+
+**Demo / dev mode** still works through a `dev_session` cookie:
- `dev_session=platform_admin` — full access, all brands
- `dev_session=brand_admin` — full access to assigned brand only
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
+- The login page renders "Demo Mode" buttons that set this cookie client-side; the middleware also auto-issues `dev_session=platform_admin` for the `/admin` demo flow when Supabase isn't configured.
-`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and Supabase Auth in production. **Never import this file directly into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
+**Single source of truth for the current admin user:** `getAdminUser()` in `src/lib/admin-permissions.ts`. It reads `dev_session` first, then the Auth.js session via `auth()` from `@/lib/auth`. The Supabase `admin_users` lookup still uses `rest/v1/admin_users?user_id=eq.` — when the `pg` pool at `src/lib/db.ts` lands, that lookup should switch to a direct `SELECT`. **Never import `admin-permissions.ts` into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
+
+The middleware (`src/middleware.ts`) uses Auth.js v5's `auth()` wrapper. It guards `/admin/*` and `/login`, preserves the `dev_session` bypass, and adds baseline security headers.
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
+#### Migration status
+
+- ✅ Auth.js v5 installed (`next-auth@beta`, currently `5.0.0-beta.31`)
+- ✅ `src/lib/auth.ts` + `src/app/api/auth/[...nextauth]/route.ts` in place
+- ✅ Google + Credentials (Supabase-backed) providers configured
+- ✅ `getAdminUser()` reads from `auth()` session
+- ✅ Middleware uses `auth()` wrapper
+- ✅ Old `/api/login`, `/api/logout`, `/api/auth/uid`, `/api/set-auth-cookie` removed
+- ⏳ Add `email` column to `admin_users` and provision Google users by email (TODO)
+- ⏳ Switch the `admin_users` lookup in `getAdminUser()` to direct `pg` (TODO — needs `src/lib/db.ts`)
+- ⏳ Remove the email/password (Supabase) provider when Supabase auth is fully cut over (TODO)
+- ⏳ The `rc_auth_uid` cookie is no longer the source of truth, but `actions/admin/users.ts` still reads it for backward compat with pre-existing sessions — the `DEV_FORCE_UID` constant and its branches are now dead code (the `/api/force-admin` route that set it was deleted) and should be removed in a follow-up
+
### Server Actions Pattern
All database writes go through server actions in `src/actions/`. These:
@@ -55,9 +77,19 @@ All database writes go through server actions in `src/actions/`. These:
Server actions are "use server" files that export async functions. Client components import and call them directly.
-### SECURITY DEFINER RPCs + Brand Scoping
+### Database (Postgres, direct)
-The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass RLS entirely. This means:
+The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
+
+#### Connection
+
+- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
+- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
+- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
+
+#### SECURITY DEFINER RPCs + Brand Scoping
+
+The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass any future RLS. This means:
- Brand isolation must be enforced at the **application layer** (in server actions), not in database policies
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
@@ -182,10 +214,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### Communications Module ("Harvest Reach")
-The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`.
+The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.)
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
+**Scheduled automations** (declared in `vercel.json`):
+- `POST /api/email-automation/abandoned-cart` — every 6h, fires abandoned-cart sequence emails
+- `POST /api/email-automation/welcome-sequence` — every 6h, fires welcome onboarding sequence
+- `POST /api/cron/send-scheduled` — daily 09:00, sends scheduled campaigns
+- `POST /api/wholesale/notifications/{send,dispatch,pickup-reminder}` — wholesale lifecycle
+- `POST /api/square/process-queue` — every 2 min, drains Square sync queue
+
+These endpoints are also reachable via curl for manual triggering; the email-automation routes accept `Authorization: Bearer $CRON_SECRET`.
+
### Payments
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
@@ -200,7 +241,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions
-- All DB mutations use Supabase REST API (`fetch` to `${supabaseUrl}/rest/v1/rpc/...`) from server actions, NOT the Supabase JS client (avoids SSR cookie issues)
+- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`.
- `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type
@@ -217,11 +258,11 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|---|---|
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
| Middleware (route protection) | `src/middleware.ts` |
-| Server actions | `src/actions/*.ts` (one file per domain) |
+| Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) |
| Admin pages | `src/app/admin/[module]/page.tsx` |
| Admin client components | `src/components/admin/*.tsx` |
-| Migrations | `supabase/migrations/` |
-| Supabase client | `src/lib/supabase.ts` |
+| Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
+| Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
| Email templates | `src/lib/email-templates.ts` |
| Date formatting | `src/lib/format-date.ts` |
| Feature flags | `src/lib/feature-flags.ts` |
@@ -238,7 +279,8 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Gotchas
- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone.
-- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions.
+- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have no row-level policies. All brand scoping must be enforced in server actions.
+- **Supabase residue in the wild**: `grep -r "@supabase" src/` will still find imports during the transition. Do not add new ones; if you're touching a file that imports from Supabase, replace the call with the equivalent `pg`-pool call before merging.
- **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead.
- **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item.
- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`.
\ No newline at end of file
diff --git a/MEMORY.md b/MEMORY.md
index 96d35ff..917ecd2 100644
--- a/MEMORY.md
+++ b/MEMORY.md
@@ -2,11 +2,40 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
-**Last updated:** 2026-06-03 (during Supabase migration apply session)
+**Last updated:** 2026-06-06 (Supabase → Postgres pivot)
---
-## Supabase CLI + Migrations Tooling
+## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
+
+The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
+
+### What changes immediately
+- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
+- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
+- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
+- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
+- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
+
+### What's TBD / needs follow-up
+- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
+- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
+- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
+- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
+- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
+- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
+
+### Migration content that's now obsolete
+- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
+- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
+- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
+
+### Historical sections below
+The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
+
+---
+
+## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
### Login + Link (done in this session)
- User ran `supabase login`
@@ -35,15 +64,23 @@ Key changes:
- Falls back to direct `pg` only if CLI path fails.
- Header comments updated with current recommended workflow.
-**Recommended commands now:**
+**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
```bash
+# Supabase CLI path (legacy — do not use going forward)
supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp
-node supabase/push-migrations.js 148 # or any prefix
+node supabase/push-migrations.js 148 # CLI path
# or
npm run migrate:one 148
```
+```bash
+# Direct pg path (this is the future — only the pg branch is kept alive in the script)
+DATABASE_URL=postgres://... node supabase/push-migrations.js 148
+# or
+DATABASE_URL=postgres://... npm run migrate:one 148
+```
+
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
---
@@ -133,19 +170,23 @@ Verification queries (post-apply) confirmed:
---
-## Current State / Gotchas
+## Current State / Gotchas (2026-06-06)
-- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. Only early ones (001/002) + many timestamped migrations from other activity are tracked.
-- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
-- When adding **new** migrations in the future, prefer the standard `supabase migration new` flow if possible, but the custom `push-migrations.js` + numeric prefix style is still the established pattern here.
-- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
+- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
+- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
+- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
+- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
+- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
+- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
+- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
---
## How to Use This Memory
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
+- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
- Update this file with new key facts, applied migrations, or new gotchas.
- Feel free to add dated sections.
diff --git a/debug-auth-test.ts b/debug-auth-test.ts
deleted file mode 100644
index 981e6db..0000000
--- a/debug-auth-test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { chromium } from 'playwright';
-
-async function debugAuth() {
- console.log('Launching browser...');
- const browser = await chromium.launch({ headless: true });
- const context = await browser.newContext();
- const page = await context.newPage();
-
- // First, login
- console.log('Navigating to login page...');
- await page.goto('https://route-commerce-platform.vercel.app/login');
-
- console.log('Filling login form...');
- await page.fill('#email', 'kylemart@gmail.com');
- await page.fill('#password', 'Test123456!');
-
- console.log('Clicking sign in...');
- const response = await page.click('button[type="submit"]');
-
- // Wait for network to settle
- await page.waitForLoadState('networkidle').catch(() => {});
-
- console.log('Current URL after wait:', page.url());
-
- // Get any error messages
- const errorText = await page.$eval('[role="alert"]', el => el.textContent).catch(() => null);
- if (errorText) console.log('Error message:', errorText);
-
- const pageContent = await page.content();
- if (pageContent.includes('Access Denied')) {
- console.log('*** ACCESS DENIED PAGE DETECTED ***');
- }
-
- // Check cookies
- const cookies = await context.cookies();
- console.log('Cookies:', cookies.map(c => `${c.name}=${c.value.slice(0, 30)}...`));
-
- // Try to visit debug-auth page
- console.log('Navigating to /admin/debug-auth...');
- try {
- const response = await page.goto('https://route-commerce-platform.vercel.app/admin/debug-auth', { timeout: 10000 });
- console.log('Response status:', response?.status());
- console.log('Response URL:', page.url());
- const content = await page.content();
- console.log('Page content (first 2000 chars):', content.slice(0, 2000));
- } catch (e) {
- console.log('Error:', e);
- }
-
- await browser.close();
-}
-
-debugAuth().catch(console.error);
\ No newline at end of file
diff --git a/middleware.ts b/middleware.ts
deleted file mode 100644
index 9b2287e..0000000
--- a/middleware.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { NextResponse, type NextRequest } from "next/server";
-
-const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
-
-export async function middleware(request: NextRequest) {
- const response = NextResponse.next({ request });
-
- // ── Dev session bypass (enabled in all envs for demo) ──────────────
- // Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
- const devSession = request.cookies.get("dev_session")?.value;
- const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
- const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
-
- let authUid: string | null = null;
-
- if (isDevMode) {
- // Dev session only valid in development
- authUid = DEV_UID;
- } else if (rcAuthUid) {
- // rc_auth_uid is set by /api/login — treat as authenticated
- authUid = rcAuthUid;
- }
- // No rc_auth_uid in production → authUid stays null → redirect to /login
-
- const isAdmin = request.nextUrl.pathname.startsWith("/admin");
- const isLogin = request.nextUrl.pathname === "/login";
-
- if (isAdmin && !authUid) {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- // Auto-login for demo: no Supabase configured, no auth cookie present
- if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
- const url = request.nextUrl.clone();
- url.pathname = "/admin";
- url.searchParams.set("demo", "1");
- const response = NextResponse.redirect(url);
- response.cookies.set("dev_session", "platform_admin", {
- path: "/",
- maxAge: 60 * 60 * 24,
- httpOnly: true,
- sameSite: "strict",
- });
- return response;
- }
- const url = request.nextUrl.clone();
- url.pathname = "/login";
- return NextResponse.redirect(url);
- }
-
- if (isLogin && authUid) {
- const url = request.nextUrl.clone();
- url.pathname = "/admin";
- return NextResponse.redirect(url);
- }
-
- return response;
-}
-
-export const config = {
- matcher: [
- "/admin/:path*",
- "/admin",
- "/login",
- ],
-};
\ No newline at end of file
diff --git a/package.json b/package.json
index f093578..dd2eb27 100644
--- a/package.json
+++ b/package.json
@@ -11,11 +11,15 @@
"migrate": "node supabase/push-migrations.js",
"migrate:one": "node supabase/push-migrations.js",
"type-check": "npx tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:ui": "vitest --ui",
+ "test:e2e": "playwright test --project=local",
+ "test:e2e:prod": "PLAYWRIGHT_PROD=1 playwright test",
"format": "prettier --write \"src/**/*.{ts,tsx}\""
},
"dependencies": {
"@anthropic-ai/sdk": "^0.96.0",
- "@clerk/nextjs": "^7.4.2",
"@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2",
"@sentry/nextjs": "^10.55.0",
@@ -28,6 +32,7 @@
"gsap": "^3.15.0",
"lucide-react": "^1.17.0",
"next": "^16.2.6",
+ "next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"openai": "^6.37.0",
"papaparse": "^5.5.3",
@@ -48,17 +53,22 @@
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/papaparse": "^5.5.2",
+ "@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^11.0.0",
+ "@vitejs/plugin-react": "^4.7.0",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.2.5",
+ "jsdom": "^25.0.1",
"pg": "^8.20.0",
"playwright": "^1.59.1",
"tailwindcss": "^4",
- "typescript": "^5"
+ "typescript": "^5",
+ "vite-tsconfig-paths": "^5.1.4",
+ "vitest": "^2.1.9"
},
"overrides": "{}"
}
diff --git a/playwright.config.ts b/playwright.config.ts
index a96b010..d71384a 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,6 +1,9 @@
import { defineConfig, devices } from "@playwright/test";
import path from "path";
+const LOCAL_BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
+const PROD_BASE = "https://route-commerce-platform.vercel.app";
+
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
@@ -9,16 +12,19 @@ export default defineConfig({
workers: 1,
reporter: "list",
use: {
- baseURL: "https://route-commerce-platform.vercel.app",
+ baseURL: LOCAL_BASE,
trace: "on-first-retry",
},
projects: [
+ {
+ name: "local",
+ use: { ...devices["Desktop Chrome"], baseURL: LOCAL_BASE },
+ },
{
name: "production",
- use: {
- ...devices["Desktop Chrome"],
- baseURL: "https://route-commerce-platform.vercel.app",
- },
+ // `PLAYWRIGHT_PROD=1 npx playwright test` to run against the live site.
+ testMatch: /.*\.prod\.spec\.ts$/,
+ use: { ...devices["Desktop Chrome"], baseURL: PROD_BASE },
},
],
});
diff --git a/src/actions/admin/force-login.ts b/src/actions/admin/force-login.ts
deleted file mode 100644
index e70dbec..0000000
--- a/src/actions/admin/force-login.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-"use server";
-
-import { createServerClient } from "@supabase/ssr";
-import { cookies } from "next/headers";
-import { NextResponse } from "next/server";
-
-const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
-
-export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
- const cookieStore = await cookies();
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
-
- const response = NextResponse.next();
- const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
- cookies: {
- getAll() { return cookieStore.getAll(); },
- setAll(cookiesToSet, headers) {
- cookiesToSet.forEach(({ name, value, options }) => {
- response.cookies.set(name, value, options);
- });
- Object.entries(headers).forEach(([key, value]) => {
- response.headers.set(key, value);
- });
- },
- },
- });
-
- // Upsert dev platform_admin record
- const { data: existing } = await supabase
- .from("admin_users")
- .select("id, role")
- .eq("user_id", DEV_ADMIN_UID)
- .single();
-
- if (!existing) {
- const { error: insertError } = await supabase
- .from("admin_users")
- .insert({
- user_id: DEV_ADMIN_UID,
- brand_id: null,
- role: "platform_admin",
- active: true,
- can_manage_products: true,
- can_manage_stops: true,
- can_manage_orders: true,
- can_manage_pickup: true,
- can_manage_messages: true,
- can_manage_refunds: true,
- can_manage_users: true,
- can_manage_water_log: true,
- can_manage_reports: true,
- can_manage_settings: true,
- must_change_password: false,
- });
-
- if (insertError) {
- return { success: false, error: insertError.message };
- }
- }
-
- return { success: true, uid: DEV_ADMIN_UID };
-}
diff --git a/src/actions/admin/password.ts b/src/actions/admin/password.ts
index 24e317e..ebe9af5 100644
--- a/src/actions/admin/password.ts
+++ b/src/actions/admin/password.ts
@@ -1,30 +1,48 @@
"use server";
-import { cookies } from "next/headers";
-import { createClient as createServiceClient } from "@supabase/supabase-js";
+import { auth } from "@/lib/auth";
+import { query } from "@/lib/db";
+/**
+ * Update the current user's Supabase auth password.
+ *
+ * Reads the Auth.js v5 session to identify the user. The session's
+ * `user.id` is either:
+ * - a Supabase auth user id (UUID) for email/password sign-ins
+ * - a Google `sub` (non-UUID) for Google sign-ins — these are not
+ * provisioned in Supabase auth, so the RPC will reject them. Google
+ * users must be provisioned in Supabase auth separately.
+ *
+ * The password update itself runs as a SECURITY DEFINER PL/pgSQL function
+ * (`update_user_password`) inside the database, called directly via the
+ * shared `pg` pool. No Supabase REST hop required.
+ */
export async function updatePasswordAction(
newPassword: string
-): Promise<{ error?: string }> {
- const cookieStore = await cookies();
- const uid =
- cookieStore.get("rc_auth_uid")?.value ??
- cookieStore.get("rc_uid")?.value;
-
+): Promise<{ error?: string; userId?: string }> {
+ const session = await auth();
+ const uid = session?.user?.id;
if (!uid) {
return { error: "Not authenticated. Please log in again." };
}
- const service = createServiceClient(
- process.env.NEXT_PUBLIC_SUPABASE_URL!,
- process.env.SUPABASE_SERVICE_ROLE_KEY!
- );
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ if (!UUID_REGEX.test(uid)) {
+ return {
+ error:
+ "Password change is not available for social sign-in accounts. Please contact an admin.",
+ };
+ }
- const { error } = await service.rpc("update_user_password", {
- p_user_id: uid,
- p_password: newPassword,
- });
-
- if (error) return { error: error.message };
- return {};
-}
\ No newline at end of file
+ try {
+ // The RPC is SECURITY DEFINER and returns a single row (or raises).
+ // We SELECT it (rather than SELECT update_user_password(...)) so the
+ // call stays a normal parameterized query and we can read the result.
+ await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
+ return { userId: uid };
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : "Failed to update password.";
+ return { error: message };
+ }
+}
diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts
index af0c288..904d854 100644
--- a/src/actions/admin/users.ts
+++ b/src/actions/admin/users.ts
@@ -79,19 +79,11 @@ export type UpdateAdminUserInput = {
async function getAuthClient() {
const cookieStore = await cookies();
- const headerStore = await headers();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
const response = NextResponse.next({ request });
- // Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets
- // cookies that arrive in the header but NOT in next/headers cookies()).
- const cookieHeader = headerStore.get("cookie") || "";
- const allCookies = cookieHeader.split(";").map(c => c.trim());
- const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
- const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null;
-
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return cookieStore.getAll(); },
@@ -101,16 +93,20 @@ async function getAuthClient() {
},
},
});
- return { supabase, response, rcAuthUid };
+ const devSession = cookieStore.get("dev_session")?.value;
+ return { supabase, response, devSession };
}
async function callRpcWithAuth(fn: string, params: Record): Promise<{ data: T | null; error: string | null }> {
- const { supabase, rcAuthUid } = await getAuthClient();
+ const { supabase, devSession } = await getAuthClient();
- // Dev force-login UID bypasses Supabase auth entirely
- const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
- if (rcAuthUid === DEV_FORCE_UID) {
- return { data: null, error: null }; // let the action proceed without auth check
+ // Dev mode bypass — let the action proceed without Supabase auth.
+ // (Pre-Auth.js this was gated on the legacy `rc_auth_uid === DEV_FORCE_UID`
+ // cookie that the now-deleted `/api/force-admin` route set. With Auth.js v5
+ // in place, the `dev_session` cookie is the single source of truth for the
+ // demo flow.)
+ if (process.env.NODE_ENV !== "production" && devSession) {
+ return { data: null, error: null };
}
const { data: userData, error: userError } = await supabase.auth.getUser();
@@ -368,8 +364,6 @@ function buildUsersFromRows(adminRows: Record[], authUsers: { i
// ─── Production admin actions (require real Supabase auth) ─────────────────
-const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
-
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
@@ -389,15 +383,18 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
- // In development mode: ALL requests with a valid rc_auth_uid use the dev/service path.
- // This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation.
- if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
- return devListAdminUsers(rcAuthUid);
+ // In development mode: dev_session cookie holders use the dev/service path.
+ // (The previous code also accepted a legacy `rc_auth_uid` cookie set by
+ // `/api/dev-login` — `/api/dev-login` still sets both cookies, so existing
+ // dev sessions keep working. The legacy DEV_FORCE_UID check is removed
+ // because the only route that set that specific UID was deleted.)
+ if (process.env.NODE_ENV !== "production" && devSession) {
+ return devListAdminUsers(devSession);
}
// Dev session cookie (platform_admin/brand_admin) — always use service role path
const isDevAdmin = process.env.NODE_ENV !== "production" && (
- devSession === "platform_admin" || devSession === "brand_admin" || rcAuthUid === DEV_FORCE_UID
+ devSession === "platform_admin" || devSession === "brand_admin"
);
if (isDevAdmin) {
return devListAdminUsers(rcAuthUid ?? undefined);
@@ -445,16 +442,10 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
// Read auth context
const cookieStore = await cookies();
- const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
- const cookieHeader = headerStore.get("cookie") || "";
- const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
- .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
- // DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin
- if (rcAuthUid === DEV_FORCE_UID) {
- return devCreateAdminUser(input);
- }
+ // TODO: when the Auth.js v5 migration lands everywhere, replace this
+ // cookie-based check with a session check via `await auth()` from `@/lib/auth`.
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
@@ -463,8 +454,17 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
return devCreateAdminUser(input);
}
- // Production path — use service role directly (bypasses Supabase JWT auth)
- // rc_auth_uid cookie proves the admin is logged in; service role creates the account
+ // Production path — service role creates the account. The caller is
+ // expected to be an authenticated admin (gated by the admin layout /
+ // getAdminUser() check on the page).
+ // Keep reading the legacy `rc_auth_uid` cookie for backward compat with
+ // pre-Auth.js sessions — TODO: drop this branch once all clients are on
+ // Auth.js.
+ const headerStore = await headers();
+ const cookieHeader = headerStore.get("cookie") || "";
+ const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
+ .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
+
if (rcAuthUid) {
const service = getServiceClient();
@@ -557,13 +557,12 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
// Dev bypass check
const cookieStore = await cookies();
- const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
- const cookieHeader = headerStore.get("cookie") || "";
- const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
- .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
- // DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
- if (rcAuthUid === DEV_FORCE_UID) {
+ // Dev mode — let the action proceed with the service role.
+ // (The previous code also accepted a legacy `rc_auth_uid === DEV_FORCE_UID`
+ // cookie set by the now-deleted Emergency Force Login page. With Auth.js v5
+ // and the demo buttons in /login, the `dev_session` cookie is sufficient.)
+ if (process.env.NODE_ENV !== "production" && devSession) {
const service = getServiceClient();
const { data, error } = await service
.from("admin_users")
@@ -606,13 +605,13 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
// Dev bypass check
const cookieStore = await cookies();
- const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
- const cookieHeader = headerStore.get("cookie") || "";
- const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
- .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
- // DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
- if (rcAuthUid === DEV_FORCE_UID) {
+ // Dev mode — let the action proceed with the service role.
+ // (The previous code gated on `rc_auth_uid === DEV_FORCE_UID`, a magic
+ // cookie value the now-deleted Emergency Force Login page set. With
+ // Auth.js v5 and the demo buttons in /login, the `dev_session` cookie is
+ // the source of truth for the dev path.)
+ if (process.env.NODE_ENV !== "production" && devSession) {
const service = getServiceClient();
// Get user_id first
const { data: adminRow, error: fetchError } = await service
@@ -642,7 +641,10 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
- if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
+ // Dev path or legacy rc_auth_uid cookie — use service role directly.
+ // TODO: when Auth.js v5 is the only auth path, drop the rcAuthUid branch
+ // and require `await auth()` to be present.
+ if (process.env.NODE_ENV !== "production" || rcAuthUid) {
const service = getServiceClient();
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null };
diff --git a/src/actions/auth-actions.ts b/src/actions/auth-actions.ts
new file mode 100644
index 0000000..146981e
--- /dev/null
+++ b/src/actions/auth-actions.ts
@@ -0,0 +1,51 @@
+"use server";
+
+import { signIn, signOut } from "@/lib/auth";
+import { AuthError } from "next-auth";
+
+export type SignInResult = { ok: true } | { ok: false; error: string };
+
+/**
+ * Sign in with the email/password (Supabase-backed) Credentials provider
+ * configured in src/lib/auth.ts.
+ */
+export async function signInWithPassword(
+ _prev: SignInResult | null,
+ formData: FormData
+): Promise {
+ const email = String(formData.get("email") ?? "").trim();
+ const password = String(formData.get("password") ?? "");
+
+ if (!email) return { ok: false, error: "Please enter your email address." };
+ if (!password) return { ok: false, error: "Please enter your password." };
+
+ try {
+ await signIn("supabase-password", {
+ email,
+ password,
+ redirect: false,
+ });
+ return { ok: true };
+ } catch (err) {
+ if (err instanceof AuthError) {
+ return { ok: false, error: "Invalid email or password." };
+ }
+ throw err;
+ }
+}
+
+/**
+ * Kick off the Google OAuth flow. Auth.js will redirect to Google's consent
+ * screen and then back to /api/auth/callback/google, which sets the session
+ * cookie and redirects to the configured callback URL.
+ */
+export async function signInWithGoogle(): Promise {
+ await signIn("google", { redirectTo: "/admin" });
+}
+
+/**
+ * Sign out and clear the Auth.js session cookie.
+ */
+export async function signOutAction(): Promise {
+ await signOut({ redirectTo: "/login" });
+}
diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts
index 939af8e..92d5c52 100644
--- a/src/actions/brand-settings.ts
+++ b/src/actions/brand-settings.ts
@@ -271,25 +271,36 @@ export async function getBrandSettings(brandId: string): Promise {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- const response = await fetch(
- `${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
- {
- method: "POST",
- headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
- body: JSON.stringify({ p_brand_slug: brandSlug }),
- }
- );
+ if (!supabaseUrl || !supabaseKey) {
+ return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
+ }
- if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
- const data = await response.json();
- return {
- success: true,
- settings: data,
- wholesaleEnabled: data?.wholesale_enabled,
- };
+ // Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
+ // doesn't crash the prerender — the page just falls back to its
+ // default brand name and revalidates from a real request later.
+ try {
+ const response = await fetch(
+ `${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
+ {
+ method: "POST",
+ headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
+ body: JSON.stringify({ p_brand_slug: brandSlug }),
+ }
+ );
+
+ if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
+ const data = await response.json();
+ return {
+ success: true,
+ settings: data,
+ wholesaleEnabled: data?.wholesale_enabled,
+ };
+ } catch {
+ return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
+ }
}
export async function saveBrandSettings(params: {
diff --git a/src/actions/login.ts b/src/actions/login.ts
deleted file mode 100644
index da40bbc..0000000
--- a/src/actions/login.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-"use server";
-
-import { cookies } from "next/headers";
-import { createServerClient } from "@supabase/ssr";
-
-export type LoginWithPasswordResult =
- | { success: true; redirect: true }
- | { success: false; error: string };
-
-export async function loginWithPassword(
- email: string,
- password: string
-): Promise {
- const cookieStore = await cookies();
-
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
-
- if (!supabaseUrl || !supabaseAnonKey) {
- return { success: false, error: "Server misconfiguration." };
- }
-
- const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
- cookies: {
- getAll() {
- return cookieStore.getAll();
- },
- setAll(cookiesToSet) {
- cookiesToSet.forEach(({ name, value, options }) => {
- cookieStore.set(name, value, options);
- });
- },
- },
- });
-
- const { data, error } = await supabase.auth.signInWithPassword({
- email,
- password,
- });
-
- if (error || !data.user) {
- return { success: false, error: error?.message || "Invalid credentials" };
- }
-
- // Set the rc_auth_uid cookie that getAdminUser() reads
- const isProd = process.env.NODE_ENV === "production";
- cookieStore.set("rc_auth_uid", data.user.id, {
- path: "/",
- maxAge: 60 * 60 * 24 * 30,
- httpOnly: true,
- sameSite: "lax",
- secure: isProd,
- });
-
- return { success: true, redirect: true };
-}
diff --git a/src/actions/pickup.ts b/src/actions/pickup.ts
index 7181975..cb83704 100644
--- a/src/actions/pickup.ts
+++ b/src/actions/pickup.ts
@@ -5,7 +5,7 @@ import { logAuditEvent } from "@/actions/audit";
import { svcHeaders } from "@/lib/svc-headers";
type MarkPickupResult =
- | { success: true; pickup_completed_at: string; pickup_completed_by: string }
+ | { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
| { success: false; error: string };
export async function markPickupComplete(
@@ -23,6 +23,9 @@ export async function markPickupComplete(
}
const now = new Date().toISOString();
+ // `user_id` is null for Google-authenticated admins who haven't been
+ // linked to a Supabase auth user yet. Pass null through; downstream
+ // audit/assignment RPCs will surface a clearer error.
const performedBy = adminUser.user_id;
// brand_admin: verify the order belongs to their brand
diff --git a/src/actions/stops.ts b/src/actions/stops.ts
index b35826b..f1af40d 100644
--- a/src/actions/stops.ts
+++ b/src/actions/stops.ts
@@ -105,22 +105,30 @@ export type StopForSitemap = {
};
export async function getActiveStopsForSitemap(): Promise {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- // Get all active stops with their brand slug
- const response = await fetch(
- `${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
- {
- method: "POST",
- headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
- }
- );
+ if (!supabaseUrl || !supabaseKey) return [];
- if (!response.ok) return [];
+ // Get all active stops with their brand slug.
+ // Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
+ // crash the prerender — the sitemap just renders without stop URLs.
+ try {
+ const response = await fetch(
+ `${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
+ {
+ method: "POST",
+ headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
+ }
+ );
- const stops = await response.json();
- return Array.isArray(stops) ? stops : [];
+ if (!response.ok) return [];
+
+ const stops = await response.json();
+ return Array.isArray(stops) ? stops : [];
+ } catch {
+ return [];
+ }
}
/**
@@ -150,24 +158,33 @@ export async function getPublicStopsForBrand(
): Promise {
if (!brandSlug) return [];
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- const response = await fetch(
- `${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
- {
- method: "POST",
- headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
- body: JSON.stringify({ p_brand_slug: brandSlug }),
- next: {
- revalidate: 300,
- tags: ["stops", `brand:${brandSlug}:stops`],
- },
- }
- );
+ if (!supabaseUrl || !supabaseKey) return [];
- if (!response.ok) return [];
+ // Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
+ // doesn't crash the prerender — the page just renders with no stops
+ // and revalidates from a real request once the cache is warm.
+ try {
+ const response = await fetch(
+ `${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
+ {
+ method: "POST",
+ headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
+ body: JSON.stringify({ p_brand_slug: brandSlug }),
+ next: {
+ revalidate: 300,
+ tags: ["stops", `brand:${brandSlug}:stops`],
+ },
+ }
+ );
- const stops = await response.json();
- return Array.isArray(stops) ? (stops as PublicStop[]) : [];
+ if (!response.ok) return [];
+
+ const stops = await response.json();
+ return Array.isArray(stops) ? (stops as PublicStop[]) : [];
+ } catch {
+ return [];
+ }
}
\ No newline at end of file
diff --git a/src/app/admin/debug-auth/page.tsx b/src/app/admin/debug-auth/page.tsx
deleted file mode 100644
index f949329..0000000
--- a/src/app/admin/debug-auth/page.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { cookies } from "next/headers";
-
-export default async function DebugAuthPage() {
- const cookieStore = await cookies();
- const allCookies = cookieStore.getAll();
- const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
- const rcUid = cookieStore.get("rc_uid")?.value;
- const rcAccessToken = cookieStore.get("rc_access_token")?.value;
-
- let adminUsersStatus = "not_tried";
- let adminUsersResult: string | null = null;
-
- if (rcAuthUid) {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
- if (supabaseUrl && serviceKey) {
- try {
- const res = await fetch(
- `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
- { headers: { apikey: serviceKey, "Content-Type": "application/json" } }
- );
- adminUsersStatus = String(res.status);
- const data = await res.json().catch(() => null);
- adminUsersResult = JSON.stringify(data);
- } catch (e) {
- adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
- }
- } else {
- adminUsersStatus = "missing_env_vars";
- }
- } else {
- adminUsersStatus = "no_rc_auth_uid_cookie";
- }
-
- return (
-
-
Auth Debug
-
-
-
Cookies ({allCookies.length})
-
- {allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
-
-
-
-
-
Key Cookies
-
rc_auth_uid: {rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}
-
rc_uid: {rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}
-
rc_access_token: {rcAccessToken ? "PRESENT" : "NOT SET"}
-
-
-
-
Admin Users Lookup
-
Status: {adminUsersStatus}
-
Result:
{adminUsersResult || "(none)"}
-
-
- );
-}
\ No newline at end of file
diff --git a/src/app/admin/settings/square-sync/page.tsx b/src/app/admin/settings/square-sync/page.tsx
index 032dd80..167ace1 100644
--- a/src/app/admin/settings/square-sync/page.tsx
+++ b/src/app/admin/settings/square-sync/page.tsx
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
+// Uses cookies() via getAdminUser — must be dynamic to avoid the
+// "couldn't be rendered statically" build error.
+export const dynamic = "force-dynamic";
+
export default async function SquareSyncSettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
diff --git a/src/app/admin/test-auth/page.tsx b/src/app/admin/test-auth/page.tsx
deleted file mode 100644
index cdbaafb..0000000
--- a/src/app/admin/test-auth/page.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { getAdminUser } from "@/lib/admin-permissions";
-import { cookies } from "next/headers";
-
-export const dynamic = "force-dynamic";
-
-export default async function TestAuthPage() {
- const cookieStore = await cookies();
- const allCookies = cookieStore.getAll();
-
- let adminUser = null;
- let error: string | null = null;
-
- try {
- adminUser = await getAdminUser();
- } catch (e: unknown) {
- error = e instanceof Error ? e.message : String(e);
- }
-
- return (
-
-
Auth Debug
-
-
-
All Cookies ({allCookies.length})
-
- {allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
-
-
-
-
-
rc_auth_uid
-
- {allCookies.some(c => c.name === "rc_auth_uid")
- ? SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value}
- : NOT SET
- }
-
-
-
-
-
rc_access_token
-
- {allCookies.some(c => c.name === "rc_access_token")
- ? SET (not needed)
- : NOT SET (OK)
- }
-
-
-
-
-
getAdminUser() result
- {error ? (
-
- ) : adminUser ? (
-
-
AUTHENTICATED
-
- {JSON.stringify({
- id: adminUser.id,
- user_id: adminUser.user_id,
- role: adminUser.role,
- brand_id: adminUser.brand_id,
- active: adminUser.active,
- }, null, 2)}
-
-
- ) : (
-
NOT AUTHENTICATED — null returned
- )}
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/src/app/admin/test-cookies/page.tsx b/src/app/admin/test-cookies/page.tsx
deleted file mode 100644
index 638275c..0000000
--- a/src/app/admin/test-cookies/page.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { getAdminUser } from "@/lib/admin-permissions";
-import { cookies } from "next/headers";
-
-export const dynamic = "force-dynamic";
-
-export default async function TestPage() {
- const cookieStore = await cookies();
- const allCookies = cookieStore.getAll();
- let adminUser = null;
- let adminUserError: string | null = null;
-
- try {
- adminUser = await getAdminUser();
- } catch (e: any) {
- adminUserError = e?.message ?? String(e);
- }
-
- return (
-
-
Auth Debug
-
-
-
Server cookies ({allCookies.length})
-
- {allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}...`).join("\n") || "(none)"}
-
-
-
-
-
rc_access_token present?
-
- {allCookies.some(c => c.name === "rc_access_token")
- ? YES — {allCookies.find(c => c.name === "rc_access_token")?.value.length} chars
- : NO
- }
-
-
-
-
-
getAdminUser() result
- {adminUserError ? (
-
ERROR: {adminUserError}
- ) : (
-
- {JSON.stringify(adminUser, null, 2)}
-
- )}
-
-
- );
-}
\ No newline at end of file
diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..6e9b8af
--- /dev/null
+++ b/src/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,4 @@
+// Auth.js v5 route handler — re-exports the GET/POST handlers from src/lib/auth.ts
+// Mounted at /api/auth/* (signin, signout, callback, session, csrf, providers, etc.)
+import { handlers } from "@/lib/auth";
+export const { GET, POST } = handlers;
diff --git a/src/app/api/auth/uid/route.ts b/src/app/api/auth/uid/route.ts
deleted file mode 100644
index 9a96310..0000000
--- a/src/app/api/auth/uid/route.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { NextResponse } from "next/server";
-import { cookies } from "next/headers";
-
-export async function GET() {
- const cookieStore = await cookies();
- const uid =
- cookieStore.get("rc_auth_uid")?.value ??
- cookieStore.get("rc_uid")?.value ??
- null;
- return NextResponse.json({ uid });
-}
\ No newline at end of file
diff --git a/src/app/api/debug-admin-users/route.ts b/src/app/api/debug-admin-users/route.ts
deleted file mode 100644
index cded2cf..0000000
--- a/src/app/api/debug-admin-users/route.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { NextResponse } from "next/server";
-
-export async function GET() {
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
-
- if (!serviceKey || !supabaseUrl) {
- return NextResponse.json({ error: "Missing env vars", serviceKey: !!serviceKey, supabaseUrl: !!supabaseUrl }, { status: 500 });
- }
-
- // Test 1: just a simple health endpoint that doesn't require the key
- let healthResult = null;
- try {
- const res = await fetch(`${supabaseUrl}/rest/v1/`, {
- headers: { apikey: serviceKey },
- });
- healthResult = { status: res.status, ok: res.ok };
- } catch (e: any) {
- healthResult = { error: e?.message };
- }
-
- // Test 2: try admin_users with POST (bypasses RLS select policies)
- let adminResult = null;
- try {
- const res = await fetch(
- `${supabaseUrl}/rest/v1/admin_users?select=id,user_id,role,email,display_name&limit=5`,
- {
- method: "GET",
- headers: {
- apikey: serviceKey,
- "Content-Type": "application/json",
- Prefer: "return=representation",
- },
- }
- );
- const body = await res.text();
- let parsed = null;
- try { parsed = JSON.parse(body); } catch { parsed = body; }
- adminResult = { status: res.status, body: parsed };
- } catch (e: any) {
- adminResult = { error: e?.message };
- }
-
- return NextResponse.json({ healthResult, adminResult, serviceKeyLen: serviceKey.length });
-}
\ No newline at end of file
diff --git a/src/app/api/debug-auth/route.ts b/src/app/api/debug-auth/route.ts
deleted file mode 100644
index 41ca183..0000000
--- a/src/app/api/debug-auth/route.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { NextResponse } from "next/server";
-import { cookies } from "next/headers";
-
-export async function GET() {
- const cookieStore = await cookies();
- const allCookies = cookieStore.getAll();
- const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
- const rcUid = cookieStore.get("rc_uid")?.value;
- const rcAccessToken = cookieStore.get("rc_access_token")?.value;
-
- let adminUsersStatus = "not_tried";
- let adminUsersResult: string | null = null;
-
- if (rcAuthUid) {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
- if (supabaseUrl && serviceKey) {
- try {
- const res = await fetch(
- `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
- { headers: { apikey: serviceKey, "Content-Type": "application/json" } }
- );
- adminUsersStatus = String(res.status);
- const data = await res.json().catch(() => null);
- adminUsersResult = JSON.stringify(data);
- } catch (e) {
- adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
- }
- } else {
- adminUsersStatus = "missing_env_vars";
- }
- } else {
- adminUsersStatus = "no_rc_auth_uid_cookie";
- }
-
- return NextResponse.json({
- cookies: {
- rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
- rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
- rc_access_token: rcAccessToken ? "present" : null,
- all_cookie_names: allCookies.map(c => c.name),
- },
- admin_users: {
- status: adminUsersStatus,
- result: adminUsersResult,
- },
- });
-}
\ No newline at end of file
diff --git a/src/app/api/debug-cookie/route.ts b/src/app/api/debug-cookie/route.ts
deleted file mode 100644
index eed9951..0000000
--- a/src/app/api/debug-cookie/route.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { NextResponse } from "next/server";
-import { cookies } from "next/headers";
-
-export const dynamic = "force-dynamic";
-
-export async function GET() {
- const cookieStore = await cookies();
- const allCookies = cookieStore.getAll();
- const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
- const rcUid = cookieStore.get("rc_uid")?.value;
-
- return NextResponse.json({
- received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })),
- rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING",
- rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING",
- raw_rc_auth_uid: rcAuthUid ?? null,
- });
-}
diff --git a/src/app/api/debug-env/route.ts b/src/app/api/debug-env/route.ts
deleted file mode 100644
index c346c06..0000000
--- a/src/app/api/debug-env/route.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { NextResponse } from "next/server";
-
-export async function GET() {
- const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
- const nodeEnv = process.env.NODE_ENV;
-
- const result = {
- NODE_ENV: nodeEnv,
- NEXT_PUBLIC_SUPABASE_URL: url ? `${url.substring(0, 40)}... (SET)` : "MISSING",
- NEXT_PUBLIC_SUPABASE_ANON_KEY: key ? `${key.substring(0, 20)}... (SET)` : "MISSING",
- SUPABASE_SERVICE_ROLE_KEY: serviceKey ? `${serviceKey.substring(0, 20)}... (SET)` : "MISSING",
- supabaseClientCanCreate: false as boolean,
- error: null as string | null,
- };
-
- if (url && key) {
- try {
- const { createClient } = await import("@supabase/supabase-js");
- const client = createClient(url, key);
- result.supabaseClientCanCreate = true;
- } catch (e: any) {
- result.error = e?.message ?? String(e);
- }
- } else {
- result.error = "Missing env vars";
- }
-
- return NextResponse.json(result, { status: 200 });
-}
\ No newline at end of file
diff --git a/src/app/api/debug-get-admin-users/route.ts b/src/app/api/debug-get-admin-users/route.ts
deleted file mode 100644
index a70e662..0000000
--- a/src/app/api/debug-get-admin-users/route.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { NextResponse } from "next/server";
-import { getAdminUser } from "@/lib/admin-permissions";
-
-export async function GET() {
- // Test the REST call directly from this endpoint
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
- const uid = "c023efb3-e3ef-4156-bed6-d17b92ea8aca";
-
- let restResult = null;
- try {
- const res = await fetch(
- `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
- { headers: { apikey: serviceKey, "Content-Type": "application/json" } }
- );
- const data = await res.json().catch(() => null);
- restResult = { status: res.status, data, keyLen: serviceKey.length };
- } catch (e: any) {
- restResult = { error: e?.message };
- }
-
- const adminUser = await getAdminUser();
- return NextResponse.json({
- adminUser: adminUser ? {
- id: adminUser.id,
- user_id: adminUser.user_id,
- role: adminUser.role,
- brand_id: adminUser.brand_id,
- active: adminUser.active,
- } : null,
- restResult,
- supabaseUrl: supabaseUrl ? "SET" : "MISSING",
- });
-}
\ No newline at end of file
diff --git a/src/app/api/debug-hello/route.ts b/src/app/api/debug-hello/route.ts
deleted file mode 100644
index 59b8821..0000000
--- a/src/app/api/debug-hello/route.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { NextResponse } from "next/server";
-
-export async function GET() {
- return NextResponse.json({
- ts: new Date().toISOString(),
- deployment: "debug-hello",
- msg: "hello from latest build"
- });
-}
\ No newline at end of file
diff --git a/src/app/api/debug-me/route.ts b/src/app/api/debug-me/route.ts
deleted file mode 100644
index 72aa90b..0000000
--- a/src/app/api/debug-me/route.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { NextResponse } from "next/server";
-import { cookies } from "next/headers";
-
-export const dynamic = "force-dynamic";
-
-export async function GET() {
- const cookieStore = await cookies();
- const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
-
- if (!uid) {
- return NextResponse.json({ error: "No uid cookie found" }, { status: 400 });
- }
-
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
-
- if (!supabaseUrl || !serviceKey) {
- return NextResponse.json({
- uid,
- error: "Missing env vars",
- supabaseUrl: supabaseUrl ?? "MISSING",
- serviceKeyPresent: !!serviceKey,
- });
- }
-
- // Exact same lookup as getAdminUser
- const lookupRes = await fetch(
- `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
- { headers: { apikey: serviceKey, "Content-Type": "application/json" } }
- );
-
- let adminUsers: unknown[] = [];
- let lookupOk = lookupRes.ok;
- let lookupStatus = lookupRes.status;
- let lookupData: unknown = null;
-
- if (lookupRes.ok) {
- lookupData = await lookupRes.json().catch(() => []);
- adminUsers = Array.isArray(lookupData) ? lookupData : [];
- } else {
- lookupData = await lookupRes.text().catch(() => "unknown error");
- }
-
- if (adminUsers.length > 0) {
- return NextResponse.json({
- uid,
- result: "found",
- adminUser: adminUsers[0],
- });
- }
-
- // Try auto-create
- const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
- if (!UUID_REGEX.test(uid)) {
- return NextResponse.json({ uid, result: "invalid_uuid" });
- }
-
- const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
- method: "POST",
- headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
- body: JSON.stringify({
- user_id: uid, role: "platform_admin", brand_id: null, active: true,
- can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
- can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
- can_manage_users: true, can_manage_water_log: true, can_manage_reports: true,
- can_manage_settings: true, must_change_password: false
- }),
- });
-
- return NextResponse.json({
- uid,
- result: "auto_created",
- lookupOk,
- lookupStatus,
- adminUsersFound: adminUsers.length,
- postStatus: postRes.status,
- postOk: postRes.ok,
- postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null),
- });
-}
diff --git a/src/app/api/force-admin/route.ts b/src/app/api/force-admin/route.ts
deleted file mode 100644
index 97b7de7..0000000
--- a/src/app/api/force-admin/route.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { NextResponse } from "next/server";
-
-const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
-const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"];
-
-export async function GET(request: Request) {
- const url = new URL(request.url);
- const role = url.searchParams.get("role") ?? "platform_admin";
- const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin";
-
- const origin = url.origin;
-
- const response = NextResponse.redirect(new URL("/admin", origin));
-
- const cookieOptions = {
- path: "/",
- maxAge: 60 * 60 * 24 * 30,
- sameSite: "lax" as const,
- };
-
- response.cookies.set("dev_session", safeRole, {
- ...cookieOptions,
- httpOnly: false,
- });
- response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, {
- ...cookieOptions,
- httpOnly: false,
- });
-
- return response;
-}
\ No newline at end of file
diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts
deleted file mode 100644
index abfea1a..0000000
--- a/src/app/api/login/route.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { NextResponse } from "next/server";
-import { cookies } from "next/headers";
-
-export async function POST(request: Request) {
- const { email, password } = await request.json().catch(() => ({}));
-
- if (!email || !password) {
- return NextResponse.json({ error: "Email and password are required." }, { status: 400 });
- }
-
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- if (!supabaseUrl || !supabaseAnonKey) {
- return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
- }
-
- const authRes = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
- method: "POST",
- headers: { "Content-Type": "application/json", apikey: supabaseAnonKey },
- body: JSON.stringify({ email, password }),
- });
-
- const authData = await authRes.json().catch(() => null);
-
- if (!authRes.ok || !authData?.access_token) {
- const msg = authData?.error_description ?? authData?.error ?? "Invalid credentials.";
- return NextResponse.json({ ok: false, error: msg }, { status: 401 });
- }
-
- const userId = authData.user?.id ?? authData.user_id;
- if (!userId) {
- return NextResponse.json({ ok: false, error: "Auth succeeded but user ID missing." }, { status: 500 });
- }
-
- // Set cookie + return JSON — client reads this and navigates
- const isProd = process.env.NODE_ENV === "production";
- const response = NextResponse.json({ ok: true });
- response.cookies.set("rc_auth_uid", userId, {
- path: "/",
- maxAge: 60 * 60 * 24 * 30,
- httpOnly: true,
- sameSite: "lax",
- secure: isProd,
- });
-
- return response;
-}
\ No newline at end of file
diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts
deleted file mode 100644
index 3846a1b..0000000
--- a/src/app/api/logout/route.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { NextResponse } from "next/server";
-import { cookies } from "next/headers";
-
-export async function POST() {
- const cookieStore = await cookies();
- // Clear all auth cookies (new + legacy)
- cookieStore.delete("rc_access_token");
- cookieStore.delete("rc_uid");
- cookieStore.delete("rc_auth_uid");
- cookieStore.delete("rc_auth_token");
- return NextResponse.json({ success: true });
-}
diff --git a/src/app/api/set-auth-cookie/route.ts b/src/app/api/set-auth-cookie/route.ts
deleted file mode 100644
index b281a89..0000000
--- a/src/app/api/set-auth-cookie/route.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { NextResponse } from "next/server";
-import { cookies } from "next/headers";
-
-export async function POST(request: Request) {
- const { userId } = await request.json().catch(() => ({}));
-
- if (!userId) {
- return NextResponse.json({ error: "Missing userId" }, { status: 400 });
- }
-
- const cookieStore = await cookies();
- const isProd = process.env.NODE_ENV === "production";
- cookieStore.set("rc_auth_uid", userId, {
- path: "/",
- maxAge: 60 * 60 * 24 * 30,
- httpOnly: true,
- sameSite: isProd ? "strict" : "lax",
- secure: isProd,
- });
-
- return NextResponse.json({ ok: true });
-}
\ No newline at end of file
diff --git a/src/app/api/supabase-test/route.ts b/src/app/api/supabase-test/route.ts
deleted file mode 100644
index cc642a8..0000000
--- a/src/app/api/supabase-test/route.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { createClient } from "@supabase/supabase-js";
-
-const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
-const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
-
-export async function GET() {
- // Try creating supabase client — if it throws, capture the exact error
- let status: string;
- let canCreate = false;
- let errMessage = "";
-
- if (!url || !key) {
- status = "MISSING_ENV_VARS";
- errMessage = `url=${url ? "SET" : "MISSING"}, key=${key ? "SET" : "MISSING"}`;
- } else {
- try {
- createClient(url, key);
- canCreate = true;
- status = "OK";
- } catch (e: any) {
- errMessage = e?.message ?? String(e);
- status = "CREATE_CLIENT_FAILED";
- }
- }
-
- const body = JSON.stringify({
- status,
- canCreate,
- errMessage,
- envVars: {
- NODE_ENV: process.env.NODE_ENV,
- urlSet: !!url,
- urlPrefix: url ? url.substring(0, 30) : null,
- keySet: !!key,
- keyPrefix: key ? key.substring(0, 10) : null,
- },
- }, null, 2);
-
- return new Response(body, {
- status: status === "OK" ? 200 : 500,
- headers: { "Content-Type": "application/json" },
- });
-}
\ No newline at end of file
diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx
deleted file mode 100644
index 19ad3b4..0000000
--- a/src/app/auth/callback/page.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useRouter, useSearchParams } from "next/navigation";
-
-export default function AuthCallback() {
- const router = useRouter();
-
- useEffect(() => {
- const url = new URL(window.location.href);
- // Supabase sends token via query params (not hash) on redirect
- const accessToken = url.searchParams.get("token") || url.searchParams.get("access_token");
- const type = url.searchParams.get("type");
- const error = url.searchParams.get("error");
-
- if (error) {
- router.replace(`/login?error=${error}`);
- return;
- }
-
- if (!accessToken) {
- router.replace("/login?error=no_token");
- return;
- }
-
- // Validate token by fetching user info from Supabase
- fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/user`, {
- headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
- Authorization: `Bearer ${accessToken}`,
- },
- })
- .then(r => r.json())
- .then(data => {
- if (data?.id) {
- // Set rc_auth_uid cookie via API route
- return fetch("/api/set-auth-cookie", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId: data.id }),
- });
- }
- throw new Error("No user ID in response");
- })
- .then(() => router.replace("/admin"))
- .catch(() => router.replace("/login?error=token_invalid"));
- }, [router]);
-
- return (
-
-
Verifying reset link...
-
- );
-}
\ No newline at end of file
diff --git a/src/app/change-password/page.tsx b/src/app/change-password/page.tsx
index ea8a5f4..b7a29f3 100644
--- a/src/app/change-password/page.tsx
+++ b/src/app/change-password/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { updatePasswordAction } from "@/actions/admin/password";
@@ -12,22 +12,6 @@ export default function ChangePasswordPage() {
const [confirm, setConfirm] = useState("");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
- const [checkingSession, setCheckingSession] = useState(true);
- const [userId, setUserId] = useState(null);
-
- useEffect(() => {
- fetch("/api/auth/uid")
- .then((r) => r.json())
- .then((data) => {
- if (!data.uid) {
- router.push("/login");
- } else {
- setUserId(data.uid);
- setCheckingSession(false);
- }
- })
- .catch(() => router.push("/login"));
- }, [router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -51,30 +35,17 @@ export default function ChangePasswordPage() {
return;
}
- if (userId) {
- logUserActivity({
- user_id: userId,
- activity_type: "password_change",
- details: {},
- });
- }
+ // Audit log (best-effort — the password change itself was the source of truth)
+ logUserActivity({
+ user_id: result.userId ?? "unknown",
+ activity_type: "password_change",
+ details: {},
+ }).catch(() => {});
router.push("/admin");
router.refresh();
}
- if (checkingSession) {
- return (
-
-
-
- );
- }
-
return (
@@ -147,4 +118,4 @@ export default function ChangePasswordPage() {
);
-}
\ No newline at end of file
+}
diff --git a/src/app/dev-login/page.tsx b/src/app/dev-login/page.tsx
deleted file mode 100644
index bb85b10..0000000
--- a/src/app/dev-login/page.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-"use client";
-
-export default function DevLoginPage() {
- return (
-
-
-
Dev Login
-
Click below to login as platform admin:
-
-
-
- );
-}
\ No newline at end of file
diff --git a/src/app/login/LoginClient.tsx b/src/app/login/LoginClient.tsx
index 75c7889..1f3f693 100644
--- a/src/app/login/LoginClient.tsx
+++ b/src/app/login/LoginClient.tsx
@@ -1,15 +1,17 @@
"use client";
-import { useState, useEffect, useCallback, Suspense } from "react";
+import { useState, useCallback, Suspense, useEffect } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
+import {
+ signInWithPassword,
+ signInWithGoogle,
+ type SignInResult,
+} from "@/actions/auth-actions";
function LoginForm() {
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
- const [globalError, setGlobalError] = useState(null);
- const [loading, setLoading] = useState(false);
- const [showPassword, setShowPassword] = useState(false);
+ const [result, setResult] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
const [forgotPassword, setForgotPassword] = useState(false);
const [forgotEmail, setForgotEmail] = useState("");
const [forgotSent, setForgotSent] = useState(false);
@@ -22,49 +24,48 @@ function LoginForm() {
setMounted(true);
}, []);
- const handleSubmit = useCallback(async (e: React.FormEvent) => {
- e.preventDefault();
- setGlobalError(null);
- if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
- if (!password.trim()) { setGlobalError("Please enter your password."); return; }
- setLoading(true);
- try {
- const res = await fetch("/api/login", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email: email.trim(), password }),
- });
- const data = await res.json().catch(() => ({ error: "Login failed" }));
- if (res.ok && data?.ok) {
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSubmitting(true);
+ setResult(null);
+ const fd = new FormData(e.currentTarget);
+ const r = await signInWithPassword(null, fd);
+ setResult(r);
+ setSubmitting(false);
+ if (r.ok) {
+ // Server action succeeded; navigate to /admin
window.location.replace("/admin");
- } else {
- setGlobalError(data?.error || `Login failed (${res.status})`);
}
- } catch {
- setGlobalError("Network error. Please try again.");
- } finally {
- setLoading(false);
- }
- }, [email, password]);
+ },
+ []
+ );
- const handleForgotPassword = useCallback(async (e: React.FormEvent) => {
- e.preventDefault();
- if (!forgotEmail.trim()) return;
- setForgotLoading(true);
- setForgotError(null);
- const fd = new FormData();
- fd.set("email", forgotEmail.trim());
- const result = await fetch("/api/forgot-password", {
- method: "POST",
- body: fd,
- }).then(r => r.json()).catch(() => ({ error: "Network error" }));
- setForgotLoading(false);
- if (result.error) {
- setForgotError(result.error);
- } else {
- setForgotSent(true);
- }
- }, [forgotEmail]);
+ const handleForgotPassword = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!forgotEmail.trim()) return;
+ setForgotLoading(true);
+ setForgotError(null);
+ const fd = new FormData();
+ fd.set("email", forgotEmail.trim());
+ const r = await fetch("/api/forgot-password", {
+ method: "POST",
+ body: fd,
+ })
+ .then((r) => r.json())
+ .catch(() => ({ error: "Network error" }));
+ setForgotLoading(false);
+ if (r.error) {
+ setForgotError(r.error);
+ } else {
+ setForgotSent(true);
+ }
+ },
+ [forgotEmail]
+ );
+
+ const globalError = result && !result.ok ? result.error : null;
return (
@@ -124,6 +125,32 @@ function LoginForm() {
+ {/* Google sign-in (primary) */}
+
+
+ {/* Divider */}
+
+