feat(db+auth): add pg pool, admin_users email/provider migration, refactor auth lookup
- Create src/lib/db.ts (shared pg.Pool, query/withTx helpers, server-only) - Add @types/pg devDep - Migration 204: add email, auth_provider, auth_subject columns to admin_users; backfill from auth.users; new upsert_admin_user accepts multi-provider args; new get_admin_user_for_session RPC resolves Auth.js session id (UUID or Google sub) to an admin row - Refactor getAdminUser() to use pg + new RPC; auto-provisions on first Google sign-in using session.user.email - Refactor updatePasswordAction to call update_user_password via pg (drops the Supabase REST hop) - Delete orphaned src/actions/login.ts (replaced by auth-actions.ts) - Drop remaining DEV_FORCE_UID references in users.ts; dev path now uses dev_session cookie (the only cookie the dev flow can set) - Update AdminUser type: user_id is now string | null (Google users have no Supabase auth id); add email + auth_provider fields - Fix downstream type errors: StopProductAssignment.callerUid accepts null; pickup.ts performedBy widens to string | null - Bump vitest config, tests/, and other earlier cleanup changes
This commit is contained in:
@@ -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.
|
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)
|
npx playwright test # Run E2E tests (Playwright)
|
||||||
```
|
```
|
||||||
|
|
||||||
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
|
> 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.
|
||||||
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||||
> 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.
|
|
||||||
|
|
||||||
**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.
|
**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
|
### 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=platform_admin` — full access, all brands
|
||||||
- `dev_session=brand_admin` — full access to assigned brand only
|
- `dev_session=brand_admin` — full access to assigned brand only
|
||||||
- `dev_session=store_employee` — limited access (orders, pickup, wholesale 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.<uid>` — 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.
|
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
|
### Server Actions Pattern
|
||||||
|
|
||||||
All database writes go through server actions in `src/actions/`. These:
|
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.
|
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
|
- 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
|
- 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")
|
### 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`.
|
`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
|
### Payments
|
||||||
|
|
||||||
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
|
- **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
|
## 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
|
- `gen_random_uuid()` used in migrations for primary keys
|
||||||
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
||||||
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
- 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` |
|
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
|
||||||
| Middleware (route protection) | `src/middleware.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 pages | `src/app/admin/[module]/page.tsx` |
|
||||||
| Admin client components | `src/components/admin/*.tsx` |
|
| Admin client components | `src/components/admin/*.tsx` |
|
||||||
| Migrations | `supabase/migrations/` |
|
| Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
|
||||||
| Supabase client | `src/lib/supabase.ts` |
|
| Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
|
||||||
| Email templates | `src/lib/email-templates.ts` |
|
| Email templates | `src/lib/email-templates.ts` |
|
||||||
| Date formatting | `src/lib/format-date.ts` |
|
| Date formatting | `src/lib/format-date.ts` |
|
||||||
| Feature flags | `src/lib/feature-flags.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
|
## 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.
|
- **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.
|
- **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.
|
- **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`.
|
- **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`.
|
||||||
@@ -2,11 +2,40 @@
|
|||||||
|
|
||||||
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
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)
|
### Login + Link (done in this session)
|
||||||
- User ran `supabase login`
|
- User ran `supabase login`
|
||||||
@@ -35,15 +64,23 @@ Key changes:
|
|||||||
- Falls back to direct `pg` only if CLI path fails.
|
- Falls back to direct `pg` only if CLI path fails.
|
||||||
- Header comments updated with current recommended workflow.
|
- 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
|
```bash
|
||||||
|
# Supabase CLI path (legacy — do not use going forward)
|
||||||
supabase login
|
supabase login
|
||||||
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
||||||
node supabase/push-migrations.js 148 # or any prefix
|
node supabase/push-migrations.js 148 # CLI path
|
||||||
# or
|
# or
|
||||||
npm run migrate:one 148
|
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).
|
`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.
|
- 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.
|
||||||
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
|
- 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.)
|
||||||
- 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.
|
- `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.
|
||||||
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
|
- 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.
|
- 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
|
## How to Use This Memory
|
||||||
|
|
||||||
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
|
- 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.
|
- Update this file with new key facts, applied migrations, or new gotchas.
|
||||||
- Feel free to add dated sections.
|
- Feel free to add dated sections.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
+12
-2
@@ -11,11 +11,15 @@
|
|||||||
"migrate": "node supabase/push-migrations.js",
|
"migrate": "node supabase/push-migrations.js",
|
||||||
"migrate:one": "node supabase/push-migrations.js",
|
"migrate:one": "node supabase/push-migrations.js",
|
||||||
"type-check": "npx tsc --noEmit",
|
"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}\""
|
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.96.0",
|
"@anthropic-ai/sdk": "^0.96.0",
|
||||||
"@clerk/nextjs": "^7.4.2",
|
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
@@ -28,6 +32,7 @@
|
|||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.37.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
@@ -48,17 +53,22 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.5",
|
"eslint-config-next": "16.2.5",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"overrides": "{}"
|
"overrides": "{}"
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -1,6 +1,9 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
import path from "path";
|
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({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
@@ -9,16 +12,19 @@ export default defineConfig({
|
|||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: "list",
|
reporter: "list",
|
||||||
use: {
|
use: {
|
||||||
baseURL: "https://route-commerce-platform.vercel.app",
|
baseURL: LOCAL_BASE,
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
|
{
|
||||||
|
name: "local",
|
||||||
|
use: { ...devices["Desktop Chrome"], baseURL: LOCAL_BASE },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "production",
|
name: "production",
|
||||||
use: {
|
// `PLAYWRIGHT_PROD=1 npx playwright test` to run against the live site.
|
||||||
...devices["Desktop Chrome"],
|
testMatch: /.*\.prod\.spec\.ts$/,
|
||||||
baseURL: "https://route-commerce-platform.vercel.app",
|
use: { ...devices["Desktop Chrome"], baseURL: PROD_BASE },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,48 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { auth } from "@/lib/auth";
|
||||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
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(
|
export async function updatePasswordAction(
|
||||||
newPassword: string
|
newPassword: string
|
||||||
): Promise<{ error?: string }> {
|
): Promise<{ error?: string; userId?: string }> {
|
||||||
const cookieStore = await cookies();
|
const session = await auth();
|
||||||
const uid =
|
const uid = session?.user?.id;
|
||||||
cookieStore.get("rc_auth_uid")?.value ??
|
|
||||||
cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
return { error: "Not authenticated. Please log in again." };
|
return { error: "Not authenticated. Please log in again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = createServiceClient(
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
if (!UUID_REGEX.test(uid)) {
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
return {
|
||||||
);
|
error:
|
||||||
|
"Password change is not available for social sign-in accounts. Please contact an admin.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await service.rpc("update_user_password", {
|
try {
|
||||||
p_user_id: uid,
|
// The RPC is SECURITY DEFINER and returns a single row (or raises).
|
||||||
p_password: newPassword,
|
// 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]);
|
||||||
if (error) return { error: error.message };
|
return { userId: uid };
|
||||||
return {};
|
} catch (err) {
|
||||||
}
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to update password.";
|
||||||
|
return { error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+46
-44
@@ -79,19 +79,11 @@ export type UpdateAdminUserInput = {
|
|||||||
|
|
||||||
async function getAuthClient() {
|
async function getAuthClient() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const headerStore = await headers();
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
|
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
|
||||||
const response = NextResponse.next({ request });
|
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, {
|
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() { return cookieStore.getAll(); },
|
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<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
|
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): 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
|
// Dev mode bypass — let the action proceed without Supabase auth.
|
||||||
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
// (Pre-Auth.js this was gated on the legacy `rc_auth_uid === DEV_FORCE_UID`
|
||||||
if (rcAuthUid === DEV_FORCE_UID) {
|
// cookie that the now-deleted `/api/force-admin` route set. With Auth.js v5
|
||||||
return { data: null, error: null }; // let the action proceed without auth check
|
// 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();
|
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||||
@@ -368,8 +364,6 @@ function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { i
|
|||||||
|
|
||||||
// ─── Production admin actions (require real Supabase auth) ─────────────────
|
// ─── 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 }> {
|
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||||
if (useMockData) {
|
if (useMockData) {
|
||||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
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())
|
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
.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.
|
// In development mode: dev_session cookie holders use the dev/service path.
|
||||||
// This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation.
|
// (The previous code also accepted a legacy `rc_auth_uid` cookie set by
|
||||||
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
|
// `/api/dev-login` — `/api/dev-login` still sets both cookies, so existing
|
||||||
return devListAdminUsers(rcAuthUid);
|
// 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
|
// Dev session cookie (platform_admin/brand_admin) — always use service role path
|
||||||
const isDevAdmin = process.env.NODE_ENV !== "production" && (
|
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) {
|
if (isDevAdmin) {
|
||||||
return devListAdminUsers(rcAuthUid ?? undefined);
|
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 }> {
|
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||||
// Read auth context
|
// Read auth context
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const headerStore = await headers();
|
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
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
|
// TODO: when the Auth.js v5 migration lands everywhere, replace this
|
||||||
if (rcAuthUid === DEV_FORCE_UID) {
|
// cookie-based check with a session check via `await auth()` from `@/lib/auth`.
|
||||||
return devCreateAdminUser(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
|
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);
|
return devCreateAdminUser(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production path — use service role directly (bypasses Supabase JWT auth)
|
// Production path — service role creates the account. The caller is
|
||||||
// rc_auth_uid cookie proves the admin is logged in; service role creates the account
|
// 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) {
|
if (rcAuthUid) {
|
||||||
const service = getServiceClient();
|
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 }> {
|
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||||
// Dev bypass check
|
// Dev bypass check
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const headerStore = await headers();
|
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
const devSession = cookieStore.get("dev_session")?.value;
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
// Dev mode — let the action proceed with the service role.
|
||||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
// (The previous code also accepted a legacy `rc_auth_uid === DEV_FORCE_UID`
|
||||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
// cookie set by the now-deleted Emergency Force Login page. With Auth.js v5
|
||||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
// and the demo buttons in /login, the `dev_session` cookie is sufficient.)
|
||||||
if (rcAuthUid === DEV_FORCE_UID) {
|
if (process.env.NODE_ENV !== "production" && devSession) {
|
||||||
const service = getServiceClient();
|
const service = getServiceClient();
|
||||||
const { data, error } = await service
|
const { data, error } = await service
|
||||||
.from("admin_users")
|
.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 }> {
|
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
// Dev bypass check
|
// Dev bypass check
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const headerStore = await headers();
|
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
const devSession = cookieStore.get("dev_session")?.value;
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
// Dev mode — let the action proceed with the service role.
|
||||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
// (The previous code gated on `rc_auth_uid === DEV_FORCE_UID`, a magic
|
||||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
// cookie value the now-deleted Emergency Force Login page set. With
|
||||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
// Auth.js v5 and the demo buttons in /login, the `dev_session` cookie is
|
||||||
if (rcAuthUid === DEV_FORCE_UID) {
|
// the source of truth for the dev path.)
|
||||||
|
if (process.env.NODE_ENV !== "production" && devSession) {
|
||||||
const service = getServiceClient();
|
const service = getServiceClient();
|
||||||
// Get user_id first
|
// Get user_id first
|
||||||
const { data: adminRow, error: fetchError } = await service
|
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())
|
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
.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 service = getServiceClient();
|
||||||
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
||||||
return { success: !error, error: error?.message ?? null };
|
return { success: !error, error: error?.message ?? null };
|
||||||
|
|||||||
@@ -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<SignInResult> {
|
||||||
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
|
||||||
|
if (!email) return { ok: false, error: "Please enter your email address." };
|
||||||
|
if (!password) return { ok: false, error: "Please enter your password." };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn("supabase-password", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AuthError) {
|
||||||
|
return { ok: false, error: "Invalid email or password." };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off the Google OAuth flow. Auth.js will redirect to Google's consent
|
||||||
|
* screen and then back to /api/auth/callback/google, which sets the session
|
||||||
|
* cookie and redirects to the configured callback URL.
|
||||||
|
*/
|
||||||
|
export async function signInWithGoogle(): Promise<void> {
|
||||||
|
await signIn("google", { redirectTo: "/admin" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out and clear the Auth.js session cookie.
|
||||||
|
*/
|
||||||
|
export async function signOutAction(): Promise<void> {
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
}
|
||||||
@@ -271,25 +271,36 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
|
|||||||
|
|
||||||
// Public version for storefront pages — uses slug, no auth required
|
// Public version for storefront pages — uses slug, no auth required
|
||||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
const response = await fetch(
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
|
||||||
{
|
}
|
||||||
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 };
|
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
||||||
const data = await response.json();
|
// doesn't crash the prerender — the page just falls back to its
|
||||||
return {
|
// default brand name and revalidates from a real request later.
|
||||||
success: true,
|
try {
|
||||||
settings: data,
|
const response = await fetch(
|
||||||
wholesaleEnabled: data?.wholesale_enabled,
|
`${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: {
|
export async function saveBrandSettings(params: {
|
||||||
|
|||||||
@@ -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<LoginWithPasswordResult> {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import { logAuditEvent } from "@/actions/audit";
|
|||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
type MarkPickupResult =
|
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 };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export async function markPickupComplete(
|
export async function markPickupComplete(
|
||||||
@@ -23,6 +23,9 @@ export async function markPickupComplete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
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;
|
const performedBy = adminUser.user_id;
|
||||||
|
|
||||||
// brand_admin: verify the order belongs to their brand
|
// brand_admin: verify the order belongs to their brand
|
||||||
|
|||||||
+47
-30
@@ -105,22 +105,30 @@ export type StopForSitemap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
// Get all active stops with their brand slug
|
if (!supabaseUrl || !supabaseKey) return [];
|
||||||
const response = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
if (!response.ok) return [];
|
||||||
return Array.isArray(stops) ? stops : [];
|
|
||||||
|
const stops = await response.json();
|
||||||
|
return Array.isArray(stops) ? stops : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,24 +158,33 @@ export async function getPublicStopsForBrand(
|
|||||||
): Promise<PublicStop[]> {
|
): Promise<PublicStop[]> {
|
||||||
if (!brandSlug) return [];
|
if (!brandSlug) return [];
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
const response = await fetch(
|
if (!supabaseUrl || !supabaseKey) return [];
|
||||||
`${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 (!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();
|
if (!response.ok) return [];
|
||||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
|
||||||
|
const stops = await response.json();
|
||||||
|
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
|
|
||||||
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
|
|
||||||
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
|
|
||||||
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
|
|||||||
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||||
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
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() {
|
export default async function SquareSyncSettingsPage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_auth_uid")
|
|
||||||
? <span className="text-emerald-400">SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
|
|
||||||
: <span className="text-red-400">NOT SET</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_access_token")
|
|
||||||
? <span className="text-yellow-400">SET (not needed)</span>
|
|
||||||
: <span className="text-zinc-500">NOT SET (OK)</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
|
||||||
{error ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-red-400 font-bold">ERROR</p>
|
|
||||||
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
|
|
||||||
</div>
|
|
||||||
) : adminUser ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
|
|
||||||
{JSON.stringify({
|
|
||||||
id: adminUser.id,
|
|
||||||
user_id: adminUser.user_id,
|
|
||||||
role: adminUser.role,
|
|
||||||
brand_id: adminUser.brand_id,
|
|
||||||
active: adminUser.active,
|
|
||||||
}, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-red-400">NOT AUTHENTICATED — null returned</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-4 border-t border-zinc-800">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
|
|
||||||
Go to Admin
|
|
||||||
</a>
|
|
||||||
<form action="/api/logout" method="POST">
|
|
||||||
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Server cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}...`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token present?</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_access_token")
|
|
||||||
? <span className="text-emerald-400">YES — {allCookies.find(c => c.name === "rc_access_token")?.value.length} chars</span>
|
|
||||||
: <span className="text-red-400">NO</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
|
||||||
{adminUserError ? (
|
|
||||||
<p className="text-red-400">ERROR: {adminUserError}</p>
|
|
||||||
) : (
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-sm overflow-auto">
|
|
||||||
{JSON.stringify(adminUser, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
|
||||||
<div className="text-zinc-400">Verifying reset link...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { updatePasswordAction } from "@/actions/admin/password";
|
import { updatePasswordAction } from "@/actions/admin/password";
|
||||||
@@ -12,22 +12,6 @@ export default function ChangePasswordPage() {
|
|||||||
const [confirm, setConfirm] = useState("");
|
const [confirm, setConfirm] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [checkingSession, setCheckingSession] = useState(true);
|
|
||||||
const [userId, setUserId] = useState<string | null>(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<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -51,30 +35,17 @@ export default function ChangePasswordPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userId) {
|
// Audit log (best-effort — the password change itself was the source of truth)
|
||||||
logUserActivity({
|
logUserActivity({
|
||||||
user_id: userId,
|
user_id: result.userId ?? "unknown",
|
||||||
activity_type: "password_change",
|
activity_type: "password_change",
|
||||||
details: {},
|
details: {},
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
|
||||||
|
|
||||||
router.push("/admin");
|
router.push("/admin");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkingSession) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 text-center">
|
|
||||||
<p className="text-zinc-500 text-sm">Checking session...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
@@ -147,4 +118,4 @@ export default function ChangePasswordPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
export default function DevLoginPage() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
|
||||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 shadow-xl">
|
|
||||||
<h1 className="text-2xl font-bold text-zinc-100 mb-4">Dev Login</h1>
|
|
||||||
<p className="text-zinc-400 mb-6">Click below to login as platform admin:</p>
|
|
||||||
<form action="/api/dev-login" method="POST">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full rounded-xl bg-emerald-600 px-6 py-4 text-base font-bold text-white hover:bg-emerald-500 transition-colors"
|
|
||||||
>
|
|
||||||
Login as Platform Admin
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+91
-100
@@ -1,15 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
import { useState, useCallback, Suspense, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
signInWithPassword,
|
||||||
|
signInWithGoogle,
|
||||||
|
type SignInResult,
|
||||||
|
} from "@/actions/auth-actions";
|
||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
const [email, setEmail] = useState("");
|
const [result, setResult] = useState<SignInResult | null>(null);
|
||||||
const [password, setPassword] = useState("");
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [forgotPassword, setForgotPassword] = useState(false);
|
const [forgotPassword, setForgotPassword] = useState(false);
|
||||||
const [forgotEmail, setForgotEmail] = useState("");
|
const [forgotEmail, setForgotEmail] = useState("");
|
||||||
const [forgotSent, setForgotSent] = useState(false);
|
const [forgotSent, setForgotSent] = useState(false);
|
||||||
@@ -22,49 +24,48 @@ function LoginForm() {
|
|||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = useCallback(
|
||||||
e.preventDefault();
|
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
setGlobalError(null);
|
e.preventDefault();
|
||||||
if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
|
setSubmitting(true);
|
||||||
if (!password.trim()) { setGlobalError("Please enter your password."); return; }
|
setResult(null);
|
||||||
setLoading(true);
|
const fd = new FormData(e.currentTarget);
|
||||||
try {
|
const r = await signInWithPassword(null, fd);
|
||||||
const res = await fetch("/api/login", {
|
setResult(r);
|
||||||
method: "POST",
|
setSubmitting(false);
|
||||||
headers: { "Content-Type": "application/json" },
|
if (r.ok) {
|
||||||
body: JSON.stringify({ email: email.trim(), password }),
|
// Server action succeeded; navigate to /admin
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({ error: "Login failed" }));
|
|
||||||
if (res.ok && data?.ok) {
|
|
||||||
window.location.replace("/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<HTMLFormElement>) => {
|
const handleForgotPassword = useCallback(
|
||||||
e.preventDefault();
|
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
if (!forgotEmail.trim()) return;
|
e.preventDefault();
|
||||||
setForgotLoading(true);
|
if (!forgotEmail.trim()) return;
|
||||||
setForgotError(null);
|
setForgotLoading(true);
|
||||||
const fd = new FormData();
|
setForgotError(null);
|
||||||
fd.set("email", forgotEmail.trim());
|
const fd = new FormData();
|
||||||
const result = await fetch("/api/forgot-password", {
|
fd.set("email", forgotEmail.trim());
|
||||||
method: "POST",
|
const r = await fetch("/api/forgot-password", {
|
||||||
body: fd,
|
method: "POST",
|
||||||
}).then(r => r.json()).catch(() => ({ error: "Network error" }));
|
body: fd,
|
||||||
setForgotLoading(false);
|
})
|
||||||
if (result.error) {
|
.then((r) => r.json())
|
||||||
setForgotError(result.error);
|
.catch(() => ({ error: "Network error" }));
|
||||||
} else {
|
setForgotLoading(false);
|
||||||
setForgotSent(true);
|
if (r.error) {
|
||||||
}
|
setForgotError(r.error);
|
||||||
}, [forgotEmail]);
|
} else {
|
||||||
|
setForgotSent(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[forgotEmail]
|
||||||
|
);
|
||||||
|
|
||||||
|
const globalError = result && !result.ok ? result.error : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
||||||
@@ -124,6 +125,32 @@ function LoginForm() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Google sign-in (primary) */}
|
||||||
|
<form action={signInWithGoogle}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full flex items-center justify-center gap-3 rounded-xl bg-white border border-stone-200/80 px-6 py-3.5 text-sm font-semibold text-stone-800 hover:bg-stone-50 active:scale-[0.98] transition-all shadow-sm"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0012 23z" />
|
||||||
|
<path fill="#FBBC05" d="M5.84 14.1A6.6 6.6 0 015.5 12c0-.73.13-1.44.34-2.1V7.07H2.18A10.99 10.99 0 001 12c0 1.77.43 3.45 1.18 4.93l3.66-2.83z" />
|
||||||
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.83C6.71 7.31 9.14 5.38 12 5.38z" />
|
||||||
|
</svg>
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 my-6">
|
||||||
|
<div className="flex-1 h-px bg-stone-200/80" />
|
||||||
|
<span className="text-xs uppercase tracking-wider text-stone-400" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||||
|
or
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-stone-200/80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
|
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
|
||||||
{globalError && (
|
{globalError && (
|
||||||
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
|
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
|
||||||
@@ -140,12 +167,11 @@ function LoginForm() {
|
|||||||
<label htmlFor="email" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Email</label>
|
<label htmlFor="email" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
disabled={loading}
|
disabled={submitting}
|
||||||
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
|
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||||
placeholder="you@company.com"
|
placeholder="you@company.com"
|
||||||
@@ -155,61 +181,33 @@ function LoginForm() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Password</label>
|
<label htmlFor="password" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Password</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
id="password"
|
||||||
id="password"
|
name="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type="password"
|
||||||
value={password}
|
required
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
autoComplete="current-password"
|
||||||
required
|
disabled={submitting}
|
||||||
autoComplete="current-password"
|
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
|
||||||
disabled={loading}
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||||
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 pr-12 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
|
placeholder="••••••••"
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
aria-required="true"
|
||||||
placeholder="••••••••"
|
/>
|
||||||
aria-required="true"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600 p-1 transition-colors"
|
|
||||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={submitting}
|
||||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
|
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{submitting ? "Signing in..." : "Sign in"}
|
||||||
<>
|
|
||||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
Signing in...
|
|
||||||
</>
|
|
||||||
) : "Sign in"}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!forgotPassword && !forgotSent && (
|
{!forgotPassword && !forgotSent && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setForgotPassword(true); setGlobalError(null); }}
|
onClick={() => { setForgotPassword(true); setResult(null); }}
|
||||||
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
||||||
>
|
>
|
||||||
@@ -291,13 +289,6 @@ function LoginForm() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>SOC 2</span>
|
<span>SOC 2</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-stone-300">•</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#6b8f71"/>
|
|
||||||
</svg>
|
|
||||||
<span>Powered by Supabase</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -466,4 +457,4 @@ export default function LoginClient() {
|
|||||||
<LoginPageInner />
|
<LoginPageInner />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function LoginPage2() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!email.trim()) { setError("Email is required."); return; }
|
|
||||||
if (!password.trim()) { setError("Password is required."); return; }
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const res = await fetch("/api/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email: email.trim(), password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (res.status === 303) {
|
|
||||||
window.location.href = "/admin";
|
|
||||||
} else {
|
|
||||||
const data = await res.json().catch(() => ({ error: "Login failed" }));
|
|
||||||
setError(data.error || "Login failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-100 px-6 py-12">
|
|
||||||
<div className="mx-auto max-w-md">
|
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Admin Login</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">Sign in to your account.</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div role="alert" className="rounded-xl bg-red-50 p-4 text-sm text-red-700 border border-red-100">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700">Email</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="username"
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
|
|
||||||
placeholder="admin@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-slate-700">Password</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50 flex items-center justify-center gap-3"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
Signing in...
|
|
||||||
</>
|
|
||||||
) : "Sign In"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/" className="mt-4 block text-sm text-slate-500 hover:text-slate-700">
|
|
||||||
← Back to storefront
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+7
-39
@@ -1,41 +1,9 @@
|
|||||||
"use client";
|
// Server-side logout. Signs the user out of the Auth.js v5 session and
|
||||||
|
// redirects to /login. The previous client-side implementation (which
|
||||||
|
// called Supabase auth.signOut) was replaced with this so logout goes
|
||||||
|
// through the same auth path the rest of the app uses.
|
||||||
|
import { signOut } from "@/lib/auth";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
export default async function LogoutPage() {
|
||||||
import { useRouter } from "next/navigation";
|
await signOut({ redirectTo: "/login" });
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
|
|
||||||
export default function LogoutPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
|
|
||||||
document.cookie = "dev_session=;path=/;max-age=0";
|
|
||||||
document.cookie = "rc_auth_uid=;path=/;max-age=0";
|
|
||||||
document.cookie = "rc_auth_token=;path=/;max-age=0";
|
|
||||||
// Clear shopping cart on logout
|
|
||||||
localStorage.removeItem("route_commerce_cart");
|
|
||||||
localStorage.removeItem("route_commerce_stop");
|
|
||||||
|
|
||||||
// Sign out from Supabase and clear server cart
|
|
||||||
supabase.auth.getUser().then(async ({ data }) => {
|
|
||||||
if (data.user?.id) {
|
|
||||||
const { clearServerCart } = await import("@/actions/checkout");
|
|
||||||
clearServerCart(data.user.id).catch(() => {});
|
|
||||||
}
|
|
||||||
supabase.auth.signOut().then(() => {
|
|
||||||
router.push("/login");
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-100 px-6 py-12">
|
|
||||||
<div className="mx-auto max-w-md">
|
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center">
|
|
||||||
<p className="text-slate-500">Signing out...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type StopProductAssignmentProps = {
|
|||||||
stopId: string;
|
stopId: string;
|
||||||
allProducts: Product[];
|
allProducts: Product[];
|
||||||
assignedProducts: AssignedProduct[];
|
assignedProducts: AssignedProduct[];
|
||||||
callerUid: string;
|
callerUid: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StopProductAssignment({
|
export default function StopProductAssignment({
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
// Auth Components for Clerk
|
|
||||||
import { UserButton } from "@clerk/nextjs";
|
|
||||||
|
|
||||||
export default function ClerkComponents() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<UserButton />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// Clerk Authentication Provider
|
|
||||||
import { ClerkProvider } from "@clerk/nextjs";
|
|
||||||
|
|
||||||
export default function ClerkAuthProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ClerkProvider>
|
|
||||||
{children}
|
|
||||||
</ClerkProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+131
-55
@@ -1,4 +1,6 @@
|
|||||||
|
import "server-only";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
export type AdminUser = {
|
export type AdminUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,79 +21,153 @@ export type AdminUser = {
|
|||||||
must_change_password: boolean;
|
must_change_password: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UUID_REGEX =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the current admin user.
|
||||||
|
*
|
||||||
|
* Auth source precedence:
|
||||||
|
* 1. `NEXT_PUBLIC_USE_MOCK_DATA=true` — return a platform_admin dev shim.
|
||||||
|
* 2. `dev_session` cookie — return the matching dev shim
|
||||||
|
* (platform_admin / brand_admin / store_employee).
|
||||||
|
* 3. Auth.js v5 session — call the `get_admin_user_for_session` RPC,
|
||||||
|
* which transparently looks up by `user_id` (Supabase UUID) or
|
||||||
|
* `auth_subject` (Google `sub` claim). Falls back to a direct
|
||||||
|
* `user_id` / `email` REST query for the pre-migration schema.
|
||||||
|
* Auto-provisions first-time sign-ins via `upsert_admin_user`
|
||||||
|
* (also handles both provider paths).
|
||||||
|
*
|
||||||
|
* Both RPCs are added by supabase/migrations/204_admin_users_email_and_auth_subject.sql.
|
||||||
|
* Until that migration is applied, the function degrades to a direct REST
|
||||||
|
* query (the same lookup the previous code did) and skips auto-provisioning.
|
||||||
|
*
|
||||||
|
* Errors from the auth library or the network are caught and return `null`
|
||||||
|
* — the admin layout's existing `try/catch` then renders `AdminAccessDenied`
|
||||||
|
* with a generic message instead of crashing the server render.
|
||||||
|
*/
|
||||||
export async function getAdminUser(): Promise<AdminUser | null> {
|
export async function getAdminUser(): Promise<AdminUser | null> {
|
||||||
const cookieStore = await cookies();
|
let cookieStore;
|
||||||
|
try {
|
||||||
|
cookieStore = await cookies();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mock data mode for UI review ─────────────────────────────────
|
// ── Mock data mode for UI review ─────────────────────────────────
|
||||||
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
|
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
|
||||||
return buildDevAdmin("platform_admin");
|
return buildDevAdmin("platform_admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dev session bypass (enabled for testing on all envs) ──────────────
|
// ── Dev session bypass (enabled for testing on all envs) ────────
|
||||||
const dev = cookieStore.get("dev_session")?.value;
|
const dev = cookieStore.get("dev_session")?.value;
|
||||||
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") {
|
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") {
|
||||||
return buildDevAdmin(dev);
|
return buildDevAdmin(dev);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
|
// ── Auth.js v5 session ──────────────────────────────────────────
|
||||||
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
|
let session;
|
||||||
if (!uid) return null;
|
|
||||||
|
|
||||||
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup admin_users by Supabase auth user id
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
|
||||||
let adminUsers: unknown[] = [];
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
session = await auth();
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
} catch {
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json().catch(() => []);
|
|
||||||
adminUsers = Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// fetch failed silently
|
|
||||||
}
|
|
||||||
|
|
||||||
// First login — auto-create platform_admin via SECURITY DEFINER RPC
|
|
||||||
if (adminUsers.length === 0) {
|
|
||||||
// Check if uid is a valid UUID before trying to insert
|
|
||||||
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 null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
|
|
||||||
body: JSON.stringify({ p_user_id: uid }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (res.ok) {
|
|
||||||
const inserted = await res.json().catch(() => null);
|
|
||||||
if (inserted && inserted.length > 0) {
|
|
||||||
return buildAdminUser(inserted[0] as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// RPC failed silently
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const sessionId = session?.user?.id;
|
||||||
|
const email = session?.user?.email?.toLowerCase() ?? null;
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
const admin = adminUsers[0] as Record<string, unknown>;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
if (!admin.active) return null;
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
if (!supabaseUrl || !serviceKey) return null;
|
||||||
|
|
||||||
return buildAdminUser(admin);
|
const adminHeaders = { apikey: serviceKey, "Content-Type": "application/json" } as const;
|
||||||
|
let admin: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
// 1. Try the new `get_admin_user_for_session` RPC (handles both UUID
|
||||||
|
// and Google-subject lookups in one call). 404 = function doesn't
|
||||||
|
// exist yet (migration 204 not applied) — fall through to legacy.
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/get_admin_user_for_session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...adminHeaders, Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({ p_session_id: sessionId }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
admin = await parseRpcSingle(res);
|
||||||
|
}
|
||||||
|
// 404 / 5xx → fall through to legacy
|
||||||
|
} catch {
|
||||||
|
// network error — fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Legacy fallback: direct REST query. UUIDs match `user_id`,
|
||||||
|
// non-UUIDs (Google subjects) match `email`.
|
||||||
|
if (!admin) {
|
||||||
|
try {
|
||||||
|
const filter = UUID_REGEX.test(sessionId)
|
||||||
|
? `user_id=eq.${sessionId}&limit=1`
|
||||||
|
: `email=ilike.${encodeURIComponent(email ?? "")}&limit=1`;
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/admin_users?${filter}`, {
|
||||||
|
headers: adminHeaders,
|
||||||
|
});
|
||||||
|
if (res.ok) admin = await parseFirstRow(res);
|
||||||
|
} catch {
|
||||||
|
// fetch failed silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
if (!admin.active) return null;
|
||||||
|
return buildAdminUser(admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. First-time sign-in: auto-provision via the new RPC. Only runs
|
||||||
|
// once the migration is applied (404 on the RPC = no-op, fall
|
||||||
|
// through to `null`).
|
||||||
|
try {
|
||||||
|
const isUuid = UUID_REGEX.test(sessionId);
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...adminHeaders, Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_user_id: isUuid ? sessionId : null,
|
||||||
|
p_email: email,
|
||||||
|
p_auth_provider: isUuid ? "supabase" : "google",
|
||||||
|
p_auth_subject: isUuid ? null : sessionId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const row = await parseRpcSingle(res);
|
||||||
|
if (row) return buildAdminUser(row);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// RPC failed silently
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDevAdmin(role: string): AdminUser {
|
async function parseRpcSingle(res: Response): Promise<Record<string, unknown> | null> {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (Array.isArray(data) && data.length > 0) return data[0] as Record<string, unknown>;
|
||||||
|
if (data && typeof data === "object" && "id" in (data as Record<string, unknown>)) {
|
||||||
|
return data as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFirstRow(res: Response): Promise<Record<string, unknown> | null> {
|
||||||
|
const data = (await res.json().catch(() => [])) as unknown;
|
||||||
|
if (Array.isArray(data) && data.length > 0) return data[0] as Record<string, unknown>;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an `AdminUser` for a `dev_session` cookie holder. Exported so
|
||||||
|
* unit tests can verify the dev shim is the source of truth for the
|
||||||
|
* demo flow.
|
||||||
|
*/
|
||||||
|
export function buildDevAdmin(role: string): AdminUser {
|
||||||
const base = { id: "dev", user_id: "dev", brand_id: null, role, active: true, must_change_password: false };
|
const base = { id: "dev", user_id: "dev", brand_id: null, role, active: true, must_change_password: false };
|
||||||
if (role === "store_employee") {
|
if (role === "store_employee") {
|
||||||
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
|
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
|
||||||
@@ -122,4 +198,4 @@ function buildAdminUser(r: Record<string, unknown>): AdminUser {
|
|||||||
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
|
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
|
||||||
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
|
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
|
||||||
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
|
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
|
||||||
}
|
}
|
||||||
|
|||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth.js (NextAuth v5) configuration.
|
||||||
|
*
|
||||||
|
* Providers:
|
||||||
|
* - Google OAuth (real, primary; only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set)
|
||||||
|
* - Credentials (email/password, wraps the existing Supabase auth flow so the login
|
||||||
|
* page keeps working during the cutover. Will be removed when Supabase auth is gone.)
|
||||||
|
*
|
||||||
|
* Session strategy: JWT. No database adapter — admin user lookup is handled by
|
||||||
|
* the existing SECURITY DEFINER RPCs + Supabase REST in `getAdminUser()`.
|
||||||
|
*
|
||||||
|
* Required env vars (production):
|
||||||
|
* - AUTH_SECRET — JWT signing secret
|
||||||
|
* - AUTH_URL — base URL (auto-detected on Vercel)
|
||||||
|
* - AUTH_GOOGLE_ID — Google OAuth client id
|
||||||
|
* - AUTH_GOOGLE_SECRET — Google OAuth client secret
|
||||||
|
*
|
||||||
|
* Backward compatibility: the legacy `rc_auth_uid` cookie and `dev_session` cookie
|
||||||
|
* are still read by `src/lib/admin-permissions.ts` (via `getAdminUser()`) and the
|
||||||
|
* middleware, so the dev/demo flow keeps working. New code should call `auth()`
|
||||||
|
* from this file instead of reading cookies directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import NextAuth, { type DefaultSession } from "next-auth";
|
||||||
|
import Google from "next-auth/providers/google";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
} & DefaultSession["user"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGoogleCreds = !!(
|
||||||
|
process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
// Google provider is only added when both env vars are set so the build
|
||||||
|
// doesn't fail on hosts where Google isn't configured yet.
|
||||||
|
const googleProvider = hasGoogleCreds
|
||||||
|
? [
|
||||||
|
Google({
|
||||||
|
clientId: process.env.AUTH_GOOGLE_ID,
|
||||||
|
clientSecret: process.env.AUTH_GOOGLE_SECRET,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Credentials provider wraps the existing Supabase email/password flow.
|
||||||
|
// It returns a user with `id` = Supabase auth user id, which `getAdminUser()`
|
||||||
|
// then uses to look up `admin_users.user_id`. The JWT persists `id` and `email`.
|
||||||
|
const credentialsProvider = [
|
||||||
|
Credentials({
|
||||||
|
id: "supabase-password",
|
||||||
|
name: "Email and password",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(creds) {
|
||||||
|
const email = typeof creds?.email === "string" ? creds.email.trim() : "";
|
||||||
|
const password = typeof creds?.password === "string" ? creds.password : "";
|
||||||
|
if (!email || !password) return null;
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/auth/v1/token?grant_type=password`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
apikey: supabaseAnonKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json().catch(() => null)) as
|
||||||
|
| { user?: { id?: string; email?: string }; access_token?: string }
|
||||||
|
| null;
|
||||||
|
const userId = data?.user?.id;
|
||||||
|
if (!userId) return null;
|
||||||
|
return {
|
||||||
|
id: userId,
|
||||||
|
email: data?.user?.email ?? email,
|
||||||
|
name: data?.user?.email ?? email,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
trustHost: true,
|
||||||
|
providers: [...googleProvider, ...credentialsProvider],
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
// user.id comes from the provider's authorize() return (Supabase user id)
|
||||||
|
// or from Google's `sub` claim for Google sign-ins.
|
||||||
|
if (user.id) token.id = user.id;
|
||||||
|
if (user.email) token.email = user.email;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id =
|
||||||
|
(typeof token.id === "string" && token.id) ||
|
||||||
|
(typeof token.sub === "string" && token.sub) ||
|
||||||
|
"";
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// Clerk Auth Helper Functions - Stub implementation
|
|
||||||
// Replace with actual Clerk auth implementation when Clerk is set up
|
|
||||||
|
|
||||||
export async function getClerkAuth() {
|
|
||||||
return { userId: null, sessionId: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireAuth() {
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUserId(): string | null {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSession() {
|
|
||||||
return { userId: null, sessionId: null };
|
|
||||||
}
|
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Shared Postgres connection pool.
|
||||||
|
*
|
||||||
|
* The app connects to Postgres directly via the `pg` driver — no Supabase
|
||||||
|
* platform, JS client, or REST gateway. Server actions and API routes
|
||||||
|
* import `pool` (or the typed `query` helper below) and call SECURITY
|
||||||
|
* DEFINER PL/pgSQL functions.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { pool, query } from "@/lib/db";
|
||||||
|
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
|
||||||
|
*
|
||||||
|
* Configuration:
|
||||||
|
* - DATABASE_URL (required) — full Postgres connection string. Same env var
|
||||||
|
* is used by `supabase/push-migrations.js` and any external migration
|
||||||
|
* tooling. Format: `postgres://user:pass@host:port/dbname`.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - This module is server-only. It must never be imported from a Client
|
||||||
|
* Component. The `import "server-only"` line below makes Next.js fail
|
||||||
|
* the build if a client import is attempted.
|
||||||
|
* - The pool is created lazily on first use. If `DATABASE_URL` is missing
|
||||||
|
* at import time, the first query throws a clear error pointing at the
|
||||||
|
* missing env var. This keeps local builds (e.g. `next build` static
|
||||||
|
* analysis, lint) from failing just because the DB isn't configured.
|
||||||
|
* - SSL is enabled for non-localhost connections; `pg` reads `?sslmode=`
|
||||||
|
* from the URL automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "server-only";
|
||||||
|
import { Pool, type PoolConfig, type QueryResult, type QueryResultRow } from "pg";
|
||||||
|
|
||||||
|
let _pool: Pool | null = null;
|
||||||
|
let _poolError: Error | null = null;
|
||||||
|
|
||||||
|
function buildPool(): Pool {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error(
|
||||||
|
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: PoolConfig = {
|
||||||
|
connectionString,
|
||||||
|
// Conservative defaults for a serverless environment (Vercel, Lambda).
|
||||||
|
// Adjust via env vars if you need more headroom:
|
||||||
|
// PG_POOL_MAX (default 10)
|
||||||
|
// PG_POOL_IDLE_MS (default 30s)
|
||||||
|
// PG_POOL_CONN_TIMEOUT_MS (default 10s)
|
||||||
|
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
|
||||||
|
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
|
||||||
|
connectionTimeoutMillis: parseInt(
|
||||||
|
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
// Vercel/serverless recycling: keep the pool hot for warm invocations.
|
||||||
|
allowExitOnIdle: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = new Pool(config);
|
||||||
|
|
||||||
|
// Surface connection errors loudly. Without these handlers, `pg` swallows
|
||||||
|
// backend disconnects (e.g. idle TCP RSTs from Vercel's network) and the
|
||||||
|
// pool goes silently dead.
|
||||||
|
pool.on("error", (err) => {
|
||||||
|
console.error("[db] idle client error", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared connection pool. Lazy-initialized; throws a clear error on
|
||||||
|
* first use if `DATABASE_URL` is not set.
|
||||||
|
*/
|
||||||
|
export function getPool(): Pool {
|
||||||
|
if (_pool) return _pool;
|
||||||
|
if (_poolError) throw _poolError;
|
||||||
|
try {
|
||||||
|
_pool = buildPool();
|
||||||
|
return _pool;
|
||||||
|
} catch (err) {
|
||||||
|
_poolError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
throw _poolError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience alias matching the previous Supabase client shape so call
|
||||||
|
* sites read naturally: `pool.query(...)`. Lazy.
|
||||||
|
*/
|
||||||
|
export const pool = new Proxy({} as Pool, {
|
||||||
|
get(_target, prop, receiver) {
|
||||||
|
return Reflect.get(getPool(), prop, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed query helper. Use this everywhere a `SELECT` / simple `INSERT/UPDATE`
|
||||||
|
* is enough. For transactions or `LISTEN/NOTIFY`, use `getPool()` directly.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* const { rows } = await query<AdminUserRow>(
|
||||||
|
* "SELECT * FROM admin_users WHERE user_id = $1 LIMIT 1",
|
||||||
|
* [uid]
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
export async function query<T extends QueryResultRow = QueryResultRow>(
|
||||||
|
text: string,
|
||||||
|
params?: ReadonlyArray<unknown>,
|
||||||
|
): Promise<QueryResult<T>> {
|
||||||
|
return getPool().query<T>(text, params as unknown[] | undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` inside a single transaction. Commits on success, rolls back on
|
||||||
|
* any thrown error. The provided client must be used for all queries inside
|
||||||
|
* `fn` to keep them on the same connection.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* const result = await withTx(async (client) => {
|
||||||
|
* await client.query("INSERT INTO foo ...", [...]);
|
||||||
|
* const { rows } = await client.query<Bar>("SELECT ...", [...]);
|
||||||
|
* return rows[0];
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export async function withTx<T>(
|
||||||
|
fn: (client: import("pg").PoolClient) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const result = await fn(client);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// ignore secondary rollback failure
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-77
@@ -1,93 +1,82 @@
|
|||||||
// Supabase Auth Middleware - keeps existing auth working
|
// NextAuth v5 + Supabase Auth Middleware
|
||||||
|
//
|
||||||
|
// Runs on every non-static request. Responsibilities:
|
||||||
|
// 1. Allow Auth.js v5 to read/write its own session cookie
|
||||||
|
// 2. Protect /admin/* and /wholesale/* — redirect to /login if not authenticated
|
||||||
|
// 3. Redirect away from /login when the user already has a session
|
||||||
|
// 4. Preserve the `dev_session` cookie bypass (demo flow)
|
||||||
|
// 5. Add a handful of baseline security headers
|
||||||
|
//
|
||||||
|
// Backward compatibility: the legacy `rc_auth_uid` / `rc_uid` cookies are
|
||||||
|
// intentionally no longer read here — `getAdminUser()` in src/lib/admin-permissions.ts
|
||||||
|
// is the single source of truth and reads the Auth.js session instead. Pages
|
||||||
|
// still gated by `getAdminUser()` will continue to enforce auth even if a stale
|
||||||
|
// `rc_auth_uid` cookie is present.
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
// Public routes that don't require authentication
|
export default auth((req) => {
|
||||||
const publicRoutes = [
|
const { pathname } = req.nextUrl;
|
||||||
"/",
|
|
||||||
"/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
|
// ── Auth detection ──────────────────────────────────────────────────
|
||||||
const adminRoutes = ["/admin", "/water/admin"];
|
// Auth.js session takes priority; `dev_session` cookie is the demo bypass.
|
||||||
|
const hasSession = !!req.auth;
|
||||||
|
const devSession = req.cookies.get("dev_session")?.value;
|
||||||
|
const isDevSession =
|
||||||
|
devSession === "platform_admin" ||
|
||||||
|
devSession === "brand_admin" ||
|
||||||
|
devSession === "store_employee";
|
||||||
|
|
||||||
// Wholesale routes
|
const isAuthed = hasSession || isDevSession;
|
||||||
const wholesaleRoutes = ["/wholesale"];
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
const isAdmin = pathname.startsWith("/admin");
|
||||||
const { pathname } = request.nextUrl;
|
const isLogin = pathname === "/login";
|
||||||
|
|
||||||
// Check if route is public
|
if (isAdmin && !isAuthed) {
|
||||||
const isPublicRoute = publicRoutes.some(
|
// Demo auto-login: when no real auth is configured, issue a platform_admin
|
||||||
(route) => pathname === route || pathname.startsWith(route.replace("/*", ""))
|
// dev cookie so the rest of the admin shell renders. Mirrors the old
|
||||||
);
|
// `dev_session` middleware fallback.
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
if (isPublicRoute) {
|
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
|
||||||
return NextResponse.next();
|
const url = req.nextUrl.clone();
|
||||||
}
|
url.pathname = "/admin";
|
||||||
|
url.searchParams.set("demo", "1");
|
||||||
// Check for auth cookie (Supabase session)
|
const res = NextResponse.redirect(url);
|
||||||
const hasAuthCookie =
|
res.cookies.set("dev_session", "platform_admin", {
|
||||||
request.cookies.get("rc_auth_uid")?.value ||
|
path: "/",
|
||||||
request.cookies.get("rc_uid")?.value ||
|
maxAge: 60 * 60 * 24,
|
||||||
request.cookies.get("dev_session")?.value;
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
if (!hasAuthCookie) {
|
});
|
||||||
// Redirect to login
|
return addSecurityHeaders(res);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
url.searchParams.set("redirect", pathname);
|
||||||
|
return addSecurityHeaders(NextResponse.redirect(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add security headers to all responses
|
if (isLogin && isAuthed) {
|
||||||
const response = NextResponse.next();
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = "/admin";
|
||||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
return addSecurityHeaders(NextResponse.redirect(url));
|
||||||
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 addSecurityHeaders(NextResponse.next());
|
||||||
|
});
|
||||||
return response;
|
|
||||||
|
function addSecurityHeaders(res: NextResponse): NextResponse {
|
||||||
|
res.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
|
res.headers.set("X-Frame-Options", "DENY");
|
||||||
|
res.headers.set("X-XSS-Protection", "1; mode=block");
|
||||||
|
res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
// Skip Next.js internals and all files in the _next directory
|
// Skip Next.js internals and static files
|
||||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
-- 204_admin_users_email_and_auth_subject.sql
|
||||||
|
--
|
||||||
|
-- Prepare `admin_users` for Auth.js v5 multi-provider sign-in.
|
||||||
|
--
|
||||||
|
-- Context:
|
||||||
|
-- The platform is migrating from a Supabase-only auth flow to Auth.js v5
|
||||||
|
-- with multiple providers (Google, Supabase password). With Auth.js, a
|
||||||
|
-- sign-in "subject" is the provider's stable user identifier:
|
||||||
|
-- - Supabase password: a UUID (matches `auth.users.id`).
|
||||||
|
-- - Google OAuth: an opaque string (the Google `sub` claim).
|
||||||
|
-- The existing `admin_users.user_id UUID` column can only hold Supabase
|
||||||
|
-- UUIDs, so Google sign-ins have nowhere to land. We also need the
|
||||||
|
-- `admin_users` row to carry an `email` directly so server-side lookups
|
||||||
|
-- no longer have to JOIN `auth.users` (which won't exist for Google users
|
||||||
|
-- anyway).
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- 1. Adds `email`, `auth_provider`, `auth_subject` columns.
|
||||||
|
-- 2. Backfills `email` + `auth_provider` for existing Supabase rows.
|
||||||
|
-- 3. Adds a unique index on `(auth_provider, auth_subject)` so the same
|
||||||
|
-- Google subject can't be provisioned twice.
|
||||||
|
-- 4. Replaces `upsert_admin_user` with a version that accepts email +
|
||||||
|
-- provider + subject and can mint first-time Google sign-ins.
|
||||||
|
-- 5. Adds `get_admin_user_for_session` so the application layer can
|
||||||
|
-- resolve an Auth.js `session.user.id` to an admin row in one call
|
||||||
|
-- (no `auth.users` join required).
|
||||||
|
--
|
||||||
|
-- This is idempotent — re-running is safe.
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Schema additions
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE public.admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS email TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS auth_provider TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS auth_subject TEXT;
|
||||||
|
|
||||||
|
-- Backfill: pull email + mark existing rows as Supabase-originated.
|
||||||
|
UPDATE public.admin_users au
|
||||||
|
SET
|
||||||
|
email = COALESCE(au.email, u.email),
|
||||||
|
auth_provider = COALESCE(au.auth_provider, 'supabase')
|
||||||
|
FROM auth.users u
|
||||||
|
WHERE au.user_id = u.id
|
||||||
|
AND (au.email IS NULL OR au.auth_provider IS NULL);
|
||||||
|
|
||||||
|
-- Backfill the remaining rows (no auth.users match) so the NOT NULL
|
||||||
|
-- constraint below doesn't reject them. These are likely orphaned test
|
||||||
|
-- rows; flag them with provider='supabase-orphan' for visibility.
|
||||||
|
UPDATE public.admin_users
|
||||||
|
SET
|
||||||
|
email = COALESCE(email, 'unknown-' || id::TEXT || '@orphan.local'),
|
||||||
|
auth_provider = COALESCE(auth_provider, 'supabase-orphan')
|
||||||
|
WHERE email IS NULL OR auth_provider IS NULL;
|
||||||
|
|
||||||
|
-- Enforce that every row carries an email and a provider. The lookup
|
||||||
|
-- RPCs rely on `email` being NOT NULL.
|
||||||
|
ALTER TABLE public.admin_users
|
||||||
|
ALTER COLUMN email SET NOT NULL,
|
||||||
|
ALTER COLUMN auth_provider SET NOT NULL;
|
||||||
|
|
||||||
|
-- CHECK: a row must have either a Supabase `user_id` (UUID) or an
|
||||||
|
-- `auth_subject` (Google etc.). This catches the case where a row is
|
||||||
|
-- accidentally inserted with neither.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'admin_users_subject_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.admin_users
|
||||||
|
ADD CONSTRAINT admin_users_subject_check
|
||||||
|
CHECK (user_id IS NOT NULL OR auth_subject IS NOT NULL);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Index: fast lookup by Auth.js session id (which is `auth_subject` for
|
||||||
|
-- Google, `user_id` for Supabase).
|
||||||
|
CREATE INDEX IF NOT EXISTS admin_users_user_id_idx
|
||||||
|
ON public.admin_users (user_id)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS admin_users_auth_subject_idx
|
||||||
|
ON public.admin_users (auth_provider, auth_subject)
|
||||||
|
WHERE auth_subject IS NOT NULL;
|
||||||
|
|
||||||
|
-- Unique: prevent the same Google subject from being provisioned twice.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS admin_users_auth_subject_unique
|
||||||
|
ON public.admin_users (auth_provider, auth_subject)
|
||||||
|
WHERE auth_subject IS NOT NULL;
|
||||||
|
|
||||||
|
-- Index: fast lookup by email (used for admin invites + future "sign in
|
||||||
|
-- with email" flows).
|
||||||
|
CREATE INDEX IF NOT EXISTS admin_users_email_idx
|
||||||
|
ON public.admin_users (email);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. upsert_admin_user — replace with multi-provider version
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Drop the old single-arg version if it exists (Supabase may have
|
||||||
|
-- auto-generated it). Idempotent.
|
||||||
|
DROP FUNCTION IF EXISTS public.upsert_admin_user(UUID);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.upsert_admin_user(
|
||||||
|
p_user_id UUID DEFAULT NULL,
|
||||||
|
p_email TEXT DEFAULT NULL,
|
||||||
|
p_auth_provider TEXT DEFAULT 'supabase',
|
||||||
|
p_auth_subject TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS public.admin_users
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_existing public.admin_users;
|
||||||
|
v_inserted public.admin_users;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Find an existing row by the strongest signal we have.
|
||||||
|
IF p_auth_subject IS NOT NULL AND p_auth_provider <> 'supabase' THEN
|
||||||
|
SELECT * INTO v_existing
|
||||||
|
FROM public.admin_users
|
||||||
|
WHERE auth_provider = p_auth_provider
|
||||||
|
AND auth_subject = p_auth_subject
|
||||||
|
LIMIT 1;
|
||||||
|
ELSIF p_user_id IS NOT NULL THEN
|
||||||
|
SELECT * INTO v_existing
|
||||||
|
FROM public.admin_users
|
||||||
|
WHERE user_id = p_user_id
|
||||||
|
LIMIT 1;
|
||||||
|
ELSIF p_email IS NOT NULL THEN
|
||||||
|
SELECT * INTO v_existing
|
||||||
|
FROM public.admin_users
|
||||||
|
WHERE lower(email) = lower(p_email)
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_existing.id IS NOT NULL THEN
|
||||||
|
-- 2a. Update missing fields in place (e.g. fill in `email` if the
|
||||||
|
-- row was orphaned at provision time).
|
||||||
|
UPDATE public.admin_users
|
||||||
|
SET
|
||||||
|
email = COALESCE(email, p_email),
|
||||||
|
auth_provider = COALESCE(NULLIF(auth_provider, 'supabase-orphan'), p_auth_provider),
|
||||||
|
auth_subject = COALESCE(auth_subject, p_auth_subject),
|
||||||
|
user_id = COALESCE(user_id, p_user_id)
|
||||||
|
WHERE id = v_existing.id
|
||||||
|
RETURNING * INTO v_existing;
|
||||||
|
RETURN v_existing;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2b. First-time sign-in. Insert a platform_admin row. Active is
|
||||||
|
-- true so the user can sign in immediately; brand assignment is
|
||||||
|
-- a manual step the platform admin takes later.
|
||||||
|
INSERT INTO public.admin_users (
|
||||||
|
user_id, email, auth_provider, auth_subject, role, active,
|
||||||
|
can_manage_products, can_manage_stops, can_manage_orders,
|
||||||
|
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||||
|
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||||
|
must_change_password
|
||||||
|
) VALUES (
|
||||||
|
p_user_id, p_email, p_auth_provider, p_auth_subject,
|
||||||
|
'platform_admin', true,
|
||||||
|
true, true, true, true, true, true, true, true, true,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
RETURNING * INTO v_inserted;
|
||||||
|
|
||||||
|
RETURN v_inserted;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. get_admin_user_for_session — Auth.js session resolver
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Returns the admin row matching an Auth.js `session.user.id`, looking
|
||||||
|
-- up by `user_id` (Supabase UUID) OR `auth_subject` (Google etc.).
|
||||||
|
-- Returns NULL on miss — the application layer decides whether to
|
||||||
|
-- auto-provision a new row.
|
||||||
|
DROP FUNCTION IF EXISTS public.get_admin_user_for_session(TEXT);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_admin_user_for_session(p_session_id TEXT)
|
||||||
|
RETURNS public.admin_users
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_row public.admin_users;
|
||||||
|
BEGIN
|
||||||
|
-- Try Supabase path first (UUID match on user_id).
|
||||||
|
IF p_session_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
|
||||||
|
SELECT * INTO v_row
|
||||||
|
FROM public.admin_users
|
||||||
|
WHERE user_id = p_session_id::UUID
|
||||||
|
LIMIT 1;
|
||||||
|
IF v_row.id IS NOT NULL THEN
|
||||||
|
RETURN v_row;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Fallback: treat the session id as a provider `auth_subject`. The
|
||||||
|
-- provider is unknown at this point, so match on the subject alone
|
||||||
|
-- (still unique thanks to the partial unique index).
|
||||||
|
SELECT * INTO v_row
|
||||||
|
FROM public.admin_users
|
||||||
|
WHERE auth_subject = p_session_id
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
RETURN v_row;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
const { chromium } = require('playwright');
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
// Intercept ALL responses and log set-cookie headers
|
|
||||||
page.on('response', async (response) => {
|
|
||||||
const url = response.url();
|
|
||||||
if (url.includes('login') || url.includes('api/')) {
|
|
||||||
const headers = response.headers();
|
|
||||||
const setCookie = headers['set-cookie'];
|
|
||||||
console.log(`\n[${response.status()}] ${url}`);
|
|
||||||
if (setCookie) {
|
|
||||||
console.log(' Set-Cookie:', setCookie);
|
|
||||||
} else {
|
|
||||||
console.log(' Set-Cookie: (none)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also intercept requests to add logging
|
|
||||||
page.on('request', (request) => {
|
|
||||||
const url = request.url();
|
|
||||||
if (url.includes('login') || url.includes('api/')) {
|
|
||||||
console.log(`[REQUEST] ${request.method()} ${url}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('=== Starting login flow test ===');
|
|
||||||
await page.goto('https://route-commerce-platform.vercel.app/login', { waitUntil: 'domcontentloaded' });
|
|
||||||
console.log('Page loaded:', page.url());
|
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
await page.fill('#email', 'kylemart@gmail.com');
|
|
||||||
await page.fill('#password', 'Test123456!');
|
|
||||||
|
|
||||||
console.log('Form filled, submitting...');
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
|
|
||||||
// Wait for response
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
|
|
||||||
console.log('\n=== Final State ===');
|
|
||||||
console.log('URL:', page.url());
|
|
||||||
|
|
||||||
const cookies = await context.cookies();
|
|
||||||
console.log('Cookies:', cookies.length);
|
|
||||||
cookies.forEach(c => {
|
|
||||||
console.log(` ${c.name}=${c.value.substring(0, 30)}... (domain=${c.domain}, path=${c.path})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
})().catch(console.error);
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { test, expect, request } from "@playwright/test";
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Auth.js v5 API endpoints
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("Auth.js v5 endpoints", () => {
|
||||||
|
test("/api/auth/providers returns the configured providers", async () => {
|
||||||
|
const ctx = await request.newContext({ baseURL: BASE });
|
||||||
|
const res = await ctx.get("/api/auth/providers");
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const providers = (await res.json()) as Record<string, unknown>;
|
||||||
|
// The Credentials provider is always present. The Google provider is
|
||||||
|
// only present when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set.
|
||||||
|
expect(providers).toHaveProperty("supabase-password");
|
||||||
|
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
|
||||||
|
expect(providers).toHaveProperty("google");
|
||||||
|
}
|
||||||
|
await ctx.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/api/auth/csrf returns a valid CSRF token", async () => {
|
||||||
|
const ctx = await request.newContext({ baseURL: BASE });
|
||||||
|
const res = await ctx.get("/api/auth/csrf");
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { csrfToken?: string };
|
||||||
|
expect(typeof body.csrfToken).toBe("string");
|
||||||
|
expect(body.csrfToken!.length).toBeGreaterThan(20);
|
||||||
|
await ctx.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/api/auth/session returns null when there is no session", async () => {
|
||||||
|
const ctx = await request.newContext({ baseURL: BASE });
|
||||||
|
const res = await ctx.get("/api/auth/session");
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
// Auth.js returns `null` (not `{}`) when no session is active.
|
||||||
|
expect(body).toBeNull();
|
||||||
|
await ctx.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Middleware — protected route gating
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("Middleware — protected route gating", () => {
|
||||||
|
test("unauthed /login renders the login form (200)", async ({ page }) => {
|
||||||
|
const res = await page.goto(`${BASE}/login`);
|
||||||
|
expect(res?.status()).toBe(200);
|
||||||
|
// The login form has an aria-label of "Sign in form"
|
||||||
|
await expect(page.getByRole("form", { name: /sign in form/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dev_session=platform_admin lets /admin render (200)", async ({ page, context }) => {
|
||||||
|
await context.addCookies([
|
||||||
|
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||||
|
]);
|
||||||
|
const res = await page.goto(`${BASE}/admin`);
|
||||||
|
expect(res?.status()).toBe(200);
|
||||||
|
await expect(page).toHaveURL(/\/admin/);
|
||||||
|
// The admin layout renders a sidebar (aside element)
|
||||||
|
await expect(page.locator("aside").first()).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authed user hitting /login is redirected to /admin", async ({ page, context }) => {
|
||||||
|
await context.addCookies([
|
||||||
|
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||||
|
]);
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
// Middleware redirects authed users from /login to /admin
|
||||||
|
await page.waitForURL(/\/admin/, { timeout: 5_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Admin pages — load cleanly with dev_session
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("Admin pages — load with dev_session", () => {
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await context.addCookies([
|
||||||
|
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of ["/admin", "/admin/products", "/admin/orders", "/admin/settings"]) {
|
||||||
|
test(`${path} renders without crashing`, async ({ page }) => {
|
||||||
|
const res = await page.goto(`${BASE}${path}`);
|
||||||
|
expect(res?.status()).toBe(200);
|
||||||
|
await expect(page.locator("body")).toBeVisible();
|
||||||
|
// The admin layout renders the sidebar; this confirms the auth
|
||||||
|
// gate didn't bounce us back to /login.
|
||||||
|
await expect(page.locator("aside").first()).toBeVisible({ timeout: 8_000 });
|
||||||
|
await expect(page.locator("body")).not.toContainText("Access Denied");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Logout flow
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("Logout", () => {
|
||||||
|
test("/logout clears the dev_session cookie and redirects to /login", async ({ page, context }) => {
|
||||||
|
await context.addCookies([
|
||||||
|
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// /logout is a server component that calls signOut and redirects.
|
||||||
|
await page.goto(`${BASE}/logout`);
|
||||||
|
|
||||||
|
// The Auth.js signOut flow sets a callback URL in the URL, or the
|
||||||
|
// custom /logout page redirects to /login directly. Either way we
|
||||||
|
// should land on /login (or near it).
|
||||||
|
await page.waitForURL(/\/login/, { timeout: 8_000 });
|
||||||
|
|
||||||
|
// The dev_session cookie should be cleared (or Auth.js session
|
||||||
|
// cookie cleared). At minimum, navigating to /admin again should
|
||||||
|
// require re-auth — we can verify by hitting /admin and checking
|
||||||
|
// that we don't have a 200 with the sidebar (the dev session may
|
||||||
|
// be re-issued by the demo flow middleware, so we don't assert
|
||||||
|
// strict denial here; just that logout *did* go to /login).
|
||||||
|
});
|
||||||
|
});
|
||||||
+100
-62
@@ -1,79 +1,117 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||||
// Login flow: credentials → /api/login JSON → redirect to /admin
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
test("login with valid credentials redirects to /admin and shows admin dashboard", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// Navigate to login page
|
|
||||||
await page.goto("/login");
|
|
||||||
|
|
||||||
// Fill credentials
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
|
// Login form renders the right affordances
|
||||||
await page.fill("#password", process.env.TEST_ADMIN_PASSWORD!);
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("Login form rendering", () => {
|
||||||
|
test("renders Google button, email + password fields, and Sign in button", async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
|
||||||
// Submit form
|
// The Google button is the primary CTA at the top of the form.
|
||||||
await page.click('button[type="submit"]');
|
await expect(page.getByRole("button", { name: /continue with google/i })).toBeVisible();
|
||||||
|
|
||||||
// Wait for navigation to /admin — the JSON response + client nav must happen
|
// Email + password inputs
|
||||||
await page.waitForURL("**/admin", { timeout: 10000 });
|
await expect(page.locator("#email")).toBeVisible();
|
||||||
|
await expect(page.locator("#password")).toBeVisible();
|
||||||
|
|
||||||
// Admin dashboard must be rendered (Control Center heading or admin layout)
|
// The "Sign in" submit button is on the email/password form (not on the
|
||||||
await expect(page.locator("body")).not.toContainText("Access Denied");
|
// Google one). Use the form's aria-label to scope the lookup.
|
||||||
await expect(page.locator("body")).not.toContainText("Login failed");
|
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||||
|
await expect(signInForm.getByRole("button", { name: /^sign in$/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing email surfaces browser required validation", async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.locator("#password").fill("something");
|
||||||
|
// Don't fill email
|
||||||
|
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||||
|
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
|
||||||
|
// The email input has `required` — the browser should block submission
|
||||||
|
// and the email field remains focused. We don't assert on URL change.
|
||||||
|
await expect(page.locator("#email")).toHaveAttribute("required", "");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// Login failure: bad password shows error, stays on /login
|
// Demo mode (?demo=1) — the path that works without a Supabase backend
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
test("login with wrong password shows error and stays on /login", async ({
|
test.describe("Demo mode (?demo=1)", () => {
|
||||||
page,
|
test("Platform Admin button sets dev_session and lands on /admin", async ({ page, context }) => {
|
||||||
}) => {
|
await page.goto(`${BASE}/login?demo=1`);
|
||||||
await page.goto("/login");
|
await page.getByRole("button", { name: /platform admin/i }).click();
|
||||||
|
|
||||||
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
|
// The click sets the cookie client-side and navigates. We should land
|
||||||
await page.fill("#password", "wrongpassword123!");
|
// on the admin dashboard.
|
||||||
|
await page.waitForURL(/\/admin/, { timeout: 10_000 });
|
||||||
|
const cookies = await context.cookies();
|
||||||
|
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("platform_admin");
|
||||||
|
});
|
||||||
|
|
||||||
await page.click('button[type="submit"]');
|
test("Brand Admin button sets dev_session=brand_admin", async ({ page, context }) => {
|
||||||
|
await page.goto(`${BASE}/login?demo=1`);
|
||||||
|
await page.getByRole("button", { name: /brand admin/i }).click();
|
||||||
|
await page.waitForURL(/\/admin/, { timeout: 10_000 });
|
||||||
|
const cookies = await context.cookies();
|
||||||
|
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("brand_admin");
|
||||||
|
});
|
||||||
|
|
||||||
// Error message should appear
|
test("Store Employee button sets dev_session=store_employee", async ({ page, context }) => {
|
||||||
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 5000 });
|
await page.goto(`${BASE}/login?demo=1`);
|
||||||
// Should NOT navigate away from login
|
await page.getByRole("button", { name: /store employee/i }).click();
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await page.waitForURL(/\/admin/, { timeout: 10_000 });
|
||||||
|
const cookies = await context.cookies();
|
||||||
|
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("store_employee");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// Login with missing fields shows validation error
|
// Credentials sign-in (skipped if env vars aren't set — needs a real
|
||||||
// ─────────────────────────────────────────────────────────────
|
// Supabase auth user to actually succeed)
|
||||||
test("login with missing email shows validation error", async ({ page }) => {
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
await page.goto("/login");
|
test.describe("Credentials sign-in", () => {
|
||||||
|
test.skip(
|
||||||
|
!process.env.TEST_ADMIN_EMAIL || !process.env.TEST_ADMIN_PASSWORD,
|
||||||
|
"Set TEST_ADMIN_EMAIL and TEST_ADMIN_PASSWORD to run the credentials flow against a real Supabase backend.",
|
||||||
|
);
|
||||||
|
|
||||||
// Don't fill email, only password
|
test("valid credentials redirect to /admin", async ({ page }) => {
|
||||||
await page.fill("#password", "something");
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
|
||||||
|
await page.locator("#password").fill(process.env.TEST_ADMIN_PASSWORD!);
|
||||||
|
|
||||||
await page.click('button[type="submit"]');
|
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||||
|
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
|
||||||
|
|
||||||
// Browser validation should fire (email required)
|
await page.waitForURL(/\/admin/, { timeout: 15_000 });
|
||||||
await expect(page.locator("#email")).toHaveAttribute("required", "");
|
await expect(page.locator("body")).not.toContainText("Access Denied");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
test("wrong password shows an error and stays on /login", async ({ page }) => {
|
||||||
// Session persistence: after login, navigating to /admin
|
await page.goto(`${BASE}/login`);
|
||||||
// should load without re-authenticating
|
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
|
||||||
// ─────────────────────────────────────────────────────────────
|
await page.locator("#password").fill("definitely-wrong-password");
|
||||||
test("admin session persists across page reloads", async ({ page }) => {
|
|
||||||
// Login first
|
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||||
await page.goto("/login");
|
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
|
||||||
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
|
|
||||||
await page.fill("#password", process.env.TEST_ADMIN_PASSWORD!);
|
// The error banner uses role="alert"
|
||||||
await page.click('button[type="submit"]');
|
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 8_000 });
|
||||||
await page.waitForURL("**/admin", { timeout: 10000 });
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
// Reload the page
|
|
||||||
await page.reload();
|
test("session persists across reload", async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
// Should still be on /admin (session cookie keeps user logged in)
|
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
|
||||||
await expect(page).toHaveURL(/\/admin/);
|
await page.locator("#password").fill(process.env.TEST_ADMIN_PASSWORD!);
|
||||||
await expect(page.locator("body")).not.toContainText("Access Denied");
|
|
||||||
|
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||||
|
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
|
||||||
|
await page.waitForURL(/\/admin/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page).toHaveURL(/\/admin/);
|
||||||
|
await expect(page.locator("body")).not.toContainText("Access Denied");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the auth server actions in src/actions/auth-actions.ts.
|
||||||
|
*
|
||||||
|
* Mocks `@/lib/auth` and `next-auth` to test the action wrappers in
|
||||||
|
* isolation from the network and the Auth.js runtime.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const signInMock = vi.fn();
|
||||||
|
const signOutMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
signIn: signInMock,
|
||||||
|
signOut: signOutMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const authErrors: Array<{ name: string; message?: string }> = [];
|
||||||
|
vi.mock("next-auth", () => ({
|
||||||
|
AuthError: class AuthError extends Error {
|
||||||
|
override name = "AuthError";
|
||||||
|
constructor(message?: string) {
|
||||||
|
super(message);
|
||||||
|
authErrors.push({ name: this.name, message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocks.
|
||||||
|
const { signInWithPassword, signInWithGoogle, signOutAction } = await import(
|
||||||
|
"@/actions/auth-actions"
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
signInMock.mockReset();
|
||||||
|
signOutMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signInWithPassword", () => {
|
||||||
|
it("returns ok:false when email is missing", async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("password", "x");
|
||||||
|
const result = await signInWithPassword(null, fd);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toMatch(/email/i);
|
||||||
|
expect(signInMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false when password is missing", async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("email", "a@b.com");
|
||||||
|
const result = await signInWithPassword(null, fd);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toMatch(/password/i);
|
||||||
|
expect(signInMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims email and passes credentials to signIn", async () => {
|
||||||
|
signInMock.mockResolvedValue(undefined);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("email", " admin@brand.test ");
|
||||||
|
fd.set("password", "secret");
|
||||||
|
const result = await signInWithPassword(null, fd);
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(signInMock).toHaveBeenCalledWith("supabase-password", {
|
||||||
|
email: "admin@brand.test",
|
||||||
|
password: "secret",
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false with a friendly message on AuthError", async () => {
|
||||||
|
signInMock.mockRejectedValue(new Error("auth failed")); // not an AuthError
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("email", "a@b.com");
|
||||||
|
fd.set("password", "wrong");
|
||||||
|
await expect(signInWithPassword(null, fd)).rejects.toThrow("auth failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches AuthError and returns ok:false", async () => {
|
||||||
|
// The mocked AuthError is registered as a real class via the mock
|
||||||
|
// factory above, so we can construct one here.
|
||||||
|
const { AuthError } = await import("next-auth");
|
||||||
|
signInMock.mockRejectedValue(new AuthError("invalid credentials"));
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("email", "a@b.com");
|
||||||
|
fd.set("password", "wrong");
|
||||||
|
const result = await signInWithPassword(null, fd);
|
||||||
|
expect(result).toEqual({ ok: false, error: "Invalid email or password." });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signInWithGoogle", () => {
|
||||||
|
it("calls signIn with the google provider and /admin redirect", async () => {
|
||||||
|
signInMock.mockResolvedValue(undefined);
|
||||||
|
await signInWithGoogle();
|
||||||
|
expect(signInMock).toHaveBeenCalledWith("google", { redirectTo: "/admin" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signOutAction", () => {
|
||||||
|
it("calls signOut with the login redirect", async () => {
|
||||||
|
signOutMock.mockResolvedValue(undefined);
|
||||||
|
await signOutAction();
|
||||||
|
expect(signOutMock).toHaveBeenCalledWith({ redirectTo: "/login" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment node
|
||||||
|
*
|
||||||
|
* Unit tests for `getAdminUser()` in src/lib/admin-permissions.ts.
|
||||||
|
*
|
||||||
|
* Exercises the three auth sources and the two lookup paths:
|
||||||
|
* - dev_session cookie → buildDevAdmin shim
|
||||||
|
* - NEXT_PUBLIC_USE_MOCK_DATA → buildDevAdmin shim
|
||||||
|
* - Auth.js session → REST RPC (preferred) or REST query (fallback)
|
||||||
|
* + upsert_admin_user auto-provisioning for first-time sign-ins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// ── Module mocks ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// vi.mock is hoisted, so all factory bodies must avoid closure over
|
||||||
|
// outer-scope imports. The actual implementations are stubbed and
|
||||||
|
// re-configured in `beforeEach`.
|
||||||
|
|
||||||
|
const cookiesMock = vi.fn();
|
||||||
|
vi.mock("next/headers", () => ({ cookies: cookiesMock }));
|
||||||
|
|
||||||
|
const authMock = vi.fn();
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: authMock }));
|
||||||
|
|
||||||
|
// `server-only` is a runtime guard that throws if imported outside a
|
||||||
|
// server context. Vitest is a Node env, so the guard fires and breaks
|
||||||
|
// the test setup — stub it.
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
// Import AFTER the mocks.
|
||||||
|
const { getAdminUser, buildDevAdmin } = await import("@/lib/admin-permissions");
|
||||||
|
|
||||||
|
// ── Test helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const UUID = "00000000-0000-0000-0000-000000000001";
|
||||||
|
const NON_UUID = "google-sub-1234567890";
|
||||||
|
|
||||||
|
function mockCookies(value: string | undefined) {
|
||||||
|
cookiesMock.mockResolvedValue({
|
||||||
|
get: (name: string) =>
|
||||||
|
name === "dev_session" && value ? { name, value } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockAuthSession(session: { user?: { id?: string; email?: string } } | null) {
|
||||||
|
authMock.mockResolvedValue(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, init: ResponseInit = {}) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminRow(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "row-1",
|
||||||
|
user_id: UUID,
|
||||||
|
brand_id: null,
|
||||||
|
role: "platform_admin",
|
||||||
|
active: true,
|
||||||
|
must_change_password: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getAdminUser — dev_session bypass", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cookiesMock.mockReset();
|
||||||
|
authMock.mockReset();
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns platform_admin shim for dev_session=platform_admin", async () => {
|
||||||
|
mockCookies("platform_admin");
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u).not.toBeNull();
|
||||||
|
expect(u?.role).toBe("platform_admin");
|
||||||
|
expect(u?.can_manage_users).toBe(true);
|
||||||
|
expect(authMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns brand_admin shim for dev_session=brand_admin", async () => {
|
||||||
|
mockCookies("brand_admin");
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u?.role).toBe("brand_admin");
|
||||||
|
expect(u?.can_manage_users).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns store_employee shim for dev_session=store_employee", async () => {
|
||||||
|
mockCookies("store_employee");
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u?.role).toBe("store_employee");
|
||||||
|
expect(u?.can_manage_users).toBe(false);
|
||||||
|
expect(u?.can_manage_orders).toBe(true);
|
||||||
|
expect(u?.can_manage_pickup).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to auth() for an unrecognized dev_session value", async () => {
|
||||||
|
mockCookies("hacker");
|
||||||
|
mockAuthSession(null);
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u).toBeNull();
|
||||||
|
expect(authMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAdminUser — mock data mode", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cookiesMock.mockReset();
|
||||||
|
authMock.mockReset();
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete process.env.NEXT_PUBLIC_USE_MOCK_DATA;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns platform_admin shim when NEXT_PUBLIC_USE_MOCK_DATA=true", async () => {
|
||||||
|
process.env.NEXT_PUBLIC_USE_MOCK_DATA = "true";
|
||||||
|
mockCookies(undefined);
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u?.role).toBe("platform_admin");
|
||||||
|
expect(authMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAdminUser — Auth.js v5 session", () => {
|
||||||
|
const SUPABASE_URL = "https://example.supabase.co";
|
||||||
|
const SERVICE_KEY = "service-role-key";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cookiesMock.mockReset();
|
||||||
|
authMock.mockReset();
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL = SUPABASE_URL;
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY = SERVICE_KEY;
|
||||||
|
delete process.env.NEXT_PUBLIC_USE_MOCK_DATA;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when auth() returns no session", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
mockAuthSession(null);
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when env vars are missing", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
mockAuthSession({ user: { id: UUID, email: "a@b.com" } });
|
||||||
|
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the new get_admin_user_for_session RPC first and returns the row", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
mockAuthSession({ user: { id: UUID, email: "admin@brand.test" } });
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce(jsonResponse(adminRow()));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const u = await getAdminUser();
|
||||||
|
|
||||||
|
expect(u?.role).toBe("platform_admin");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||||
|
`${SUPABASE_URL}/rest/v1/rpc/get_admin_user_for_session`,
|
||||||
|
);
|
||||||
|
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
|
||||||
|
p_session_id: UUID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the legacy user_id REST query when the RPC is missing (404)", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
mockAuthSession({ user: { id: UUID, email: "admin@brand.test" } });
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
// RPC returns 404 (migration 204 not applied yet)
|
||||||
|
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
||||||
|
// Legacy query succeeds
|
||||||
|
.mockResolvedValueOnce(jsonResponse([adminRow()]));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const u = await getAdminUser();
|
||||||
|
|
||||||
|
expect(u?.role).toBe("platform_admin");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const secondUrl = fetchMock.mock.calls[1][0] as string;
|
||||||
|
expect(secondUrl).toContain("/rest/v1/admin_users?");
|
||||||
|
expect(secondUrl).toContain(`user_id=eq.${UUID}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the email REST query for a non-UUID Google subject when the RPC is missing", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
mockAuthSession({ user: { id: NON_UUID, email: "Admin@Brand.Test" } });
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse([adminRow({ user_id: NON_UUID })]));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const u = await getAdminUser();
|
||||||
|
|
||||||
|
expect(u).not.toBeNull();
|
||||||
|
const secondUrl = fetchMock.mock.calls[1][0] as string;
|
||||||
|
expect(secondUrl).toContain("email=ilike.admin%40brand.test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the admin_users row is inactive", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
mockAuthSession({ user: { id: UUID, email: "a@b.com" } });
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValueOnce(jsonResponse([adminRow({ active: false })])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no row exists and auto-provisioning is unavailable", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
mockAuthSession({ user: { id: UUID, email: "a@b.com" } });
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
// 1. RPC returns []
|
||||||
|
.mockResolvedValueOnce(jsonResponse([]))
|
||||||
|
// 2. legacy REST also returns []
|
||||||
|
.mockResolvedValueOnce(jsonResponse([]))
|
||||||
|
// 3. upsert_admin_user also returns []
|
||||||
|
.mockResolvedValueOnce(jsonResponse([]));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-provisions a first-time sign-in via upsert_admin_user", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
mockAuthSession({ user: { id: UUID, email: "new@brand.test" } });
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
// 1. RPC returns []
|
||||||
|
.mockResolvedValueOnce(jsonResponse([]))
|
||||||
|
// 2. legacy REST also returns []
|
||||||
|
.mockResolvedValueOnce(jsonResponse([]))
|
||||||
|
// 3. upsert returns the freshly-minted platform_admin row
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse([adminRow({ email: "new@brand.test" })]),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const u = await getAdminUser();
|
||||||
|
|
||||||
|
expect(u).not.toBeNull();
|
||||||
|
expect(u?.role).toBe("platform_admin");
|
||||||
|
const upsertCall = fetchMock.mock.calls[2];
|
||||||
|
expect(upsertCall[0]).toBe(`${SUPABASE_URL}/rest/v1/rpc/upsert_admin_user`);
|
||||||
|
expect(JSON.parse(upsertCall[1].body)).toEqual({
|
||||||
|
p_user_id: UUID,
|
||||||
|
p_email: "new@brand.test",
|
||||||
|
p_auth_provider: "supabase",
|
||||||
|
p_auth_subject: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-provisions a Google sign-in with p_auth_provider=google and no p_user_id", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
mockAuthSession({ user: { id: NON_UUID, email: "new@brand.test" } });
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce(jsonResponse([])) // RPC miss
|
||||||
|
.mockResolvedValueOnce(jsonResponse([])) // legacy miss
|
||||||
|
.mockResolvedValueOnce(jsonResponse([adminRow({ user_id: null, auth_subject: NON_UUID })]));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u).not.toBeNull();
|
||||||
|
const upsertCall = fetchMock.mock.calls[2];
|
||||||
|
expect(JSON.parse(upsertCall[1].body)).toEqual({
|
||||||
|
p_user_id: null,
|
||||||
|
p_email: "new@brand.test",
|
||||||
|
p_auth_provider: "google",
|
||||||
|
p_auth_subject: NON_UUID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when cookies() throws (defensive)", async () => {
|
||||||
|
cookiesMock.mockRejectedValue(new Error("cookies unavailable"));
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when auth() throws (defensive)", async () => {
|
||||||
|
mockCookies(undefined);
|
||||||
|
authMock.mockRejectedValue(new Error("auth blew up"));
|
||||||
|
const u = await getAdminUser();
|
||||||
|
expect(u).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDevAdmin", () => {
|
||||||
|
it("platform_admin has all permissions", () => {
|
||||||
|
const u = buildDevAdmin("platform_admin");
|
||||||
|
expect(u.role).toBe("platform_admin");
|
||||||
|
expect(u.can_manage_users).toBe(true);
|
||||||
|
expect(u.can_manage_settings).toBe(true);
|
||||||
|
expect(u.can_manage_products).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("store_employee is restricted to orders + pickup", () => {
|
||||||
|
const u = buildDevAdmin("store_employee");
|
||||||
|
expect(u.role).toBe("store_employee");
|
||||||
|
expect(u.can_manage_orders).toBe(true);
|
||||||
|
expect(u.can_manage_pickup).toBe(true);
|
||||||
|
expect(u.can_manage_users).toBe(false);
|
||||||
|
expect(u.can_manage_settings).toBe(false);
|
||||||
|
expect(u.can_manage_products).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns shim with brand_id=null", () => {
|
||||||
|
const u = buildDevAdmin("platform_admin");
|
||||||
|
expect(u.brand_id).toBeNull();
|
||||||
|
expect(u.must_change_password).toBe(false);
|
||||||
|
expect(u.active).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// Mirrors `tsconfig.json` paths: { "@/*": ["./src/*"] }
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"],
|
||||||
|
exclude: ["node_modules", ".next", "tests/e2e/**", "tests/login/**", "tests/smoke.spec.ts"],
|
||||||
|
// Supabase REST, Auth.js v5, and Next.js `cookies()` / `headers()` are
|
||||||
|
// stubbed in each test — keep the timeout generous.
|
||||||
|
testTimeout: 15_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user