diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..80019c6 --- /dev/null +++ b/.env.example @@ -0,0 +1,82 @@ +# ============================================================================ +# Route Commerce — Environment variables +# ============================================================================ +# Copy to `.env.local` and fill in real values for local development. +# Production: set these in your hosting dashboard (Vercel / Netlify / etc.). +# ============================================================================ + +# ── App ───────────────────────────────────────────────────────────────────── +NEXT_PUBLIC_BASE_URL=http://localhost:4000 +NEXT_PUBLIC_SITE_URL=http://localhost:4000 + +# ── Database (Postgres, direct — Supabase is being removed) ──────────────── +# Single connection string used by `pg.Pool` in src/lib/auth.ts and the +# admin-permissions / data-service layer. Format: +# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce + +# ── Auth.js (NextAuth v5) ─────────────────────────────────────────────────── +# Generate with: npx auth secret +# Or: openssl rand -base64 32 +AUTH_SECRET=replace-me-with-a-32-byte-base64-string + +# Base URL used to build OAuth callback URLs. In dev: +AUTH_URL=http://localhost:4000 +# In production, set to https://yourdomain.com + +# Google OAuth provider. +# 1. Go to https://console.cloud.google.com/apis/credentials +# 2. Create an OAuth 2.0 Client ID (type: Web application) +# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google +# 4. Copy client id + client secret below +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the +# default NextAuth variable names. The code in src/auth.config.ts falls +# back to those names if GOOGLE_CLIENT_ID is unset. + +# Set to "false" to disable the in-app dev credentials provider even in +# development. Default: enabled in dev only. +ALLOW_DEV_LOGIN=true + +# ── Supabase (legacy, being removed) ──────────────────────────────────────── +# Still used by the existing admin pages, server actions, and the +# `getAdminUser` flow. Once the auth migration is complete and the +# @supabase/* packages are removed, these can go away. +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# ── Stripe ───────────────────────────────────────────────────────────────── +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +STRIPE_PRICE_STARTER= +STRIPE_PRICE_FARM= +STRIPE_PRICE_ENTERPRISE= +STRIPE_PRICE_HARVEST_REACH= +STRIPE_PRICE_WHOLESALE_PORTAL= +STRIPE_PRICE_WATER_LOG= +STRIPE_PRICE_AI_TOOLS= +STRIPE_PRICE_SQUARE_SYNC= +STRIPE_PRICE_SMS_CAMPAIGNS= + +# ── Resend (transactional email) ──────────────────────────────────────────── +RESEND_API_KEY= +RESEND_WEBHOOK_SECRET= + +# ── AI providers ──────────────────────────────────────────────────────────── +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GOOGLE_API_KEY= +XAI_API_KEY= +MINIMAX_API_KEY= +MINIMAX_BASE_URL=https://api.minimax.io/v1 + +# ── Square (optional) ─────────────────────────────────────────────────────── +SQUARE_APP_SECRET= +SQUARE_ENVIRONMENT=sandbox + +# ── Cron / automation ─────────────────────────────────────────────────────── +CRON_SECRET=replace-me-with-a-random-string diff --git a/CLAUDE.md b/CLAUDE.md index f4e9f3e..69ba6b7 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. --- @@ -41,10 +41,24 @@ No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e. - `dev_session=brand_admin` — full access to assigned brand only - `dev_session=store_employee` — limited access (orders, pickup, wholesale only) -`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. +`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 the legacy `rc_auth_uid` cookie in production (set by the pre-Auth.js `/api/login`) — never import this file directly into Client Components. Use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead. + +The middleware (`src/middleware.ts`) guards `/admin/:path*` and `/login`. It auto-issues a `dev_session=platform_admin` cookie for the demo flow when no auth is present. A `clerk-auth.ts` helper exists in `src/lib/` but is currently a stub — do not depend on it. The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary. +#### Auth.js (NextAuth v5) migration — in progress + +The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight: + +- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead. +- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list. +- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints. +- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos. +- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change. +- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it. +- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior. + ### Server Actions Pattern All database writes go through server actions in `src/actions/`. These: @@ -55,9 +69,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 +206,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 +233,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 +250,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 +271,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/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 f91f6b6..99c5f69 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0", + "dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000", "build": "next build --webpack", "start": "next start", "lint": "eslint", @@ -15,6 +15,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.96.0", + "@auth/pg-adapter": "^1.11.2", "@clerk/nextjs": "^7.4.2", "@google/generative-ai": "^0.24.1", "@gsap/react": "^2.1.2", @@ -30,6 +31,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", @@ -50,6 +52,7 @@ "@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", diff --git a/src/actions/auth-signin.ts b/src/actions/auth-signin.ts new file mode 100644 index 0000000..88ed86b --- /dev/null +++ b/src/actions/auth-signin.ts @@ -0,0 +1,56 @@ +"use server"; + +import { signIn, signOut } from "@/lib/auth"; +import { AuthError } from "next-auth"; + +/** + * Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for + * use from client components. + * + * Why server actions? + * • The Auth.js v5 `signIn` function has to run on the server (it + * needs to set the session cookie, talk to the database adapter, + * and redirect the user to the OAuth provider). + * • Calling it from a client component via a server action keeps the + * client bundle small and avoids exposing the OAuth client secret. + * + * Usage from a client component: + *
+ * + *
+ * + * Usage for the dev credentials provider (dev only): + *
+ * + * + * + *
+ */ + +export async function signInWithGoogle(): Promise { + await signIn("google", { redirectTo: "/admin" }); +} + +export async function signInWithDev(formData: FormData): Promise { + const username = String(formData.get("username") ?? "admin"); + const password = String(formData.get("password") ?? "dev"); + try { + await signIn("dev-login", { + username, + password, + redirectTo: "/admin", + }); + } catch (e) { + // signIn() throws a `NEXT_REDIRECT` to navigate — let that through + // so the redirect actually happens. Re-throw any other error so the + // caller can render a meaningful message. + if (e instanceof AuthError) { + throw new Error(`Dev sign-in failed: ${e.type}`); + } + throw e; + } +} + +export async function signOutAction(): Promise { + await signOut({ redirectTo: "/login" }); +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..93eb609 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,16 @@ +import { handlers } from "@/lib/auth"; + +/** + * Auth.js v5 catch-all route handler. Exposes: + * GET /api/auth/signin + * GET /api/auth/signout + * GET /api/auth/session + * GET /api/auth/csrf + * GET /api/auth/providers + * POST /api/auth/callback/:provider + * POST /api/auth/signin/:provider + * POST /api/auth/signout + * + * The actual OAuth + session logic is in `src/lib/auth.ts`. + */ +export const { GET, POST } = handlers; diff --git a/src/app/login/LoginClient.tsx b/src/app/login/LoginClient.tsx index 75c7889..e08fbe7 100644 --- a/src/app/login/LoginClient.tsx +++ b/src/app/login/LoginClient.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, Suspense } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; +import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin"; function LoginForm() { const [email, setEmail] = useState(""); @@ -124,6 +125,82 @@ function LoginForm() {

+ {/* Auth.js v5 — primary sign-in: Google OAuth */} +
+ +
+ + {/* Dev login (only visible in development) */} + {process.env.NODE_ENV !== "production" && ( +
+
+ Dev login — only available in development. + Set ALLOW_DEV_LOGIN=false in .env.local to hide. +
+
+ + +
+ +
+ )} + +
+ +
{globalError && (
diff --git a/src/app/protected-example/page.tsx b/src/app/protected-example/page.tsx new file mode 100644 index 0000000..57a852c --- /dev/null +++ b/src/app/protected-example/page.tsx @@ -0,0 +1,124 @@ +import { auth, signOut } from "@/lib/auth"; + +/** + * /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 + * 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` + */ +export default async function ProtectedExamplePage() { + const session = await auth(); + + // Defensive: middleware should have already redirected. Render a + // friendly hint if we ever reach here unauthenticated. + if (!session?.user) { + return ( +
+
+

+ Not signed in +

+

+ You should have been redirected to{" "} + + /login + + . If you can see this, the middleware matcher needs adjusting. +

+
+
+ ); + } + + 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 ( +
+
+
+

+ Protected example +

+

+ You are signed in. This page is guarded by the Auth.js + middleware in middleware.ts. +

+
+ +
+

+ Session +

+
+
+
Name
+
+ {user.name ?? "(none)"} +
+
+
+
Email
+
+ {user.email ?? "(none)"} +
+
+
+
User id
+
+ {(user as { id?: string }).id ?? "(none)"} +
+
+
+
Session expires
+
{expires}
+
+
+
+ +
+

+ Try it +

+

+ Use the form below to sign out, or navigate to{" "} + + /admin + {" "} + (the same session is shared). +

+ + { + "use server"; + await signOut({ redirectTo: "/login" }); + }} + className="mt-4" + > + + +
+
+
+ ); +} diff --git a/src/auth.config.ts b/src/auth.config.ts new file mode 100644 index 0000000..c737ccd --- /dev/null +++ b/src/auth.config.ts @@ -0,0 +1,105 @@ +import type { NextAuthConfig } from "next-auth"; +import Google from "next-auth/providers/google"; + +/** + * Edge-compatible Auth.js v5 configuration. + * + * This file is imported by `src/middleware.ts`, which runs in the Edge runtime. + * It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib) + * or any other Node-only module. Database wiring lives in `src/lib/auth.ts`. + * + * If you need to add a provider that uses Node-only APIs (e.g. an adapter + * implementation), define it in `src/lib/auth.ts` instead and add a thin + * placeholder here so the middleware can still reference it. + */ +const isDev = process.env.NODE_ENV !== "production"; +const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev + +export const authConfig = { + // Custom sign-in page (must exist at /login) + pages: { + signIn: "/login", + }, + + // Trust the host header in dev for callback URLs + trustHost: true, + + // Providers — referenced from middleware edge runtime. + // The Google provider only needs the env vars at runtime; it does not pull + // in any Node-only code. The dev Credentials provider is added in + // `src/lib/auth.ts` (server-side only) — it's not safe to import + // `next-auth/providers/credentials` from the edge runtime. + providers: [ + Google({ + clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID, + clientSecret: + process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET, + // No `authorization` override — we want the default scopes (openid email profile) + }), + ], + + // New users are persisted in the database (handled in src/lib/auth.ts) + // Default to JWT here so middleware can run in edge runtime; the full + // server-side handler in src/lib/auth.ts switches this to "database". + session: { strategy: "jwt" }, + + callbacks: { + /** + * Gate /admin routes. Anything not on the public list and not signed in + * gets redirected to /login. This mirrors what the page-level checks do, + * but runs first at the edge so unauthorized requests never hit the + * server component tree. + */ + authorized({ auth, request: { nextUrl } }) { + const isLoggedIn = !!auth?.user; + const isOnAdmin = nextUrl.pathname.startsWith("/admin"); + const isOnProtectedExample = nextUrl.pathname.startsWith( + "/protected-example" + ); + + if (isOnAdmin) { + if (isLoggedIn) return true; + return false; // Redirect to /login + } + + if (isOnProtectedExample) { + if (isLoggedIn) return true; + return false; + } + + return true; + }, + + /** + * Forward the user id from the database user record into the JWT on + * initial sign-in. With database sessions this is what populates + * `session.user.id` for downstream server actions. + */ + async jwt({ token, user }) { + if (user) { + token.id = (user as { id?: string }).id ?? token.sub; + } + return token; + }, + + async session({ session, token }) { + if (session.user && token?.sub) { + (session.user as { id?: string }).id = token.sub; + } + return session; + }, + }, + + // Cookie config — keep default names so legacy `rc_auth_uid` consumers + // continue to work until they're migrated. New Auth.js cookies default to + // `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod). +} satisfies NextAuthConfig; + +/** + * Helper: are we in development AND allowed to use the dev credentials + * provider? Exposed so server-side `src/lib/auth.ts` can decide whether to + * include the provider in its provider list. + */ +export function isDevLoginEnabled(): boolean { + return isDev && allowDevLogin; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..88d143a --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,136 @@ +import NextAuth from "next-auth"; +import PostgresAdapter from "@auth/pg-adapter"; +import { Pool } from "pg"; +import Credentials from "next-auth/providers/credentials"; +import { + authConfig, + isDevLoginEnabled, +} from "@/auth.config"; + +/** + * Build the dev Credentials provider. Lives here (Node-only) because + * `next-auth/providers/credentials` cannot be loaded in the edge runtime + * that the middleware uses. + */ +function buildDevCredentialsProvider() { + return Credentials({ + id: "dev-login", + name: "Dev login", + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + }, + async authorize(creds) { + if (!isDevLoginEnabled()) return null; + // Any non-empty username/password combo is accepted; this is purely a + // local convenience for smoke testing without Google OAuth. + const username = String(creds?.username ?? "").trim(); + const password = String(creds?.password ?? ""); + if (!username || !password) return null; + + return { + id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`, + name: username, + email: `${username}@dev.local`, + // Custom field surfaced via `jwt` callback if needed + devRole: "platform_admin", + } as unknown as { id: string; name: string; email: string }; + }, + }); +} + +/** + * Shared Postgres pool for Auth.js. Reuses the same database the rest of + * the app talks to (via `pg`). Lives behind a module-level singleton so + * Next.js hot reload doesn't open a new pool on every request. + * + * Note: in production, `DATABASE_URL` should be the only DB env var. The + * Supabase project URL / service role key are no longer required for auth + * (they are still used elsewhere until the rest of the app is migrated off + * the @supabase client — see CLAUDE.md). + */ +const globalForPool = globalThis as unknown as { __pgPool?: Pool }; + +function getPool(): Pool { + if (globalForPool.__pgPool) return globalForPool.__pgPool; + + const connectionString = + process.env.DATABASE_URL ?? + process.env.SUPABASE_DB_URL ?? + process.env.POSTGRES_URL; + + if (!connectionString) { + // Don't throw at module load — let route handlers return a clean 500 + // if env is missing. The smoke test instructions tell the user to + // set DATABASE_URL. + // eslint-disable-next-line no-console + console.warn( + "[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up." + ); + } + + const pool = new Pool({ + connectionString, + // Reasonable defaults; override via connection string if you need more + max: 10, + idleTimeoutMillis: 30_000, + }); + globalForPool.__pgPool = pool; + return pool; +} + +/** + * Final server-side Auth.js config. + * + * Builds on `authConfig` (edge-safe) and layers on: + * 1. The Postgres database adapter + * 2. The dev Credentials provider (only in development) + * + * Note: when using a database adapter the session strategy is fixed to + * "database" — Auth.js will persist sessions in the `sessions` table. + */ +export const { handlers, auth, signIn, signOut } = NextAuth({ + ...authConfig, + // Use JWT sessions to match the edge-friendly config in `authConfig`. + // The middleware (running on the edge) cannot reach the database, so it + // must use JWT. The Postgres adapter is still wired up so that user + // records are created/updated when a new OAuth sign-in happens — but + // the session itself is stored in the cookie as an encrypted JWT. + adapter: PostgresAdapter(getPool()), + // `session.strategy` is inherited from `authConfig` ("jwt") + providers: [ + // Re-declare the providers from authConfig and append the dev + // credentials provider if dev login is enabled. (NextAuth merges by + // provider id, so this overrides the edge stubs.) + ...authConfig.providers, + ...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []), + ], + events: { + /** + * First-time sign-in: auto-create a `platform_admin` row in + * `admin_users` keyed to this auth.js user id, mirroring the legacy + * `rc_auth_uid` flow. This is the seam between the new auth layer + * and the existing admin authorization model. + */ + async signIn({ user }) { + try { + const pool = getPool(); + const userId = user.id; + if (!userId) return; + // Fire and forget — don't block sign-in on a missing admin_users row. + await pool.query( + `SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`, + [userId] + ); + // Note: we don't auto-create here; the existing `getAdminUser()` + // in `src/lib/admin-permissions.ts` is the source of truth for + // role lookups and is unchanged. After this migration the user + // is authenticated; the existing `dev_session` demo path still + // works for the smoke test. + } catch (e) { + // eslint-disable-next-line no-console + console.warn("[auth] signIn event error (non-fatal):", e); + } + }, + }, +}); diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index e8f0cc1..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Supabase Auth Middleware - keeps existing auth working - -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - -// Public routes that don't require authentication -const publicRoutes = [ - "/", - "/login", - "/login2", - "/register", - "/forgot-password", - "/reset-password", - "/pricing", - "/terms-and-conditions", - "/privacy-policy", - "/contact", - "/api/health", - "/api/stripe/webhook", - "/api/resend/webhook", - // Brand storefronts are public - "/tuxedo", - "/tuxedo/*", - "/indian-river-direct", - "/indian-river-direct/*", - "/cart", - "/cart/*", - "/checkout", - "/checkout/*", - // Error pages - "/error", - "/not-found", -]; - -// Admin routes that require auth -const adminRoutes = ["/admin", "/water/admin"]; - -// Wholesale routes -const wholesaleRoutes = ["/wholesale"]; - -export async function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - - // Check if route is public - const isPublicRoute = publicRoutes.some( - (route) => pathname === route || pathname.startsWith(route.replace("/*", "")) - ); - - if (isPublicRoute) { - return NextResponse.next(); - } - - // Check for auth cookie (Supabase session) - const hasAuthCookie = - request.cookies.get("rc_auth_uid")?.value || - request.cookies.get("rc_uid")?.value || - request.cookies.get("dev_session")?.value; - - if (!hasAuthCookie) { - // Redirect to login - const loginUrl = new URL("/login", request.url); - loginUrl.searchParams.set("redirect", pathname); - return NextResponse.redirect(loginUrl); - } - - // Check for admin routes (may need additional role checking) - const isAdminRoute = adminRoutes.some((route) => pathname.startsWith(route)); - if (isAdminRoute) { - // Dev session check for role - const devSession = request.cookies.get("dev_session")?.value; - if (devSession === "store_employee") { - // Store employees have limited admin access - // More granular checks happen in the page components - } - } - - // Add security headers to all responses - const response = NextResponse.next(); - - response.headers.set("X-Content-Type-Options", "nosniff"); - response.headers.set("X-Frame-Options", "DENY"); - response.headers.set("X-XSS-Protection", "1; mode=block"); - response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - - return response; -} - -export const config = { - matcher: [ - // Skip Next.js internals and all files in the _next directory - "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", - ], -}; \ No newline at end of file diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..9d36a26 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,26 @@ +import NextAuth from "next-auth"; +import { authConfig } from "@/auth.config"; + +/** + * Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16). + * This is the single source of truth for route protection. The legacy + * `src/middleware.ts` has been deleted (Next.js only runs one). + * + * Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`? + * 1. Auth.js v5 ships an `authorized` callback in `authConfig` that + * knows which routes need a session. We reuse it here at the edge. + * 2. It auto-populates `request.auth` with the session (JWT-decoded) + * for any server component/page that reads `auth()` later. + * + * Public routes, admin gating, and the `auth` cookie are all configured + * in `src/auth.config.ts`. + */ +const { auth } = NextAuth(authConfig); + +export default auth; + +export const config = { + // Run on /admin and the protected example, plus /login so the + // `authorized` callback can bounce already-signed-in users away from it. + matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"], +}; diff --git a/supabase/migrations/204_authjs_tables.sql b/supabase/migrations/204_authjs_tables.sql new file mode 100644 index 0000000..7bb827b --- /dev/null +++ b/supabase/migrations/204_authjs_tables.sql @@ -0,0 +1,73 @@ +-- ============================================================ +-- Auth.js (NextAuth v5) tables +-- ============================================================ +-- Schema expected by @auth/pg-adapter. +-- Reference: https://authjs.dev/getting-started/adapters/pg +-- +-- Column names are kept as the adapter expects them (case-sensitive, +-- camelCase, quoted). Do NOT rename these without also updating the +-- adapter code in node_modules/@auth/pg-adapter. +-- +-- We use UUIDs for user ids (consistent with the rest of the platform) +-- rather than SERIAL. The adapter doesn't care what type `id` is — it +-- just passes the value through. +-- ============================================================ + +-- ── users ───────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT, + email TEXT UNIQUE, + "emailVerified" TIMESTAMPTZ, + image TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── accounts ───────────────────────────────────────────────── +-- One row per (provider, providerAccountId). Links external OAuth +-- accounts to a local user. +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + provider TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + refresh_token TEXT, + access_token TEXT, + expires_at BIGINT, + id_token TEXT, + scope TEXT, + session_state TEXT, + token_type TEXT, + UNIQUE (provider, "providerAccountId") +); + +CREATE INDEX IF NOT EXISTS accounts_userid_idx ON accounts ("userId"); + +-- ── sessions ───────────────────────────────────────────────── +-- One row per active session. With database strategy enabled, the +-- session token is stored here and looked up on every request. +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires TIMESTAMPTZ NOT NULL, + "sessionToken" TEXT NOT NULL UNIQUE +); + +CREATE INDEX IF NOT EXISTS sessions_userid_idx ON sessions ("userId"); +CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions (expires); + +-- ── verification_token ────────────────────────────────────── +-- Used for email magic-link / passwordless flows. Not used by the +-- Google provider, but the adapter still references the table. +CREATE TABLE IF NOT EXISTS verification_token ( + identifier TEXT NOT NULL, + expires TIMESTAMPTZ NOT NULL, + token TEXT NOT NULL, + PRIMARY KEY (identifier, token) +); + +-- ── Grant access to the pg pool used by the Auth.js adapter ── +-- (No-op if you're connecting as the table owner; included for +-- completeness in case a separate app role is used.) +-- GRANT SELECT, INSERT, UPDATE, DELETE ON users, accounts, sessions, verification_token TO authenticator;