Merge branch 'feature/drizzle-rls-real-auth'
# Conflicts: # CLAUDE.md # package.json # src/app/api/auth/[...nextauth]/route.ts # src/app/login/LoginClient.tsx # src/auth.config.ts # src/components/admin/AdminSidebar.tsx # src/lib/admin-permissions-types.ts # src/lib/admin-permissions.ts # src/lib/auth.ts # src/middleware.ts
This commit is contained in:
@@ -36,28 +36,36 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
**Dev mode** bypasses Supabase auth entirely via `dev_session` cookie set by `/login`:
|
||||
**Auth.js v5 (NextAuth)** is the active auth system. Config lives in `src/lib/auth.ts`, with the route handler at `src/app/api/auth/[...nextauth]/route.ts`. The login page (`src/app/login/LoginClient.tsx`) renders a "Continue with Google" button alongside the email/password form, both backed by Auth.js providers. Server-side code reads the session via `await auth()` from `@/lib/auth`; client code that needs to sign out can call the `signOutAction` server action from `@/actions/auth-actions`.
|
||||
|
||||
**Providers (see `src/lib/auth.ts`):**
|
||||
- **Google OAuth** — primary sign-in. Active when `AUTH_GOOGLE_ID` + `AUTH_GOOGLE_SECRET` are set. Users auto-provision as `platform_admin` if their Google `sub` is UUID-shaped (rare) — for Google sign-ins, `admin_users` rows must be provisioned manually by an existing admin until the `email`-based provisioning flow lands.
|
||||
- **Email/password (Supabase-backed)** — wraps the existing `auth/v1/token?grant_type=password` flow. This is transitional; once Supabase auth is fully removed, this provider goes away.
|
||||
|
||||
**Demo / dev mode** still works through a `dev_session` cookie:
|
||||
- `dev_session=platform_admin` — full access, all brands
|
||||
- `dev_session=brand_admin` — full access to assigned brand only
|
||||
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
|
||||
- The login page renders "Demo Mode" buttons that set this cookie client-side; the middleware also auto-issues `dev_session=platform_admin` for the `/admin` demo flow when Supabase isn't configured.
|
||||
|
||||
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and the legacy `rc_auth_uid` cookie in production (set by the pre-Auth.js `/api/login`) — never import this file directly into Client Components. Use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
|
||||
**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`) guards `/admin/:path*` and `/login`. It auto-issues a `dev_session=platform_admin` cookie for the demo flow when no auth is present. A `clerk-auth.ts` helper exists in `src/lib/` but is currently a stub — do not depend on it.
|
||||
The 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.
|
||||
|
||||
#### Auth.js (NextAuth v5) migration — in progress
|
||||
#### Migration status
|
||||
|
||||
The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight:
|
||||
|
||||
- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead.
|
||||
- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list.
|
||||
- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints.
|
||||
- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos.
|
||||
- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change.
|
||||
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it.
|
||||
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior.
|
||||
- ✅ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# YOLO Auth Migration — Final Report
|
||||
|
||||
**Date:** 2026-06-06 → 2026-06-07
|
||||
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
|
||||
all Supabase references from the auth/admin path.
|
||||
|
||||
---
|
||||
|
||||
## What was built
|
||||
|
||||
### 1. Auth.js v5 (NextAuth) integration — DONE
|
||||
|
||||
- **`src/lib/auth.ts`** — Auth.js v5 config. Google is the only provider
|
||||
(no Supabase-backed Credentials provider). Sessions are JWTs; no DB
|
||||
adapter. Exports `handlers`, `auth`, `signIn`, `signOut`.
|
||||
- **`src/lib/admin-permissions.ts`** — `getAdminUser()` resolves the
|
||||
current admin. Three branches in precedence order:
|
||||
1. `NEXT_PUBLIC_USE_MOCK_DATA=true` → platform_admin dev shim
|
||||
2. `dev_session` cookie → matching dev shim (platform/brand/store)
|
||||
3. Auth.js session → `null` (no DB lookup yet — see Follow-ups)
|
||||
- **`src/actions/auth-actions.ts`** — `signInWithGoogle()` and
|
||||
`signOutAction()`. `signInWithPassword` was removed.
|
||||
- **`src/app/login/LoginClient.tsx`** + **`page.tsx`** — Login page
|
||||
renders "Continue with Google" (when configured) and three demo
|
||||
buttons (Platform / Brand / Store Employee) via `?demo=1`. Email /
|
||||
password form and "Forgot password" are gone.
|
||||
- **`src/app/api/auth/[...nextauth]/route.ts`** — Auth.js route handler.
|
||||
|
||||
### 2. Direct Postgres connection (`src/lib/db.ts`) — DONE
|
||||
|
||||
- Single shared `pg.Pool` with lazy initialization. Throws a clear
|
||||
error if `DATABASE_URL` is not set.
|
||||
- Exports `getPool()`, a `pool` proxy alias, `query<T>()` helper, and
|
||||
`withTx()` for transactions.
|
||||
- Tuned for Vercel/serverless: 10 max conns, 30s idle, 10s connect
|
||||
timeout. All overridable via `PG_POOL_*` env vars.
|
||||
|
||||
### 3. Server actions → `pg` (no Supabase) — DONE
|
||||
|
||||
- **`src/actions/admin/users.ts`** — Full rewrite. `getAdminUsers`,
|
||||
`createAdminUser`, `updateAdminUser`, `deleteAdminUser`,
|
||||
`setMustChangePassword`, `getBrands` now hit Postgres directly.
|
||||
`sendPasswordResetEmail` returns a stub error (no auth service
|
||||
anymore — the function is preserved for call-site compatibility).
|
||||
- The `dev_session` cookie continues to be the demo-flow bypass.
|
||||
|
||||
### 4. Cleanup — DONE
|
||||
|
||||
- **`src/actions/admin/force-login.ts`** — Deleted (the
|
||||
Emergency-Force-Login page and its `/api/force-admin` route were
|
||||
removed in the previous session).
|
||||
- **`src/actions/admin/users.ts`** — Removed the `DEV_FORCE_UID`
|
||||
magic value, the `rc_auth_uid` cookie reads (×3), the
|
||||
`headerStore` lookup, the `getServiceClient` / `getAuthClient` /
|
||||
`callRpcWithAuth` helpers, and the `devListAdminUsers` auto-provision
|
||||
branch. All Supabase imports gone.
|
||||
- **`src/app/admin/me/AdminMeClient.tsx`** — Profile / email-change
|
||||
handlers neutralized (return "contact a platform admin" — the
|
||||
Supabase auth backend that backed them is gone).
|
||||
- **`src/middleware.ts`** — `dev_session` auto-login preserved
|
||||
(issues a `platform_admin` cookie on unauthed `/admin` so the rest
|
||||
of the admin shell renders). `auth()` wrapper in place.
|
||||
|
||||
### 5. Test infrastructure — DONE
|
||||
|
||||
- **Vitest 2.1.9** with `@vitejs/plugin-react`, jsdom, manual
|
||||
`@/* → src/*` alias. No `vite-tsconfig-paths` (ESM/require
|
||||
incompatibility).
|
||||
- **`tests/setup.ts`** — Mocks `next/navigation` (not in jsdom).
|
||||
- **`tests/unit/getAdminUser.test.ts`** — 12 tests covering
|
||||
dev_session, mock-data, Auth.js session, defensive error handling.
|
||||
- **`tests/unit/auth-actions.test.ts`** — 2 tests for
|
||||
`signInWithGoogle` + `signOutAction`.
|
||||
- Both test files stub `server-only` with `vi.mock("server-only", () => ({}))`.
|
||||
- **Playwright config** — `webServer` block for `npm run dev`,
|
||||
`PLAYWRIGHT_URL` override, `reuseExistingServer: !process.env.CI`.
|
||||
- **`tests/login/login-flow.spec.ts`** — Login form rendering +
|
||||
demo mode (3 roles) tests.
|
||||
- **`tests/e2e/auth.spec.ts`** — Auth.js endpoints, middleware
|
||||
redirect behavior, /admin load with dev_session, logout.
|
||||
|
||||
### 6. `import "server-only"` markers — DONE
|
||||
|
||||
- Added to `src/lib/auth.ts`, `src/lib/admin-permissions.ts`,
|
||||
`src/lib/db.ts`, `src/actions/auth-actions.ts`,
|
||||
`src/actions/admin/users.ts`.
|
||||
- ⚠️ In `"use server"` files, the directive must be first
|
||||
(`"use server";` on line 1, then `import "server-only";`). The dev
|
||||
build surfaced this; fixed in `auth-actions.ts` and `users.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Verification status
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npx tsc --noEmit` | ✅ 0 errors |
|
||||
| `npx vitest run` | ✅ 14/14 tests pass |
|
||||
| `npm run dev` | ✅ Boots in ~440ms |
|
||||
| `GET /login` | ✅ 200 |
|
||||
| `GET /login?demo=1` | ✅ 200 |
|
||||
| `GET /api/auth/providers` | ✅ 200 (empty list — Google not configured) |
|
||||
| `GET /` | ✅ 200 |
|
||||
| `GET /admin` (unauthed) | ✅ 307 → `/admin?demo=1` + `dev_session` cookie |
|
||||
| `GET /admin?demo=1` (with cookie) | ✅ 200 (admin layout renders) |
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
```
|
||||
M src/actions/admin/users.ts # full rewrite, pg-only
|
||||
M src/actions/auth-actions.ts # signInWithPassword removed
|
||||
M src/app/admin/me/AdminMeClient.tsx # handlers stubbed
|
||||
M src/app/login/LoginClient.tsx # email/password form removed
|
||||
M src/app/login/page.tsx # passes hasGoogle prop
|
||||
M src/lib/admin-permissions.ts # Supabase REST calls removed
|
||||
M src/lib/auth.ts # Credentials provider removed
|
||||
M tests/unit/auth-actions.test.ts # signInWithPassword tests removed
|
||||
M tests/unit/getAdminUser.test.ts # reflects no-DB behavior
|
||||
```
|
||||
|
||||
Plus deletions and creations recorded in the prior session.
|
||||
|
||||
---
|
||||
|
||||
## Follow-ups (the bigger fish)
|
||||
|
||||
The user's directive was: "git rid of all supabase references, we are
|
||||
not using it for login or anything anymore." The auth/admin path is
|
||||
now Supabase-free. **33 files** still import from Supabase — they use
|
||||
it for **data fetching** (`supabase.from(...).select(...)`), not auth.
|
||||
|
||||
**Migrating those to `pg` is a follow-up.** The pattern is the same
|
||||
in every file:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const { data } = await supabase.from("products").select("*").eq("brand_id", id);
|
||||
|
||||
// After
|
||||
import { query } from "@/lib/db";
|
||||
const { rows } = await query<ProductRow>(
|
||||
"SELECT * FROM products WHERE brand_id = $1",
|
||||
[id],
|
||||
);
|
||||
```
|
||||
|
||||
### 33 files still importing Supabase
|
||||
|
||||
```
|
||||
src/app/admin/page.tsx, products/[id]/page.tsx, products/page.tsx,
|
||||
orders/page.tsx, stops/[id]/page.tsx, stops/new/page.tsx,
|
||||
stops/page.tsx, reports/page.tsx, taxes/page.tsx,
|
||||
settings/billing/page.tsx, settings/integrations/page.tsx,
|
||||
settings/shipping/page.tsx
|
||||
src/app/tuxedo/page.tsx, about/page.tsx, contact/ContactClientPage.tsx,
|
||||
stops/[slug]/page.tsx
|
||||
src/app/indian-river-direct/page.tsx, faq/FAQClientPage.tsx,
|
||||
contact/ContactClientPage.tsx, stops/[slug]/page.tsx
|
||||
src/app/api/tuxedo/schedule-pdf/route.ts,
|
||||
src/app/api/indian-river-direct/schedule-pdf/route.ts
|
||||
src/app/reset-password/page.tsx, src/app/test/page.tsx
|
||||
src/components/admin/AdminHeader.tsx, AdminSidebar.tsx
|
||||
src/lib/supabase.ts, src/lib/supabase/server.ts # the client modules
|
||||
src/actions/wholesale-auth.ts, reset-admin.ts, audit.ts
|
||||
src/actions/ai/preferences.ts
|
||||
```
|
||||
|
||||
### Additional follow-ups
|
||||
|
||||
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
|
||||
set in the hosting dashboard (same env var as
|
||||
`supabase/push-migrations.js` uses).
|
||||
2. **Apply migration 204** if not already applied — adds
|
||||
`email`, `auth_provider`, `auth_subject` columns to `admin_users`
|
||||
and the `get_admin_user_for_session` RPC.
|
||||
3. **Wire `getAdminUser()`'s Auth.js session branch** to call
|
||||
`get_admin_user_for_session` via `pg` once a `DATABASE_URL` is
|
||||
configured. Today it returns `null` (Access Denied) — correct for
|
||||
an unprovisioned user, but a Google sign-in to a brand that
|
||||
exists won't resolve yet.
|
||||
4. **Drop the `next-auth` Credentials provider module path** in
|
||||
`auth.ts` entirely once production confirms Google is the only
|
||||
provider in use. Currently it's gated on env var presence.
|
||||
5. **Re-implement `sendPasswordResetEmail`** if/when Auth.js v5 ships
|
||||
its built-in email provider — until then, a platform admin must
|
||||
handle resets manually.
|
||||
6. **Decommission `src/lib/supabase.ts` and `src/lib/supabase/server.ts`**
|
||||
once all 33 remaining files have been ported to `pg`. They are
|
||||
the only remaining `@supabase/*` import surface.
|
||||
7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from
|
||||
`package.json`** (last step once no file imports them).
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Dev server (boots in ~440ms)
|
||||
npm test # Vitest: 14/14 pass
|
||||
npx tsc --noEmit # Typecheck: 0 errors
|
||||
npx playwright test # E2E (skips credentials tests if env unset)
|
||||
```
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Drizzle client + tenant-scoped query helper.
|
||||
*
|
||||
* The app connects to Postgres directly via the `pg` driver. Drizzle sits
|
||||
* on top, providing typed queries. The `withTenant` wrapper is the only
|
||||
* sanctioned way to run a tenant-scoped query — it sets the
|
||||
* `app.current_tenant_id` GUC transaction-locally, and the database's
|
||||
* RLS policies enforce tenant isolation even if application code forgets
|
||||
* a `WHERE tenant_id = $1`.
|
||||
*
|
||||
* Usage (read):
|
||||
* const products = await withTenant(tenantId, (db) =>
|
||||
* db.select().from(productsTable).where(eq(productsTable.active, true)),
|
||||
* );
|
||||
*
|
||||
* Usage (platform admin — sees all tenants):
|
||||
* const allTenants = await withPlatformAdmin((db) =>
|
||||
* db.select().from(tenantsTable),
|
||||
* );
|
||||
*
|
||||
* Usage (no tenant — for the rare case the query isn't tenant-scoped):
|
||||
* const plans = await withDb((db) => db.select().from(plansTable));
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
type Schema = typeof schema;
|
||||
export type Db = NodePgDatabase<Schema>;
|
||||
|
||||
let _pool: Pool | null = null;
|
||||
|
||||
function getPool(): Pool {
|
||||
if (_pool) return _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).",
|
||||
);
|
||||
}
|
||||
_pool = new Pool({
|
||||
connectionString,
|
||||
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,
|
||||
),
|
||||
allowExitOnIdle: false,
|
||||
});
|
||||
_pool.on("error", (err) => {
|
||||
console.error("[db] idle client error", err);
|
||||
});
|
||||
return _pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with a Drizzle client. No tenant context is set — the caller
|
||||
* is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables,
|
||||
* which are not tenant-scoped). For tenant-scoped reads, prefer
|
||||
* `withTenant` or `withPlatformAdmin`.
|
||||
*/
|
||||
export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
const db = drizzle(client, { schema });
|
||||
return await fn(db);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` inside a transaction with the current tenant id set as a
|
||||
* transaction-local GUC. RLS policies on tenant-scoped tables will allow
|
||||
* reads/writes only for rows where `tenant_id` matches. Pass `null` to
|
||||
* fail open (don't set the GUC) — only useful for the migrations
|
||||
* themselves, never for app code.
|
||||
*/
|
||||
export async function withTenant<T>(
|
||||
tenantId: string,
|
||||
fn: (db: Db) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return runInTransaction(async (client) => {
|
||||
// set_config(setting, value, is_local) — is_local=true makes it
|
||||
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
||||
// leaks across pooled connections.
|
||||
await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [
|
||||
tenantId,
|
||||
]);
|
||||
await client.query("SELECT set_config('app.platform_admin', 'false', true)");
|
||||
const db = drizzle(client, { schema });
|
||||
return fn(db);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` as platform admin. RLS policies permit access to all tenants.
|
||||
* Use sparingly — typically only in the /admin/platform routes.
|
||||
*/
|
||||
export async function withPlatformAdmin<T>(
|
||||
fn: (db: Db) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return runInTransaction(async (client) => {
|
||||
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
|
||||
const db = drizzle(client, { schema });
|
||||
return fn(db);
|
||||
});
|
||||
}
|
||||
|
||||
async function runInTransaction<T>(
|
||||
fn: (client: 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();
|
||||
}
|
||||
}
|
||||
|
||||
export { schema };
|
||||
@@ -0,0 +1,523 @@
|
||||
-- 0001_init.sql
|
||||
--
|
||||
-- Route Commerce SaaS schema. Single migration, idempotent, no DROP.
|
||||
-- Follows CLAUDE.md conventions:
|
||||
-- - Status enums as TEXT with CHECK (no PG ENUM type)
|
||||
-- - TIMESTAMPTZ everywhere
|
||||
-- - gen_random_uuid() for PKs
|
||||
-- - CREATE OR REPLACE FUNCTION for helpers
|
||||
--
|
||||
-- Multi-tenancy: every business table has `tenant_id` and an RLS policy
|
||||
-- that compares it to `current_setting('app.current_tenant_id')`. The
|
||||
-- application MUST set this GUC (transaction-local) before any query
|
||||
-- against a tenant-scoped table. See `db/client.ts` for the wrapper.
|
||||
--
|
||||
-- Platform admin (sees all tenants) sets `app.platform_admin = 'true'`
|
||||
-- instead. RLS policies allow that to bypass the tenant filter.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 0. Extensions
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 1. Tenancy + auth
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'trial'
|
||||
CHECK (status IN ('trial', 'active', 'past_due', 'suspended', 'churned')),
|
||||
trial_ends_at TIMESTAMPTZ,
|
||||
stripe_customer_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE,
|
||||
name TEXT,
|
||||
image TEXT,
|
||||
auth_provider TEXT NOT NULL DEFAULT 'dev'
|
||||
CHECK (auth_provider IN ('dev', 'google', 'email')),
|
||||
auth_subject TEXT,
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_auth_subject_idx
|
||||
ON users (auth_provider, auth_subject)
|
||||
WHERE auth_subject IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_users (
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'brand_admin'
|
||||
CHECK (role IN ('platform_admin', 'brand_admin', 'store_employee')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tenant_users_user_idx ON tenant_users (user_id);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 2. Billing
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL UNIQUE
|
||||
CHECK (code IN ('starter', 'farm', 'enterprise')),
|
||||
name TEXT NOT NULL,
|
||||
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
|
||||
max_users INTEGER NOT NULL,
|
||||
max_products INTEGER NOT NULL,
|
||||
max_stops_monthly INTEGER NOT NULL,
|
||||
features JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS add_ons (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL UNIQUE
|
||||
CHECK (code IN (
|
||||
'wholesale_portal',
|
||||
'harvest_reach',
|
||||
'ai_tools',
|
||||
'water_log',
|
||||
'square_sync',
|
||||
'sms_campaigns'
|
||||
)),
|
||||
name TEXT NOT NULL,
|
||||
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
plan_id UUID NOT NULL REFERENCES plans(id),
|
||||
status TEXT NOT NULL DEFAULT 'trialing'
|
||||
CHECK (status IN ('trialing', 'active', 'past_due', 'canceled', 'incomplete')),
|
||||
stripe_subscription_id TEXT,
|
||||
current_period_end TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_add_ons (
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
add_on_id UUID NOT NULL REFERENCES add_ons(id) ON DELETE CASCADE,
|
||||
stripe_subscription_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'canceled')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, add_on_id)
|
||||
);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 3. Products
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
|
||||
inventory INTEGER NOT NULL DEFAULT 0,
|
||||
unit TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS products_tenant_idx ON products (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS products_active_idx ON products (tenant_id, active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_images (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
storage_key TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
alt_text TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS product_images_product_idx
|
||||
ON product_images (product_id, position);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 4. Stops
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stops (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
schedule JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'paused', 'closed')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS stops_tenant_idx ON stops (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS stops_status_idx ON stops (tenant_id, status);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 5. Customers
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
|
||||
email_opt_in BOOLEAN NOT NULL DEFAULT true,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS customers_tenant_idx ON customers (tenant_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS customers_tenant_email_idx
|
||||
ON customers (tenant_id, email)
|
||||
WHERE email IS NOT NULL;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 6. Orders
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
|
||||
total_cents INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'confirmed', 'fulfilled', 'canceled')),
|
||||
fulfillment TEXT NOT NULL
|
||||
CHECK (fulfillment IN ('pickup', 'ship', 'mixed')),
|
||||
notes TEXT,
|
||||
placed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS orders_tenant_idx ON orders (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS orders_customer_idx ON orders (customer_id);
|
||||
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (tenant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
|
||||
fulfillment TEXT NOT NULL
|
||||
CHECK (fulfillment IN ('pickup', 'ship'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS order_items_order_idx ON order_items (order_id);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 7. Brand settings
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brand_settings (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
brand_name TEXT NOT NULL,
|
||||
tagline TEXT,
|
||||
about_html TEXT,
|
||||
primary_color TEXT DEFAULT '#0F766E',
|
||||
logo_storage_key TEXT,
|
||||
hero_storage_key TEXT,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
custom_footer_text TEXT,
|
||||
feature_flags JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 8. Marketing
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS email_templates_tenant_idx ON email_templates (tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS campaigns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
template_id UUID REFERENCES email_templates(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'canceled')),
|
||||
scheduled_for TIMESTAMPTZ,
|
||||
sent_at TIMESTAMPTZ,
|
||||
recipient_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS campaigns_tenant_idx ON campaigns (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS campaigns_status_idx ON campaigns (tenant_id, status);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 9. Files
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
purpose TEXT,
|
||||
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS files_tenant_idx ON files (tenant_id);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 10. Audit log
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id UUID,
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS audit_log_tenant_idx
|
||||
ON audit_log (tenant_id, created_at DESC);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 11. RLS helpers
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION current_tenant_id()
|
||||
RETURNS UUID
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::UUID;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION is_platform_admin()
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE(NULLIF(current_setting('app.platform_admin', true), ''), 'false') = 'true';
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 12. Enable RLS on tenant-scoped tables
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE product_images ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE stops ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE brand_settings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE email_templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS does not apply to the table owner by default. We FORCE it so the
|
||||
-- application (which connects as the same user that owns the tables in
|
||||
-- dev) cannot accidentally bypass tenant isolation. In production, the
|
||||
-- application should connect as a non-owner role; this is belt-and-
|
||||
-- suspenders for the dev/local case.
|
||||
ALTER TABLE products FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE product_images FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE stops FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE customers FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE order_items FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE brand_settings FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE email_templates FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE campaigns FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE files FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE audit_log FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 13. RLS policies: tenant match OR platform admin
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Drop existing policies (idempotent — same migration may be re-applied)
|
||||
DROP POLICY IF EXISTS tenant_isolation ON products;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON product_images;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON stops;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON customers;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON orders;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON order_items;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON brand_settings;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON email_templates;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON campaigns;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON files;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON audit_log;
|
||||
|
||||
CREATE POLICY tenant_isolation ON products
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||
|
||||
CREATE POLICY tenant_isolation ON product_images
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM products p
|
||||
WHERE p.id = product_images.product_id
|
||||
AND (p.tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM products p
|
||||
WHERE p.id = product_images.product_id
|
||||
AND (p.tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_isolation ON stops
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||
|
||||
CREATE POLICY tenant_isolation ON customers
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||
|
||||
CREATE POLICY tenant_isolation ON orders
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||
|
||||
CREATE POLICY tenant_isolation ON order_items
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM orders o
|
||||
WHERE o.id = order_items.order_id
|
||||
AND (o.tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM orders o
|
||||
WHERE o.id = order_items.order_id
|
||||
AND (o.tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_isolation ON brand_settings
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||
|
||||
CREATE POLICY tenant_isolation ON email_templates
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||
|
||||
CREATE POLICY tenant_isolation ON campaigns
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id() OR is_platform_admin())
|
||||
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
|
||||
|
||||
CREATE POLICY tenant_isolation ON files
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL)
|
||||
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL);
|
||||
|
||||
CREATE POLICY tenant_isolation ON audit_log
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL)
|
||||
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL);
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- 14. updated_at triggers
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS tenants_updated_at ON tenants;
|
||||
CREATE TRIGGER tenants_updated_at BEFORE UPDATE ON tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS users_updated_at ON users;
|
||||
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS subscriptions_updated_at ON subscriptions;
|
||||
CREATE TRIGGER subscriptions_updated_at BEFORE UPDATE ON subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS products_updated_at ON products;
|
||||
CREATE TRIGGER products_updated_at BEFORE UPDATE ON products
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS stops_updated_at ON stops;
|
||||
CREATE TRIGGER stops_updated_at BEFORE UPDATE ON stops
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS customers_updated_at ON customers;
|
||||
CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS orders_updated_at ON orders;
|
||||
CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS brand_settings_updated_at ON brand_settings;
|
||||
CREATE TRIGGER brand_settings_updated_at BEFORE UPDATE ON brand_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS email_templates_updated_at ON email_templates;
|
||||
CREATE TRIGGER email_templates_updated_at BEFORE UPDATE ON email_templates
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS campaigns_updated_at ON campaigns;
|
||||
CREATE TRIGGER campaigns_updated_at BEFORE UPDATE ON campaigns
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 0002_admin_password.sql
|
||||
--
|
||||
-- Adds a `password_hash` column to `users` so the Auth.js Credentials
|
||||
-- provider can verify email + password against the database.
|
||||
--
|
||||
-- Idempotent: uses `ADD COLUMN IF NOT EXISTS`. The column is nullable
|
||||
-- because OAuth-only users (Google) never set a password.
|
||||
--
|
||||
-- The Credentials provider's `authorize` function is documented in
|
||||
-- `src/lib/auth.ts` — it queries this column and runs `verifyPassword`
|
||||
-- (see `src/lib/passwords.ts`) before returning the user.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||
|
||||
-- Update the updated_at trigger tracking — no new triggers needed since
|
||||
-- `users` already has `set_updated_at` from migration 0001.
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Audit log. Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
jsonb,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { tenants } from "./tenants";
|
||||
import { users } from "./tenants";
|
||||
|
||||
export const auditLog = pgTable(
|
||||
"audit_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id").references(() => tenants.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: uuid("user_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
action: text("action").notNull(),
|
||||
targetType: text("target_type"),
|
||||
targetId: uuid("target_id"),
|
||||
payload: jsonb("payload"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
tenantIdx: index("audit_log_tenant_idx").on(t.tenantId, t.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export type AuditLog = typeof auditLog.$inferSelect;
|
||||
export type NewAuditLog = typeof auditLog.$inferInsert;
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Billing tables: plans, add-ons, subscriptions, tenant_add_ons.
|
||||
* Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
primaryKey,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import {
|
||||
planCodeEnum,
|
||||
addOnCodeEnum,
|
||||
subscriptionStatusEnum,
|
||||
addOnStatusEnum,
|
||||
} from "./enums";
|
||||
import { tenants } from "./tenants";
|
||||
|
||||
export const plans = pgTable("plans", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
code: text("code", { enum: planCodeEnum }).notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
monthlyPriceCents: integer("monthly_price_cents").notNull(),
|
||||
maxUsers: integer("max_users").notNull(),
|
||||
maxProducts: integer("max_products").notNull(),
|
||||
maxStopsMonthly: integer("max_stops_monthly").notNull(),
|
||||
features: jsonb("features").notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
export const addOns = pgTable("add_ons", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
code: text("code", { enum: addOnCodeEnum }).notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
monthlyPriceCents: integer("monthly_price_cents").notNull(),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
export const subscriptions = pgTable("subscriptions", {
|
||||
tenantId: uuid("tenant_id")
|
||||
.primaryKey()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
planId: uuid("plan_id")
|
||||
.notNull()
|
||||
.references(() => plans.id),
|
||||
status: text("status", { enum: subscriptionStatusEnum })
|
||||
.notNull()
|
||||
.default("trialing"),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
export const tenantAddOns = pgTable(
|
||||
"tenant_add_ons",
|
||||
{
|
||||
tenantId: uuid("tenant_id")
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
addOnId: uuid("add_on_id")
|
||||
.notNull()
|
||||
.references(() => addOns.id, { onDelete: "cascade" }),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
status: text("status", { enum: addOnStatusEnum })
|
||||
.notNull()
|
||||
.default("active"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.tenantId, t.addOnId] }),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Plan = typeof plans.$inferSelect;
|
||||
export type NewPlan = typeof plans.$inferInsert;
|
||||
export type AddOn = typeof addOns.$inferSelect;
|
||||
export type NewAddOn = typeof addOns.$inferInsert;
|
||||
export type Subscription = typeof subscriptions.$inferSelect;
|
||||
export type NewSubscription = typeof subscriptions.$inferInsert;
|
||||
export type TenantAddOn = typeof tenantAddOns.$inferSelect;
|
||||
export type NewTenantAddOn = typeof tenantAddOns.$inferInsert;
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Brand settings. One row per tenant. Source of truth:
|
||||
* `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import { pgTable, uuid, text, jsonb, timestamp } from "drizzle-orm/pg-core";
|
||||
import { tenants } from "./tenants";
|
||||
|
||||
export const brandSettings = pgTable("brand_settings", {
|
||||
tenantId: uuid("tenant_id")
|
||||
.primaryKey()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
brandName: text("brand_name").notNull(),
|
||||
tagline: text("tagline"),
|
||||
aboutHtml: text("about_html"),
|
||||
primaryColor: text("primary_color").default("#0F766E"),
|
||||
logoStorageKey: text("logo_storage_key"),
|
||||
heroStorageKey: text("hero_storage_key"),
|
||||
contactEmail: text("contact_email"),
|
||||
contactPhone: text("contact_phone"),
|
||||
customFooterText: text("custom_footer_text"),
|
||||
featureFlags: jsonb("feature_flags").notNull().default({}),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
export type BrandSettings = typeof brandSettings.$inferSelect;
|
||||
export type NewBrandSettings = typeof brandSettings.$inferInsert;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Customers. Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
boolean,
|
||||
timestamp,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { tenants } from "./tenants";
|
||||
|
||||
export const customers = pgTable(
|
||||
"customers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id")
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
email: text("email"),
|
||||
phone: text("phone"),
|
||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||
emailOptIn: boolean("email_opt_in").notNull().default(true),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
tenantIdx: index("customers_tenant_idx").on(t.tenantId),
|
||||
emailIdx: uniqueIndex("customers_tenant_email_idx")
|
||||
.on(t.tenantId, t.email)
|
||||
.where(sql`${t.email} IS NOT NULL`),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Customer = typeof customers.$inferSelect;
|
||||
export type NewCustomer = typeof customers.$inferInsert;
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared enums for the SaaS schema. Mirrored in SQL as TEXT + CHECK.
|
||||
*
|
||||
* Usage:
|
||||
* import { tenantStatusEnum, type TenantStatus } from "@/db/schema/enums";
|
||||
* import { pgEnum } from "drizzle-orm/pg-core";
|
||||
*
|
||||
* export const tenantStatus = pgEnum("tenant_status", tenantStatusEnum);
|
||||
*/
|
||||
|
||||
export const tenantStatusEnum = [
|
||||
"trial",
|
||||
"active",
|
||||
"past_due",
|
||||
"suspended",
|
||||
"churned",
|
||||
] as const;
|
||||
export type TenantStatus = (typeof tenantStatusEnum)[number];
|
||||
|
||||
export const authProviderEnum = ["dev", "google", "email"] as const;
|
||||
export type AuthProvider = (typeof authProviderEnum)[number];
|
||||
|
||||
export const roleEnum = ["platform_admin", "brand_admin", "store_employee"] as const;
|
||||
export type Role = (typeof roleEnum)[number];
|
||||
|
||||
export const planCodeEnum = ["starter", "farm", "enterprise"] as const;
|
||||
export type PlanCode = (typeof planCodeEnum)[number];
|
||||
|
||||
export const addOnCodeEnum = [
|
||||
"wholesale_portal",
|
||||
"harvest_reach",
|
||||
"ai_tools",
|
||||
"water_log",
|
||||
"square_sync",
|
||||
"sms_campaigns",
|
||||
] as const;
|
||||
export type AddOnCode = (typeof addOnCodeEnum)[number];
|
||||
|
||||
export const subscriptionStatusEnum = [
|
||||
"trialing",
|
||||
"active",
|
||||
"past_due",
|
||||
"canceled",
|
||||
"incomplete",
|
||||
] as const;
|
||||
export type SubscriptionStatus = (typeof subscriptionStatusEnum)[number];
|
||||
|
||||
export const addOnStatusEnum = ["active", "canceled"] as const;
|
||||
export type AddOnStatus = (typeof addOnStatusEnum)[number];
|
||||
|
||||
export const stopStatusEnum = ["active", "paused", "closed"] as const;
|
||||
export type StopStatus = (typeof stopStatusEnum)[number];
|
||||
|
||||
export const orderStatusEnum = [
|
||||
"pending",
|
||||
"confirmed",
|
||||
"fulfilled",
|
||||
"canceled",
|
||||
] as const;
|
||||
export type OrderStatus = (typeof orderStatusEnum)[number];
|
||||
|
||||
export const fulfillmentEnum = ["pickup", "ship", "mixed"] as const;
|
||||
export type Fulfillment = (typeof fulfillmentEnum)[number];
|
||||
|
||||
export const itemFulfillmentEnum = ["pickup", "ship"] as const;
|
||||
export type ItemFulfillment = (typeof itemFulfillmentEnum)[number];
|
||||
|
||||
export const campaignStatusEnum = [
|
||||
"draft",
|
||||
"scheduled",
|
||||
"sending",
|
||||
"sent",
|
||||
"canceled",
|
||||
] as const;
|
||||
export type CampaignStatus = (typeof campaignStatusEnum)[number];
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Files. Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
bigint,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { tenants } from "./tenants";
|
||||
import { users } from "./tenants";
|
||||
|
||||
export const files = pgTable(
|
||||
"files",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id").references(() => tenants.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
storageKey: text("storage_key").notNull().unique(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
||||
purpose: text("purpose"),
|
||||
uploadedBy: uuid("uploaded_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
tenantIdx: index("files_tenant_idx").on(t.tenantId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type File = typeof files.$inferSelect;
|
||||
export type NewFile = typeof files.$inferInsert;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Schema barrel. Re-exports every Drizzle table + inferred row type.
|
||||
*
|
||||
* Usage:
|
||||
* import { products, type Product } from "@/db/schema";
|
||||
*/
|
||||
export * from "./enums";
|
||||
export * from "./tenants";
|
||||
export * from "./billing";
|
||||
export * from "./products";
|
||||
export * from "./stops";
|
||||
export * from "./customers";
|
||||
export * from "./orders";
|
||||
export * from "./brand";
|
||||
export * from "./marketing";
|
||||
export * from "./files";
|
||||
export * from "./audit";
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Marketing: email templates + campaigns.
|
||||
* Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { campaignStatusEnum } from "./enums";
|
||||
import { tenants } from "./tenants";
|
||||
|
||||
export const emailTemplates = pgTable(
|
||||
"email_templates",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id")
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
subject: text("subject").notNull(),
|
||||
bodyHtml: text("body_html").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
tenantIdx: index("email_templates_tenant_idx").on(t.tenantId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const campaigns = pgTable(
|
||||
"campaigns",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id")
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
templateId: uuid("template_id").references((): any => emailTemplates.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
status: text("status", { enum: campaignStatusEnum })
|
||||
.notNull()
|
||||
.default("draft"),
|
||||
scheduledFor: timestamp("scheduled_for", { withTimezone: true }),
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||
recipientCount: integer("recipient_count").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
tenantIdx: index("campaigns_tenant_idx").on(t.tenantId),
|
||||
statusIdx: index("campaigns_status_idx").on(t.tenantId, t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
export type EmailTemplate = typeof emailTemplates.$inferSelect;
|
||||
export type NewEmailTemplate = typeof emailTemplates.$inferInsert;
|
||||
export type Campaign = typeof campaigns.$inferSelect;
|
||||
export type NewCampaign = typeof campaigns.$inferInsert;
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Orders + order_items. Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { orderStatusEnum, fulfillmentEnum, itemFulfillmentEnum } from "./enums";
|
||||
import { tenants } from "./tenants";
|
||||
import { customers } from "./customers";
|
||||
import { products } from "./products";
|
||||
|
||||
export const orders = pgTable(
|
||||
"orders",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id")
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
customerId: uuid("customer_id").references(() => customers.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
totalCents: integer("total_cents").notNull().default(0),
|
||||
status: text("status", { enum: orderStatusEnum }).notNull().default("pending"),
|
||||
fulfillment: text("fulfillment", { enum: fulfillmentEnum }).notNull(),
|
||||
notes: text("notes"),
|
||||
placedAt: timestamp("placed_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
tenantIdx: index("orders_tenant_idx").on(t.tenantId),
|
||||
customerIdx: index("orders_customer_idx").on(t.customerId),
|
||||
statusIdx: index("orders_status_idx").on(t.tenantId, t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
export const orderItems = pgTable(
|
||||
"order_items",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
orderId: uuid("order_id")
|
||||
.notNull()
|
||||
.references(() => orders.id, { onDelete: "cascade" }),
|
||||
productId: uuid("product_id").references(() => products.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
quantity: integer("quantity").notNull(),
|
||||
priceCents: integer("price_cents").notNull(),
|
||||
fulfillment: text("fulfillment", { enum: itemFulfillmentEnum }).notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
orderIdx: index("order_items_order_idx").on(t.orderId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Order = typeof orders.$inferSelect;
|
||||
export type NewOrder = typeof orders.$inferInsert;
|
||||
export type OrderItem = typeof orderItems.$inferSelect;
|
||||
export type NewOrderItem = typeof orderItems.$inferInsert;
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Products + product_images. Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
boolean,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { tenants } from "./tenants";
|
||||
|
||||
export const products = pgTable(
|
||||
"products",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id")
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
priceCents: integer("price_cents").notNull(),
|
||||
inventory: integer("inventory").notNull().default(0),
|
||||
unit: text("unit"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
tenantIdx: index("products_tenant_idx").on(t.tenantId),
|
||||
activeIdx: index("products_active_idx").on(t.tenantId, t.active),
|
||||
}),
|
||||
);
|
||||
|
||||
export const productImages = pgTable(
|
||||
"product_images",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
productId: uuid("product_id")
|
||||
.notNull()
|
||||
.references(() => products.id, { onDelete: "cascade" }),
|
||||
storageKey: text("storage_key").notNull(),
|
||||
position: integer("position").notNull().default(0),
|
||||
altText: text("alt_text"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
productIdx: index("product_images_product_idx").on(t.productId, t.position),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Product = typeof products.$inferSelect;
|
||||
export type NewProduct = typeof products.$inferInsert;
|
||||
export type ProductImage = typeof productImages.$inferSelect;
|
||||
export type NewProductImage = typeof productImages.$inferInsert;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Stops. Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
jsonb,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { stopStatusEnum } from "./enums";
|
||||
import { tenants } from "./tenants";
|
||||
|
||||
export const stops = pgTable(
|
||||
"stops",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id")
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
address: text("address").notNull(),
|
||||
schedule: jsonb("schedule").notNull().default([]),
|
||||
status: text("status", { enum: stopStatusEnum }).notNull().default("active"),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
tenantIdx: index("stops_tenant_idx").on(t.tenantId),
|
||||
statusIdx: index("stops_status_idx").on(t.tenantId, t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Stop = typeof stops.$inferSelect;
|
||||
export type NewStop = typeof stops.$inferInsert;
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tenancy + auth tables. Source of truth: `db/migrations/0001_init.sql`.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
index,
|
||||
uniqueIndex,
|
||||
primaryKey,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
tenantStatusEnum,
|
||||
authProviderEnum,
|
||||
roleEnum,
|
||||
} from "./enums";
|
||||
|
||||
export const tenants = pgTable("tenants", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
status: text("status", { enum: tenantStatusEnum }).notNull().default("trial"),
|
||||
trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
email: text("email").unique(),
|
||||
name: text("name"),
|
||||
image: text("image"),
|
||||
/**
|
||||
* Bcrypt / scrypt / argon2 hash, or null for OAuth-only users.
|
||||
* Format is self-describing (algorithm$N$salt$hash) so we can
|
||||
* migrate to a stronger KDF later without losing existing hashes.
|
||||
* See `src/lib/passwords.ts` for the encoder/decoder.
|
||||
*/
|
||||
passwordHash: text("password_hash"),
|
||||
authProvider: text("auth_provider", { enum: authProviderEnum })
|
||||
.notNull()
|
||||
.default("dev"),
|
||||
authSubject: text("auth_subject"),
|
||||
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
authSubjectIdx: uniqueIndex("users_auth_subject_idx")
|
||||
.on(t.authProvider, t.authSubject)
|
||||
.where(sql`${t.authSubject} IS NOT NULL`),
|
||||
}),
|
||||
);
|
||||
|
||||
export const tenantUsers = pgTable(
|
||||
"tenant_users",
|
||||
{
|
||||
tenantId: uuid("tenant_id")
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
role: text("role", { enum: roleEnum }).notNull().default("brand_admin"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.tenantId, t.userId] }),
|
||||
userIdx: index("tenant_users_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Tenant = typeof tenants.$inferSelect;
|
||||
export type NewTenant = typeof tenants.$inferInsert;
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
export type TenantUser = typeof tenantUsers.$inferSelect;
|
||||
export type NewTenantUser = typeof tenantUsers.$inferInsert;
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Seed script. Idempotent — safe to re-run. Uses `ON CONFLICT (col) DO UPDATE`
|
||||
* (with a target) for tables that have a unique constraint; uses
|
||||
* `WHERE NOT EXISTS` for tables that don't.
|
||||
*
|
||||
* npm run db:seed
|
||||
*
|
||||
* Populates:
|
||||
* - 3 plans (Starter / Farm / Enterprise)
|
||||
* - 6 add-ons
|
||||
* - 2 tenants (Tuxedo, Indian River Direct)
|
||||
* - 1 platform-admin user + 1 brand_admin per tenant
|
||||
* - brand_settings per tenant
|
||||
* - sample products, stops, customers per tenant
|
||||
* - sample email templates + a draft campaign
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { Pool } from "pg";
|
||||
import { hashPassword } from "../src/lib/passwords";
|
||||
|
||||
// Seed needs elevated privileges (inserts into plans, add_ons, tenants —
|
||||
// tables that are intentionally not RLS-scoped but the runtime app user
|
||||
// can't create rows in without setting up GUCs we don't want to bother
|
||||
// with here). Use DATABASE_ADMIN_URL (the superuser) for the seed.
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_ADMIN_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// ── Plans ────────────────────────────────────────────────────────────
|
||||
const planRows = [
|
||||
{ code: "starter", name: "Starter", price: 4900, maxUsers: 1, maxProducts: 25, maxStopsMonthly: 10, features: ["products", "stops", "orders", "basic_pickup"] },
|
||||
{ code: "farm", name: "Farm", price: 14900, maxUsers: 5, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support"] },
|
||||
{ code: "enterprise", name: "Enterprise", price: 39900, maxUsers: 9999, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support", "ai_intelligence", "sms_campaigns", "square_sync", "water_log", "custom_development", "dedicated_sla"] },
|
||||
];
|
||||
for (const p of planRows) {
|
||||
await client.query(
|
||||
`INSERT INTO plans (code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
monthly_price_cents = EXCLUDED.monthly_price_cents,
|
||||
max_users = EXCLUDED.max_users,
|
||||
max_products = EXCLUDED.max_products,
|
||||
max_stops_monthly = EXCLUDED.max_stops_monthly,
|
||||
features = EXCLUDED.features`,
|
||||
[p.code, p.name, p.price, p.maxUsers, p.maxProducts, p.maxStopsMonthly, JSON.stringify(p.features)],
|
||||
);
|
||||
}
|
||||
console.log(`Seeded ${planRows.length} plans`);
|
||||
|
||||
// ── Add-ons ──────────────────────────────────────────────────────────
|
||||
const addOnRows = [
|
||||
{ code: "wholesale_portal", name: "Wholesale Portal", price: 9900, description: "Self-serve wholesale storefront with order approval." },
|
||||
{ code: "harvest_reach", name: "Harvest Reach", price: 7900, description: "Email + SMS campaigns and automations." },
|
||||
{ code: "ai_tools", name: "AI Intelligence Pack", price: 5900, description: "Smart pricing, product descriptions, and demand forecasting." },
|
||||
{ code: "water_log", name: "Water Log", price: 3900, description: "Irrigation tracking and reporting." },
|
||||
{ code: "square_sync", name: "Square Sync", price: 3900, description: "Sync inventory and sales to Square POS." },
|
||||
{ code: "sms_campaigns", name: "SMS Campaigns", price: 2900, description: "Send SMS blasts and automations." },
|
||||
];
|
||||
for (const a of addOnRows) {
|
||||
await client.query(
|
||||
`INSERT INTO add_ons (code, name, monthly_price_cents, description)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
monthly_price_cents = EXCLUDED.monthly_price_cents,
|
||||
description = EXCLUDED.description`,
|
||||
[a.code, a.name, a.price, a.description],
|
||||
);
|
||||
}
|
||||
console.log(`Seeded ${addOnRows.length} add-ons`);
|
||||
|
||||
// ── Tenants + users + content ────────────────────────────────────────
|
||||
const tenantsData = [
|
||||
{
|
||||
slug: "tuxedo",
|
||||
name: "Tuxedo Citrus",
|
||||
brandName: "Tuxedo Citrus Co.",
|
||||
tagline: "Sun-ripened citrus, delivered.",
|
||||
aboutHtml:
|
||||
"<p>Family-run citrus grove in the Indian River region. We grow, pack, and ship premium grapefruit, oranges, and specialty varieties to wholesale buyers across the country.</p>",
|
||||
primaryColor: "#F59E0B",
|
||||
contactEmail: "hello@tuxedocitrus.example",
|
||||
contactPhone: "(555) 010-2200",
|
||||
planCode: "farm",
|
||||
addOns: ["wholesale_portal", "harvest_reach"],
|
||||
},
|
||||
{
|
||||
slug: "indian-river-direct",
|
||||
name: "Indian River Direct",
|
||||
brandName: "Indian River Direct",
|
||||
tagline: "From our groves to your store.",
|
||||
aboutHtml:
|
||||
"<p>Direct-from-grove wholesale produce. Family farms, fair pricing, fast delivery.</p>",
|
||||
primaryColor: "#0F766E",
|
||||
contactEmail: "orders@indianriverdirect.example",
|
||||
contactPhone: "(555) 010-3300",
|
||||
planCode: "starter",
|
||||
addOns: ["wholesale_portal"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tenantsData) {
|
||||
// Upsert tenant
|
||||
const tenantRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO tenants (name, slug, status, trial_ends_at)
|
||||
VALUES ($1, $2, 'active', now() + interval '30 days')
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id`,
|
||||
[t.name, t.slug],
|
||||
);
|
||||
const tenantId = tenantRes.rows[0].id;
|
||||
|
||||
// Plan + subscription
|
||||
const planRes = await client.query<{ id: string }>(
|
||||
`SELECT id FROM plans WHERE code = $1`,
|
||||
[t.planCode],
|
||||
);
|
||||
const planId = planRes.rows[0].id;
|
||||
await client.query(
|
||||
`INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_end)
|
||||
VALUES ($1, $2, 'active', now() + interval '30 days')
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
plan_id = EXCLUDED.plan_id,
|
||||
status = EXCLUDED.status,
|
||||
current_period_end = EXCLUDED.current_period_end`,
|
||||
[tenantId, planId],
|
||||
);
|
||||
|
||||
// Add-ons (composite PK — ON CONFLICT has a target)
|
||||
for (const code of t.addOns) {
|
||||
const addOnRes = await client.query<{ id: string }>(
|
||||
`SELECT id FROM add_ons WHERE code = $1`,
|
||||
[code],
|
||||
);
|
||||
if (!addOnRes.rows[0]) continue;
|
||||
const addOnId = addOnRes.rows[0].id;
|
||||
await client.query(
|
||||
`INSERT INTO tenant_add_ons (tenant_id, add_on_id, status)
|
||||
VALUES ($1, $2, 'active')
|
||||
ON CONFLICT (tenant_id, add_on_id) DO NOTHING`,
|
||||
[tenantId, addOnId],
|
||||
);
|
||||
}
|
||||
|
||||
// Brand settings (PK is tenant_id)
|
||||
await client.query(
|
||||
`INSERT INTO brand_settings
|
||||
(tenant_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
brand_name = EXCLUDED.brand_name,
|
||||
tagline = EXCLUDED.tagline,
|
||||
about_html = EXCLUDED.about_html,
|
||||
primary_color = EXCLUDED.primary_color,
|
||||
contact_email = EXCLUDED.contact_email,
|
||||
contact_phone = EXCLUDED.contact_phone`,
|
||||
[
|
||||
tenantId, t.brandName, t.tagline, t.aboutHtml,
|
||||
t.primaryColor, t.contactEmail, t.contactPhone,
|
||||
],
|
||||
);
|
||||
|
||||
// Brand admin user
|
||||
const userRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO users (email, name, auth_provider, auth_subject)
|
||||
VALUES ($1, $2, 'dev', $3)
|
||||
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id`,
|
||||
[`admin@${t.slug}.example`, `${t.brandName} Admin`, `dev-${t.slug}-admin`],
|
||||
);
|
||||
const userId = userRes.rows[0].id;
|
||||
await client.query(
|
||||
`INSERT INTO tenant_users (tenant_id, user_id, role)
|
||||
VALUES ($1, $2, 'brand_admin')
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
// Sample products — use WHERE NOT EXISTS (no good unique target other than id)
|
||||
const products = [
|
||||
{ name: "Ruby Red Grapefruit", price: 2400, unit: "40 lb case", desc: "Sweet, juicy, seedless." },
|
||||
{ name: "Navel Oranges", price: 2200, unit: "40 lb case", desc: "Classic eating orange, easy-peel." },
|
||||
{ name: "Honeybells", price: 3200, unit: "20 lb case", desc: "Limited season — bell-shaped, super sweet." },
|
||||
{ name: "Tangelos", price: 2800, unit: "40 lb case", desc: "Tangerine-grapefruit hybrid." },
|
||||
];
|
||||
for (const p of products) {
|
||||
await client.query(
|
||||
`INSERT INTO products (tenant_id, name, description, price_cents, inventory, unit, active)
|
||||
SELECT $1, $2, $3, $4, 100, $5, true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM products WHERE tenant_id = $1 AND name = $2
|
||||
)`,
|
||||
[tenantId, p.name, p.desc, p.price, p.unit],
|
||||
);
|
||||
}
|
||||
|
||||
// Sample stops
|
||||
const stops = [
|
||||
{ name: "Downtown Farmers Market", address: "100 Main St", schedule: [{ day: "Saturday", time: "08:00" }] },
|
||||
{ name: "Eastside Pickup Hub", address: "555 Oak Ave", schedule: [{ day: "Wednesday", time: "16:00" }] },
|
||||
];
|
||||
for (const s of stops) {
|
||||
await client.query(
|
||||
`INSERT INTO stops (tenant_id, name, address, schedule, status)
|
||||
SELECT $1, $2, $3, $4::jsonb, 'active'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM stops WHERE tenant_id = $1 AND name = $2
|
||||
)`,
|
||||
[tenantId, s.name, s.address, JSON.stringify(s.schedule)],
|
||||
);
|
||||
}
|
||||
|
||||
// Sample customers (email has a unique index per tenant)
|
||||
const customers = [
|
||||
{ name: "Green Grocer Co.", email: "buyer@greengrocer.example", phone: "555-010-0010" },
|
||||
{ name: "Sunset Cafe", email: "orders@sunsetcafe.example", phone: "555-010-0020" },
|
||||
{ name: "Northside Co-op", email: "produce@northsidecoop.example", phone: "555-010-0030" },
|
||||
];
|
||||
for (const c of customers) {
|
||||
await client.query(
|
||||
`INSERT INTO customers (tenant_id, name, email, phone, sms_opt_in, email_opt_in)
|
||||
SELECT $1, $2, $3, $4, true, true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM customers WHERE tenant_id = $1 AND email = $3
|
||||
)`,
|
||||
[tenantId, c.name, c.email, c.phone],
|
||||
);
|
||||
}
|
||||
|
||||
// Sample email template (id-based, use WHERE NOT EXISTS)
|
||||
const tmplRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO email_templates (tenant_id, name, subject, body_html)
|
||||
SELECT $1, 'Weekly Availability', 'This week at the grove', '<h1>Fresh this week</h1><p>Hi {{name}}, our harvest is in. Reply to reserve.</p>'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM email_templates WHERE tenant_id = $1 AND name = 'Weekly Availability'
|
||||
)
|
||||
RETURNING id`,
|
||||
[tenantId],
|
||||
);
|
||||
if (tmplRes.rows[0]) {
|
||||
await client.query(
|
||||
`INSERT INTO campaigns (tenant_id, template_id, name, status)
|
||||
SELECT $1, $2, 'Welcome series — week 1', 'draft'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM campaigns WHERE tenant_id = $1 AND name = 'Welcome series — week 1'
|
||||
)`,
|
||||
[tenantId, tmplRes.rows[0].id],
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(`Seeded ${tenantsData.length} tenants with sample data`);
|
||||
|
||||
// ── Platform admin (seeded for dev sign-in via Credentials provider) ─
|
||||
// email: admin@route-commerce.local
|
||||
// password: admin (override with SEED_ADMIN_PASSWORD)
|
||||
//
|
||||
// The user is attached to the Tuxedo tenant with the `platform_admin`
|
||||
// role so `getAdminUser()` resolves it and grants cross-tenant
|
||||
// visibility. To rotate the password, re-run `npm run db:seed`
|
||||
// (the UPSERT updates `password_hash`).
|
||||
const tuxedoRes = await client.query<{ id: string }>(
|
||||
`SELECT id FROM tenants WHERE slug = 'tuxedo'`,
|
||||
);
|
||||
const tuxedoId = tuxedoRes.rows[0]?.id;
|
||||
if (tuxedoId) {
|
||||
const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "admin";
|
||||
const passwordHash = hashPassword(adminPassword);
|
||||
const adminRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO users (email, name, auth_provider, auth_subject, password_hash)
|
||||
VALUES ($1, 'Platform Admin', 'dev', 'dev-platform-admin', $2)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
password_hash = EXCLUDED.password_hash
|
||||
RETURNING id`,
|
||||
["admin@route-commerce.local", passwordHash],
|
||||
);
|
||||
const adminId = adminRes.rows[0].id;
|
||||
await client.query(
|
||||
`INSERT INTO tenant_users (tenant_id, user_id, role)
|
||||
VALUES ($1, $2, 'platform_admin')
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
[tuxedoId, adminId],
|
||||
);
|
||||
console.log(
|
||||
`Seeded platform admin: admin@route-commerce.local / ${adminPassword === "admin" ? "admin" : "(SEED_ADMIN_PASSWORD)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log("✅ Seed complete");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => pool.end())
|
||||
.catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
pool.end();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Drizzle Kit config. Used by `drizzle-kit generate` / `drizzle-kit push`
|
||||
* for future migrations. The schema in `db/migrations/0001_init.sql` is
|
||||
* the source of truth for v1; subsequent migrations can be generated
|
||||
* from changes to `db/schema/*.ts` and committed alongside the SQL.
|
||||
*/
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./db/schema/index.ts",
|
||||
out: "./db/migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
+20
-8
@@ -3,29 +3,35 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
|
||||
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"migrate": "node supabase/push-migrations.js",
|
||||
"migrate:one": "node supabase/push-migrations.js",
|
||||
"migrate": "node scripts/migrate.js",
|
||||
"migrate:one": "node scripts/migrate.js",
|
||||
"db:migrate": "node scripts/migrate.js",
|
||||
"db:seed": "tsx db/seed.ts",
|
||||
"db:reset": "node scripts/db-reset.js",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"type-check": "npx tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:e2e": "playwright test --project=local",
|
||||
"test:e2e:prod": "PLAYWRIGHT_PROD=1 playwright test",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.96.0",
|
||||
"@auth/pg-adapter": "^1.11.2",
|
||||
"@clerk/nextjs": "^7.4.2",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@sentry/nextjs": "^10.55.0",
|
||||
"@stripe/react-stripe-js": "^3.10.0",
|
||||
"@stripe/stripe-js": "^5.10.0",
|
||||
"@supabase/ssr": "^0.10.2",
|
||||
"@supabase/supabase-js": "^2.105.3",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.38.0",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"gsap": "^3.15.0",
|
||||
@@ -57,13 +63,19 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.5",
|
||||
"jsdom": "^25.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"playwright": "^1.59.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.9.3"
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"overrides": "{}"
|
||||
}
|
||||
|
||||
+11
-5
@@ -1,6 +1,9 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import path from "path";
|
||||
|
||||
const LOCAL_BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||
const PROD_BASE = "https://route-commerce-platform.vercel.app";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: false,
|
||||
@@ -9,16 +12,19 @@ export default defineConfig({
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "https://route-commerce-platform.vercel.app",
|
||||
baseURL: LOCAL_BASE,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "local",
|
||||
use: { ...devices["Desktop Chrome"], baseURL: LOCAL_BASE },
|
||||
},
|
||||
{
|
||||
name: "production",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: "https://route-commerce-platform.vercel.app",
|
||||
},
|
||||
// `PLAYWRIGHT_PROD=1 npx playwright test` to run against the live site.
|
||||
testMatch: /.*\.prod\.spec\.ts$/,
|
||||
use: { ...devices["Desktop Chrome"], baseURL: PROD_BASE },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DESTRUCTIVE: drops and recreates the `route_commerce` database, then
|
||||
* applies all migrations and seeds.
|
||||
*
|
||||
* Usage:
|
||||
* npm run db:reset
|
||||
*
|
||||
* Use only in dev. Requires sudo-less access to a postgres superuser.
|
||||
*/
|
||||
require("dotenv").config({ path: ".env.local" });
|
||||
const { execSync } = require("node:child_process");
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("❌ DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const m = url.match(/postgres(?:ql)?:\/\/([^:]+):[^@]+@([^:]+):(\d+)\/(.+)/);
|
||||
if (!m) {
|
||||
console.error("❌ Could not parse DATABASE_URL");
|
||||
process.exit(1);
|
||||
}
|
||||
const [, user, host, port, db] = m;
|
||||
const adminUrl = url.replace(/\/[^/]+$/, "/postgres");
|
||||
|
||||
console.log(`⚠️ DROPPING and recreating ${db} on ${host}:${port} as ${user}`);
|
||||
try {
|
||||
execSync(
|
||||
`psql "${adminUrl}" -c "DROP DATABASE IF EXISTS ${db};" -c "CREATE DATABASE ${db} OWNER ${user};"`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
console.log("✓ Database recreated");
|
||||
} catch (err) {
|
||||
console.error("❌ Failed to drop/create database. Try with sudo:");
|
||||
console.error(
|
||||
` sudo -u postgres psql -c "DROP DATABASE IF EXISTS ${db};" -c "CREATE DATABASE ${db} OWNER ${user};"`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
execSync("npm run db:migrate", { stdio: "inherit" });
|
||||
execSync("npm run db:seed", { stdio: "inherit" });
|
||||
console.log("✅ Database reset, migrated, and seeded");
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Apply Postgres migrations from `db/migrations/*.sql` in lexical order.
|
||||
* Wraps the whole thing in a transaction; tracks applied files in
|
||||
* `_migrations` so re-runs are safe.
|
||||
*
|
||||
* Usage:
|
||||
* npm run db:migrate
|
||||
*
|
||||
* Replaces the old `supabase/push-migrations.js` — that script was
|
||||
* hardcoded to a Supabase URL. This one reads `DATABASE_URL` directly.
|
||||
*/
|
||||
require("dotenv").config({ path: ".env.local" });
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Client } = require("pg");
|
||||
|
||||
const MIGRATIONS_DIR = path.join(__dirname, "..", "db", "migrations");
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("❌ DATABASE_URL (or DATABASE_ADMIN_URL) is not set in .env.local");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({ connectionString: url });
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
const files = fs
|
||||
.readdirSync(MIGRATIONS_DIR)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("No migration files found in db/migrations/");
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows: applied } = await client.query(
|
||||
`SELECT filename FROM _migrations`,
|
||||
);
|
||||
const appliedSet = new Set(applied.map((r) => r.filename));
|
||||
|
||||
let appliedNow = 0;
|
||||
for (const file of files) {
|
||||
if (appliedSet.has(file)) {
|
||||
console.log(`✓ ${file} (already applied)`);
|
||||
continue;
|
||||
}
|
||||
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8");
|
||||
console.log(`→ Applying ${file}...`);
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query(sql);
|
||||
await client.query(`INSERT INTO _migrations (filename) VALUES ($1)`, [
|
||||
file,
|
||||
]);
|
||||
await client.query("COMMIT");
|
||||
appliedNow += 1;
|
||||
console.log(`✓ ${file}`);
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error(`✗ ${file} failed:`, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n✅ Done. ${appliedNow} new migration(s) applied. ${
|
||||
files.length - appliedNow
|
||||
} already current.`,
|
||||
);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Update the current user's Supabase auth password.
|
||||
*
|
||||
* Reads the Auth.js v5 session to identify the user. The session's
|
||||
* `user.id` is either:
|
||||
* - a Supabase auth user id (UUID) for email/password sign-ins
|
||||
* - a Google `sub` (non-UUID) for Google sign-ins — these are not
|
||||
* provisioned in Supabase auth, so the RPC will reject them. Google
|
||||
* users must be provisioned in Supabase auth separately.
|
||||
*
|
||||
* The password update itself runs as a SECURITY DEFINER PL/pgSQL function
|
||||
* (`update_user_password`) inside the database, called directly via the
|
||||
* shared `pg` pool. No Supabase REST hop required.
|
||||
*/
|
||||
export async function updatePasswordAction(
|
||||
newPassword: string
|
||||
): Promise<{ error?: string }> {
|
||||
const cookieStore = await cookies();
|
||||
const uid =
|
||||
cookieStore.get("rc_auth_uid")?.value ??
|
||||
cookieStore.get("rc_uid")?.value;
|
||||
|
||||
): Promise<{ error?: string; userId?: string }> {
|
||||
const session = await auth();
|
||||
const uid = session?.user?.id;
|
||||
if (!uid) {
|
||||
return { error: "Not authenticated. Please log in again." };
|
||||
}
|
||||
|
||||
const service = createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
);
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!UUID_REGEX.test(uid)) {
|
||||
return {
|
||||
error:
|
||||
"Password change is not available for social sign-in accounts. Please contact an admin.",
|
||||
};
|
||||
}
|
||||
|
||||
const { error } = await service.rpc("update_user_password", {
|
||||
p_user_id: uid,
|
||||
p_password: newPassword,
|
||||
});
|
||||
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
// The RPC is SECURITY DEFINER and returns a single row (or raises).
|
||||
// We SELECT it (rather than SELECT update_user_password(...)) so the
|
||||
// call stays a normal parameterized query and we can read the result.
|
||||
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
|
||||
return { userId: uid };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to update password.";
|
||||
return { error: message };
|
||||
}
|
||||
}
|
||||
|
||||
+243
-539
@@ -1,18 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
import { supabase as publicSupabase } from "@/lib/supabase";
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { pool, query } from "@/lib/db";
|
||||
import { getMockTableData, mockBrands } from "@/lib/mock-data";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
export type AdminUserRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_id: string | null;
|
||||
display_name: string | null;
|
||||
email: string;
|
||||
phone_number: string | null;
|
||||
@@ -75,169 +72,17 @@ export type UpdateAdminUserInput = {
|
||||
phone_number?: string | null;
|
||||
};
|
||||
|
||||
// ─── SSR client for authenticated requests ─────────────────────────────────
|
||||
|
||||
async function getAuthClient() {
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
// Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets
|
||||
// cookies that arrive in the header but NOT in next/headers cookies()).
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const allCookies = cookieHeader.split(";").map(c => c.trim());
|
||||
const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
|
||||
const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null;
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() { return cookieStore.getAll(); },
|
||||
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));
|
||||
},
|
||||
},
|
||||
});
|
||||
return { supabase, response, rcAuthUid };
|
||||
}
|
||||
|
||||
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
|
||||
const { supabase, rcAuthUid } = await getAuthClient();
|
||||
|
||||
// Dev force-login UID bypasses Supabase auth entirely
|
||||
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
return { data: null, error: null }; // let the action proceed without auth check
|
||||
}
|
||||
|
||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||
if (userError || !userData.user) {
|
||||
return { data: null, error: "Not authenticated" };
|
||||
}
|
||||
const { data, error } = await supabase.rpc(fn, params as Record<string, unknown>);
|
||||
if (error) { /* RPC error handled silently */ }
|
||||
return { data: data as T, error: error ? error.message : null };
|
||||
}
|
||||
|
||||
// ─── Service role client (server-only, never exposed to browser) ───────────
|
||||
|
||||
function getServiceClient() {
|
||||
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!roleKey) {
|
||||
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set. Cannot use service role in dev path.");
|
||||
}
|
||||
return createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
roleKey,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dev-only path — uses service role to create auth user + admin_users ──
|
||||
|
||||
async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return { user: null, error: "Dev path not available in production" };
|
||||
}
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
if (!devSession || devSession !== "platform_admin") {
|
||||
return { user: null, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
const service = getServiceClient();
|
||||
|
||||
// Create auth user with the provided password
|
||||
const { data: authUser, error: authError } = await service.auth.admin.createUser({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
},
|
||||
});
|
||||
if (authError || !authUser.user) {
|
||||
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
||||
}
|
||||
|
||||
// Insert into admin_users
|
||||
const { data: inserted, error: insertError } = await service
|
||||
.from("admin_users")
|
||||
.insert({
|
||||
user_id: authUser.user.id,
|
||||
role: input.role,
|
||||
brand_id: input.brand_id,
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
can_manage_products: input.flags.can_manage_products ?? false,
|
||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||
can_manage_users: input.flags.can_manage_users ?? false,
|
||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||
active: true,
|
||||
must_change_password: input.mustChangePassword ?? true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
return { user: null, error: insertError.message };
|
||||
}
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||
await sendWelcomeEmail({
|
||||
to: input.email,
|
||||
name: input.display_name || input.email.split("@")[0],
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
brandName: "Tuxedo Corn",
|
||||
tempPassword: input.password,
|
||||
});
|
||||
} catch (e) {
|
||||
// welcome email failed silently
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: inserted.id,
|
||||
user_id: inserted.user_id,
|
||||
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
|
||||
email: input.email,
|
||||
phone_number: inserted.phone_number ?? input.phone_number ?? null,
|
||||
role: inserted.role,
|
||||
brand_id: inserted.brand_id,
|
||||
brand_name: null,
|
||||
can_manage_products: inserted.can_manage_products,
|
||||
can_manage_stops: inserted.can_manage_stops,
|
||||
can_manage_orders: inserted.can_manage_orders,
|
||||
can_manage_pickup: inserted.can_manage_pickup,
|
||||
can_manage_messages: inserted.can_manage_messages,
|
||||
can_manage_refunds: inserted.can_manage_refunds,
|
||||
can_manage_users: inserted.can_manage_users,
|
||||
can_manage_water_log: inserted.can_manage_water_log,
|
||||
can_manage_reports: inserted.can_manage_reports,
|
||||
active: inserted.active,
|
||||
must_change_password: inserted.must_change_password ?? true,
|
||||
created_at: inserted.created_at,
|
||||
last_login: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
// ─── Row mapping ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `admin_users` schema (after migration 204 + 034 + 037):
|
||||
// id, user_id, display_name, email, phone_number, role, brand_id,
|
||||
// can_manage_<X> (BOOLEAN each), active, must_change_password,
|
||||
// created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
|
||||
|
||||
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
user_id: String(row.user_id ?? ""),
|
||||
user_id: (row.user_id as string | null) ?? null,
|
||||
display_name: (row.display_name as string | null) ?? null,
|
||||
email: String(row.email ?? ""),
|
||||
phone_number: (row.phone_number as string | null) ?? null,
|
||||
@@ -260,413 +105,272 @@ function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Dev path helpers (service role, local only) ───────────────────────────
|
||||
// ─── Welcome email (best-effort) ────────────────────────────────────────────
|
||||
|
||||
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||
const service = getServiceClient();
|
||||
|
||||
// Ensure caller has an admin_users record
|
||||
if (callerUid) {
|
||||
const { data: existing } = await service
|
||||
.from("admin_users")
|
||||
.select("id")
|
||||
.eq("user_id", callerUid)
|
||||
.maybeSingle();
|
||||
|
||||
if (!existing) {
|
||||
// auto-creating admin_users for uid
|
||||
const { data: authData } = await service.auth.admin.listUsers();
|
||||
const authUser = authData?.users?.find((u) => u.id === callerUid);
|
||||
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata;
|
||||
await service.from("admin_users").insert({
|
||||
user_id: callerUid,
|
||||
role: "platform_admin",
|
||||
brand_id: null,
|
||||
display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin",
|
||||
phone_number: (meta?.phone_number as string | null) ?? null,
|
||||
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,
|
||||
active: true,
|
||||
must_change_password: false,
|
||||
});
|
||||
}
|
||||
async function sendWelcomeEmailSafe(input: {
|
||||
to: string;
|
||||
name: string;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||
password: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||
await sendWelcomeEmail({
|
||||
to: input.to,
|
||||
name: input.name,
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
brandName: "Tuxedo Corn",
|
||||
tempPassword: input.password,
|
||||
});
|
||||
} catch {
|
||||
// welcome email is best-effort; never block user creation
|
||||
}
|
||||
|
||||
// Fetch all admin_users rows (no RLS for service role)
|
||||
const { data: adminRows, error: adminError } = await service
|
||||
.from("admin_users")
|
||||
.select(`
|
||||
id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
|
||||
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,
|
||||
brands (name)
|
||||
`)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (adminError) return { users: [], error: adminError.message };
|
||||
|
||||
// Fetch auth user details via service role admin API
|
||||
const { data: authData, error: authError } = await service.auth.admin.listUsers();
|
||||
if (authError) return { users: [], error: authError.message };
|
||||
|
||||
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
|
||||
(authData?.users ?? []).forEach((u) => {
|
||||
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
|
||||
authMap[user.id] = {
|
||||
email: user.email ?? "",
|
||||
display_name: (user.user_metadata?.display_name as string | null) ?? (user.user_metadata?.full_name as string | null) ?? null,
|
||||
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
|
||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
||||
return {
|
||||
...mapUserRow(r),
|
||||
email: authInfo.email || "No Email",
|
||||
display_name: authInfo.display_name ?? null,
|
||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
||||
brand_name: r.brands?.name ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return { users, error: null };
|
||||
}
|
||||
|
||||
function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { id: string; email?: string; user_metadata?: Record<string, unknown> }[]): { users: AdminUserRow[]; error: string | null } {
|
||||
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
|
||||
(authUsers ?? []).forEach((u) => {
|
||||
authMap[u.id] = {
|
||||
email: u.email ?? "",
|
||||
display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null,
|
||||
phone_number: (u.user_metadata?.phone_number as string | null) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const users: AdminUserRow[] = adminRows.map((row) => {
|
||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
||||
return {
|
||||
...mapUserRow(r),
|
||||
email: authInfo.email || "No Email",
|
||||
display_name: authInfo.display_name ?? null,
|
||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
||||
brand_name: r.brands?.name ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return { users, error: null };
|
||||
}
|
||||
|
||||
// ─── Production admin actions (require real Supabase auth) ─────────────────
|
||||
|
||||
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||
// ─── Public actions ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
let filteredUsers = mockUsers;
|
||||
if (brandId) {
|
||||
filteredUsers = mockUsers.filter(u => u.brand_id === brandId);
|
||||
}
|
||||
return { users: filteredUsers, error: null };
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
|
||||
// Read rc_auth_uid for force-login check
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
// In development mode: ALL requests with a valid rc_auth_uid use the dev/service path.
|
||||
// This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation.
|
||||
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
|
||||
return devListAdminUsers(rcAuthUid);
|
||||
}
|
||||
|
||||
// Dev session cookie (platform_admin/brand_admin) — always use service role path
|
||||
const isDevAdmin = process.env.NODE_ENV !== "production" && (
|
||||
devSession === "platform_admin" || devSession === "brand_admin" || rcAuthUid === DEV_FORCE_UID
|
||||
);
|
||||
if (isDevAdmin) {
|
||||
return devListAdminUsers(rcAuthUid ?? undefined);
|
||||
}
|
||||
|
||||
// Production path: try authenticated RPC first, fall back to service role if not authenticated
|
||||
const result = await callRpcWithAuth<AdminUserRow[]>("get_admin_users", { p_brand_id: brandId ?? null });
|
||||
if (result.error === "Not authenticated" && rcAuthUid) {
|
||||
// No Supabase session token in browser — use service role with rc_auth_uid
|
||||
const service = getServiceClient();
|
||||
const { data: adminRows, error: adminError } = await service
|
||||
.from("admin_users")
|
||||
.select(`id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
|
||||
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,
|
||||
brands (name)`)
|
||||
.order("created_at", { ascending: false });
|
||||
if (adminError) return { users: [], error: adminError.message };
|
||||
const { data: authData } = await service.auth.admin.listUsers();
|
||||
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
|
||||
(authData?.users ?? []).forEach((u) => {
|
||||
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
|
||||
authMap[user.id] = {
|
||||
email: user.email ?? "",
|
||||
display_name: (user.user_metadata?.display_name as string | null) ?? null,
|
||||
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
|
||||
};
|
||||
});
|
||||
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
|
||||
const r = row as Record<string, unknown> & { brands?: { name?: string } };
|
||||
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
|
||||
return {
|
||||
...mapUserRow(r),
|
||||
email: authInfo.email || "No Email",
|
||||
display_name: authInfo.display_name ?? null,
|
||||
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
|
||||
brand_name: r.brands?.name ?? null,
|
||||
};
|
||||
});
|
||||
return { users, error: null };
|
||||
}
|
||||
return { users: result.data ?? [], error: result.error };
|
||||
}
|
||||
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
// Read auth context
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
|
||||
// DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
return devCreateAdminUser(input);
|
||||
}
|
||||
|
||||
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
|
||||
|
||||
// Dev path: use service role to create user without Supabase auth session
|
||||
if (isDevAdmin) {
|
||||
return devCreateAdminUser(input);
|
||||
}
|
||||
|
||||
// Production path — use service role directly (bypasses Supabase JWT auth)
|
||||
// rc_auth_uid cookie proves the admin is logged in; service role creates the account
|
||||
if (rcAuthUid) {
|
||||
const service = getServiceClient();
|
||||
|
||||
// Create auth user
|
||||
const { data: authUser, error: authError } = await service.auth.admin.createUser({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
},
|
||||
});
|
||||
if (authError || !authUser.user) {
|
||||
return { user: null, error: authError?.message ?? "Failed to create auth user" };
|
||||
}
|
||||
|
||||
// Insert into admin_users
|
||||
const { data: inserted, error: insertError } = await service
|
||||
.from("admin_users")
|
||||
.insert({
|
||||
user_id: authUser.user.id,
|
||||
role: input.role,
|
||||
brand_id: input.brand_id,
|
||||
display_name: input.display_name || input.email.split("@")[0],
|
||||
phone_number: input.phone_number ?? null,
|
||||
can_manage_products: input.flags.can_manage_products ?? false,
|
||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||
can_manage_users: input.flags.can_manage_users ?? false,
|
||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||
active: true,
|
||||
must_change_password: input.mustChangePassword ?? true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) return { user: null, error: insertError.message };
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||
await sendWelcomeEmail({
|
||||
to: input.email,
|
||||
name: input.display_name || input.email.split("@")[0],
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
brandName: "Tuxedo Corn",
|
||||
tempPassword: input.password,
|
||||
});
|
||||
} catch (e) {
|
||||
// welcome email failed silently
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: inserted.id,
|
||||
user_id: inserted.user_id,
|
||||
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
|
||||
email: input.email,
|
||||
phone_number: inserted.phone_number ?? input.phone_number ?? null,
|
||||
role: inserted.role,
|
||||
brand_id: inserted.brand_id,
|
||||
brand_name: null,
|
||||
can_manage_products: inserted.can_manage_products,
|
||||
can_manage_stops: inserted.can_manage_stops,
|
||||
can_manage_orders: inserted.can_manage_orders,
|
||||
can_manage_pickup: inserted.can_manage_pickup,
|
||||
can_manage_messages: inserted.can_manage_messages,
|
||||
can_manage_refunds: inserted.can_manage_refunds,
|
||||
can_manage_users: inserted.can_manage_users,
|
||||
can_manage_water_log: inserted.can_manage_water_log,
|
||||
can_manage_reports: inserted.can_manage_reports,
|
||||
active: inserted.active,
|
||||
must_change_password: inserted.must_change_password ?? true,
|
||||
created_at: inserted.created_at,
|
||||
last_login: null,
|
||||
},
|
||||
users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { user: null, error: "Not authenticated" };
|
||||
try {
|
||||
const sql = brandId
|
||||
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
||||
au.role, au.brand_id, b.name AS brand_name,
|
||||
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
|
||||
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
|
||||
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
|
||||
au.active, au.must_change_password, au.created_at, au.last_login
|
||||
FROM admin_users au
|
||||
LEFT JOIN brands b ON b.id = au.brand_id
|
||||
WHERE au.brand_id = $1
|
||||
ORDER BY au.created_at DESC`
|
||||
: `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
||||
au.role, au.brand_id, b.name AS brand_name,
|
||||
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
|
||||
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
|
||||
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
|
||||
au.active, au.must_change_password, au.created_at, au.last_login
|
||||
FROM admin_users au
|
||||
LEFT JOIN brands b ON b.id = au.brand_id
|
||||
ORDER BY au.created_at DESC`;
|
||||
const { rows } = await query<Record<string, unknown>>(sql, brandId ? [brandId] : []);
|
||||
return { users: rows.map(mapUserRow), error: null };
|
||||
} catch (err) {
|
||||
return { users: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const newRow: AdminUserRow = {
|
||||
id: `mock-${Date.now()}`,
|
||||
user_id: null,
|
||||
display_name: input.display_name ?? input.email.split("@")[0],
|
||||
email: input.email,
|
||||
phone_number: input.phone_number ?? null,
|
||||
role: input.role,
|
||||
brand_id: input.brand_id,
|
||||
brand_name: null,
|
||||
can_manage_products: input.flags.can_manage_products ?? false,
|
||||
can_manage_stops: input.flags.can_manage_stops ?? false,
|
||||
can_manage_orders: input.flags.can_manage_orders ?? false,
|
||||
can_manage_pickup: input.flags.can_manage_pickup ?? false,
|
||||
can_manage_messages: input.flags.can_manage_messages ?? false,
|
||||
can_manage_refunds: input.flags.can_manage_refunds ?? false,
|
||||
can_manage_users: input.flags.can_manage_users ?? false,
|
||||
can_manage_water_log: input.flags.can_manage_water_log ?? false,
|
||||
can_manage_reports: input.flags.can_manage_reports ?? false,
|
||||
active: true,
|
||||
must_change_password: input.mustChangePassword ?? true,
|
||||
created_at: new Date().toISOString(),
|
||||
last_login: null,
|
||||
};
|
||||
mockUsers.push(newRow);
|
||||
return { user: newRow, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// No Supabase Auth — `user_id` stays NULL until the user signs in
|
||||
// via Auth.js and `get_admin_user_for_session` matches them by
|
||||
// `auth_subject` / `email`. We just insert the row.
|
||||
const f = input.flags;
|
||||
const { rows } = await query<Record<string, unknown>>(
|
||||
`INSERT INTO admin_users
|
||||
(user_id, display_name, email, phone_number, role, brand_id,
|
||||
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,
|
||||
active, must_change_password, auth_provider, auth_subject)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'pending',$17)
|
||||
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
||||
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,
|
||||
active, must_change_password, created_at, last_login`,
|
||||
[
|
||||
null,
|
||||
input.display_name ?? input.email.split("@")[0],
|
||||
input.email.toLowerCase(),
|
||||
input.phone_number ?? null,
|
||||
input.role,
|
||||
input.brand_id,
|
||||
f.can_manage_products ?? false,
|
||||
f.can_manage_stops ?? false,
|
||||
f.can_manage_orders ?? false,
|
||||
f.can_manage_pickup ?? false,
|
||||
f.can_manage_messages ?? false,
|
||||
f.can_manage_refunds ?? false,
|
||||
f.can_manage_users ?? false,
|
||||
f.can_manage_water_log ?? false,
|
||||
f.can_manage_reports ?? false,
|
||||
input.mustChangePassword ?? true,
|
||||
input.email.toLowerCase(),
|
||||
],
|
||||
);
|
||||
if (!rows[0]) return { user: null, error: "Insert returned no row" };
|
||||
|
||||
await sendWelcomeEmailSafe({
|
||||
to: input.email,
|
||||
name: input.display_name ?? input.email.split("@")[0],
|
||||
role: input.role,
|
||||
password: input.password,
|
||||
});
|
||||
|
||||
return { user: mapUserRow(rows[0]), error: null };
|
||||
} catch (err) {
|
||||
return { user: null, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
// Dev bypass check
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
const service = getServiceClient();
|
||||
const { data, error } = await service
|
||||
.from("admin_users")
|
||||
.update({
|
||||
role: input.role ?? undefined,
|
||||
brand_id: input.brand_id ?? undefined,
|
||||
can_manage_products: input.flags?.can_manage_products ?? undefined,
|
||||
can_manage_stops: input.flags?.can_manage_stops ?? undefined,
|
||||
can_manage_orders: input.flags?.can_manage_orders ?? undefined,
|
||||
can_manage_pickup: input.flags?.can_manage_pickup ?? undefined,
|
||||
can_manage_messages: input.flags?.can_manage_messages ?? undefined,
|
||||
can_manage_refunds: input.flags?.can_manage_refunds ?? undefined,
|
||||
can_manage_users: input.flags?.can_manage_users ?? undefined,
|
||||
can_manage_water_log: input.flags?.can_manage_water_log ?? undefined,
|
||||
can_manage_reports: input.flags?.can_manage_reports ?? undefined,
|
||||
active: input.active ?? undefined,
|
||||
display_name: input.display_name ?? null,
|
||||
phone_number: input.phone_number ?? null,
|
||||
})
|
||||
.eq("id", input.id)
|
||||
.select()
|
||||
.single();
|
||||
if (error) return { user: null, error: error.message };
|
||||
return { user: mapUserRow(data), error: null };
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const idx = mockUsers.findIndex((u) => u.id === input.id);
|
||||
if (idx === -1) return { user: null, error: "User not found" };
|
||||
const merged: AdminUserRow = { ...mockUsers[idx] };
|
||||
if (input.role !== undefined) merged.role = input.role;
|
||||
if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
|
||||
if (input.active !== undefined) merged.active = input.active;
|
||||
if (input.display_name !== undefined) merged.display_name = input.display_name;
|
||||
if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
|
||||
if (input.flags) {
|
||||
for (const [k, v] of Object.entries(input.flags)) {
|
||||
if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
mockUsers[idx] = merged;
|
||||
return { user: merged, error: null };
|
||||
}
|
||||
|
||||
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
|
||||
p_id: input.id,
|
||||
p_role: input.role ?? null,
|
||||
p_brand_id: input.brand_id ?? null,
|
||||
p_flags: input.flags ?? null,
|
||||
p_active: input.active ?? null,
|
||||
p_display_name: input.display_name ?? null,
|
||||
p_phone_number: input.phone_number ?? null,
|
||||
});
|
||||
const rows = result.data as AdminUserRow[] | null;
|
||||
return { user: rows?.[0] ?? null, error: result.error };
|
||||
try {
|
||||
// Build a partial SET clause. Each `can_manage_*` column is set
|
||||
// individually — the input's `flags` partial is spread across them.
|
||||
const sets: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); };
|
||||
|
||||
if (input.role !== undefined) push("role", input.role);
|
||||
if (input.brand_id !== undefined) push("brand_id", input.brand_id);
|
||||
if (input.active !== undefined) push("active", input.active);
|
||||
if (input.display_name !== undefined) push("display_name", input.display_name);
|
||||
if (input.phone_number !== undefined) push("phone_number", input.phone_number);
|
||||
if (input.flags) {
|
||||
for (const [key, val] of Object.entries(input.flags)) {
|
||||
if (val !== undefined) push(key, val);
|
||||
}
|
||||
}
|
||||
if (sets.length === 0) return { user: null, error: "Nothing to update" };
|
||||
|
||||
params.push(input.id);
|
||||
const sql = `UPDATE admin_users SET ${sets.join(", ")}
|
||||
WHERE id = $${params.length}
|
||||
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
||||
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,
|
||||
active, must_change_password, created_at, last_login`;
|
||||
const { rows } = await query<Record<string, unknown>>(sql, params);
|
||||
if (!rows[0]) return { user: null, error: "User not found" };
|
||||
return { user: mapUserRow(rows[0]), error: null };
|
||||
} catch (err) {
|
||||
return { user: null, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||
// Dev bypass check
|
||||
const cookieStore = await cookies();
|
||||
const headerStore = await headers();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const cookieHeader = headerStore.get("cookie") || "";
|
||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
||||
if (rcAuthUid === DEV_FORCE_UID) {
|
||||
const service = getServiceClient();
|
||||
// Get user_id first
|
||||
const { data: adminRow, error: fetchError } = await service
|
||||
.from("admin_users")
|
||||
.select("user_id")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
if (fetchError) return { success: false, error: fetchError.message };
|
||||
// Delete from admin_users
|
||||
const { error: deleteError } = await service.from("admin_users").delete().eq("id", id);
|
||||
if (deleteError) return { success: false, error: deleteError.message };
|
||||
// Delete auth user
|
||||
if (adminRow?.user_id) {
|
||||
await service.auth.admin.deleteUser(adminRow.user_id);
|
||||
}
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const idx = mockUsers.findIndex((u) => u.id === id);
|
||||
if (idx === -1) return { success: false, error: "User not found" };
|
||||
mockUsers.splice(idx, 1);
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
const result = await callRpcWithAuth<boolean>("delete_admin_user", { p_id: id });
|
||||
return { success: result.data ?? false, error: result.error };
|
||||
try {
|
||||
// No Supabase Auth — nothing to delete from the auth service.
|
||||
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
|
||||
return { success: (rowCount ?? 0) > 0, error: null };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
||||
const cookieStore = await cookies();
|
||||
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 === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
|
||||
const service = getServiceClient();
|
||||
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
||||
return { success: !error, error: error?.message ?? null };
|
||||
if (useMockData) {
|
||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||
const u = mockUsers.find((m) => m.id === userId);
|
||||
if (!u) return { success: false, error: "User not found" };
|
||||
u.must_change_password = true;
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
// Production path — use service role via direct update
|
||||
const service = getServiceClient();
|
||||
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
||||
return { success: !error, error: error?.message ?? null };
|
||||
try {
|
||||
const { rowCount } = await query(
|
||||
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
return { success: (rowCount ?? 0) > 0, error: null };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await publicSupabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
|
||||
});
|
||||
return { success: !error, error: error?.message ?? null };
|
||||
/**
|
||||
* No auth service anymore (no Supabase, no Auth.js password-reset
|
||||
* endpoint). A platform admin can reset access by deleting +
|
||||
* re-creating the user, or by toggling `must_change_password` via the
|
||||
* UI — the function is preserved as a no-op so call sites keep
|
||||
* compiling.
|
||||
*/
|
||||
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
||||
if (useMockData) {
|
||||
const brands = mockBrands.map(b => ({ id: b.id, name: b.name }));
|
||||
return { brands, error: null };
|
||||
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
|
||||
}
|
||||
try {
|
||||
const { rows } = await query<{ id: string; name: string }>(
|
||||
`SELECT id, name FROM brands ORDER BY name`,
|
||||
);
|
||||
return { brands: rows, error: null };
|
||||
} catch (err) {
|
||||
return { brands: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await publicSupabase.from("brands").select("id, name").order("name");
|
||||
return { brands: data ?? [], error: error?.message ?? null };
|
||||
}
|
||||
// Keep `pool` reachable so bundlers don't tree-shake the import — the
|
||||
// import is for the `server-only` side effect.
|
||||
void pool;
|
||||
|
||||
@@ -21,9 +21,7 @@ type AuditResult =
|
||||
/**
|
||||
* Logs an audit event to the audit_logs table.
|
||||
*
|
||||
* In dev mode (dev_session cookie), uses the dev user identity.
|
||||
* In production (Supabase auth), resolves the admin user from admin_users.
|
||||
*
|
||||
* Resolves the admin user from the Auth.js session via getAdminUser().
|
||||
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
|
||||
*/
|
||||
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { signIn, signOut } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* 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 /admin.
|
||||
*/
|
||||
export async function signInWithGoogle(): Promise<void> {
|
||||
await signIn("google", { redirectTo: "/admin" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with email + password. The `credentials` provider is enabled
|
||||
* in dev (see `isDevLoginEnabled()` in `src/auth.config.ts`); in
|
||||
* production it is omitted entirely and this action returns an
|
||||
* `AuthError` (Auth.js surfaces `?error=Configuration` on /login).
|
||||
*
|
||||
* On a failed credential check Auth.js redirects back to
|
||||
* /login?error=CredentialsSignin, which the LoginClient renders as
|
||||
* "Invalid email or password."
|
||||
*/
|
||||
export async function signInWithCredentials(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||
const password = String(formData.get("password") ?? "");
|
||||
if (!email || !password) {
|
||||
redirect("/login?error=MissingCredentials");
|
||||
}
|
||||
await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
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
|
||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
}
|
||||
);
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
|
||||
}
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
settings: data,
|
||||
wholesaleEnabled: data?.wholesale_enabled,
|
||||
};
|
||||
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
||||
// doesn't crash the prerender — the page just falls back to its
|
||||
// default brand name and revalidates from a real request later.
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
settings: data,
|
||||
wholesaleEnabled: data?.wholesale_enabled,
|
||||
};
|
||||
} catch {
|
||||
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBrandSettings(params: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
type MarkPickupResult =
|
||||
| { success: true; pickup_completed_at: string; pickup_completed_by: string }
|
||||
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function markPickupComplete(
|
||||
@@ -23,6 +23,9 @@ export async function markPickupComplete(
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
// `user_id` is null for Google-authenticated admins who haven't been
|
||||
// linked to a Supabase auth user yet. Pass null through; downstream
|
||||
// audit/assignment RPCs will surface a clearer error.
|
||||
const performedBy = adminUser.user_id;
|
||||
|
||||
// brand_admin: verify the order belongs to their brand
|
||||
|
||||
+47
-30
@@ -110,22 +110,30 @@ export type StopForSitemap = {
|
||||
};
|
||||
|
||||
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
// Get all active stops with their brand slug
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
if (!supabaseUrl || !supabaseKey) return [];
|
||||
|
||||
if (!response.ok) return [];
|
||||
// Get all active stops with their brand slug.
|
||||
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
|
||||
// crash the prerender — the sitemap just renders without stop URLs.
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? stops : [];
|
||||
if (!response.ok) return [];
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? stops : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,24 +163,33 @@ export async function getPublicStopsForBrand(
|
||||
): Promise<PublicStop[]> {
|
||||
if (!brandSlug) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
next: {
|
||||
revalidate: 300,
|
||||
tags: ["stops", `brand:${brandSlug}:stops`],
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!supabaseUrl || !supabaseKey) return [];
|
||||
|
||||
if (!response.ok) return [];
|
||||
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
||||
// doesn't crash the prerender — the page just renders with no stops
|
||||
// and revalidates from a real request once the cache is warm.
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
next: {
|
||||
revalidate: 300,
|
||||
tags: ["stops", `brand:${brandSlug}:stops`],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||
if (!response.ok) return [];
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { AdminUserRow } from "@/actions/admin/users";
|
||||
import { logUserActivity } from "@/actions/admin/audit";
|
||||
|
||||
type ProfilePageProps = {
|
||||
currentUser: AdminUserRow;
|
||||
@@ -21,53 +19,24 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
|
||||
// Profile / email mutations used to call Supabase directly. With the
|
||||
// platform moved off Supabase entirely, those handlers are stubbed out
|
||||
// — the page remains read-only until a server-action equivalent ships.
|
||||
// See the final YOLO report for the broader Supabase → pg data-fetch
|
||||
// migration that covers the rest of the admin pages.
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { error: rpcError } = await supabase.rpc("update_admin_user", {
|
||||
p_id: currentUser.id,
|
||||
p_display_name: displayName || null,
|
||||
p_phone_number: phoneNumber || null,
|
||||
});
|
||||
if (rpcError) {
|
||||
setError(rpcError.message);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
user_id: currentUser.user_id,
|
||||
activity_type: "profile_update",
|
||||
details: { fields: ["display_name", "phone_number"] },
|
||||
});
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setError("Profile editing is temporarily unavailable. Contact a platform admin.");
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleEmailChange(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setChangingEmail(true);
|
||||
setEmailError(null);
|
||||
try {
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
email: newEmail,
|
||||
});
|
||||
if (updateError) {
|
||||
setEmailError(updateError.message);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
user_id: currentUser.user_id,
|
||||
activity_type: "email_change",
|
||||
details: { new_email: newEmail },
|
||||
});
|
||||
setEmailChangeSent(true);
|
||||
setChangingEmail(false);
|
||||
} finally {
|
||||
setChangingEmail(false);
|
||||
}
|
||||
setEmailError("Email changes are temporarily unavailable. Contact a platform admin.");
|
||||
setChangingEmail(false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
|
||||
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
||||
|
||||
// Uses cookies() via getAdminUser — must be dynamic to avoid the
|
||||
// "couldn't be rendered statically" build error.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SquareSyncSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +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";
|
||||
|
||||
/**
|
||||
* Auth.js v5 catch-all route handler. Exposes:
|
||||
* GET /api/auth/signin
|
||||
* GET /api/auth/signout
|
||||
* GET /api/auth/session
|
||||
* GET /api/auth/csrf
|
||||
* GET /api/auth/providers
|
||||
* POST /api/auth/callback/:provider
|
||||
* POST /api/auth/signin/:provider
|
||||
* POST /api/auth/signout
|
||||
*
|
||||
* The actual OAuth + session logic is in `src/lib/auth.ts`.
|
||||
*/
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
@@ -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,21 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
||||
response.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
httpOnly: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
||||
response.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
httpOnly: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { updatePasswordAction } from "@/actions/admin/password";
|
||||
@@ -12,22 +12,6 @@ export default function ChangePasswordPage() {
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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>) {
|
||||
e.preventDefault();
|
||||
@@ -51,30 +35,17 @@ export default function ChangePasswordPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
logUserActivity({
|
||||
user_id: userId,
|
||||
activity_type: "password_change",
|
||||
details: {},
|
||||
});
|
||||
}
|
||||
// Audit log (best-effort — the password change itself was the source of truth)
|
||||
logUserActivity({
|
||||
user_id: result.userId ?? "unknown",
|
||||
activity_type: "password_change",
|
||||
details: {},
|
||||
}).catch(() => {});
|
||||
|
||||
router.push("/admin");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<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 (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
@@ -147,4 +118,4 @@ export default function ChangePasswordPage() {
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+168
-506
@@ -1,546 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
|
||||
import { useState, useTransition } from "react";
|
||||
import { signInWithGoogle, signInWithCredentials } from "@/actions/auth-actions";
|
||||
|
||||
function LoginForm() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [forgotPassword, setForgotPassword] = useState(false);
|
||||
const [forgotEmail, setForgotEmail] = useState("");
|
||||
const [forgotSent, setForgotSent] = useState(false);
|
||||
const [forgotLoading, setForgotLoading] = useState(false);
|
||||
const [forgotError, setForgotError] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
type LoginClientProps = {
|
||||
hasGoogle: boolean;
|
||||
hasCredentials: boolean;
|
||||
/** Server-rendered error message, if any (from ?error=...) */
|
||||
error: string | null;
|
||||
/** Pre-fill the email in dev (for the seeded admin). */
|
||||
seededEmail?: string;
|
||||
/** Where to send the user after a successful sign-in. */
|
||||
redirectTo?: string;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setGlobalError(null);
|
||||
if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
|
||||
if (!password.trim()) { setGlobalError("Please enter your password."); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email.trim(), password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({ error: "Login failed" }));
|
||||
if (res.ok && data?.ok) {
|
||||
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>) => {
|
||||
e.preventDefault();
|
||||
if (!forgotEmail.trim()) return;
|
||||
setForgotLoading(true);
|
||||
setForgotError(null);
|
||||
const fd = new FormData();
|
||||
fd.set("email", forgotEmail.trim());
|
||||
const result = await fetch("/api/forgot-password", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
}).then(r => r.json()).catch(() => ({ error: "Network error" }));
|
||||
setForgotLoading(false);
|
||||
if (result.error) {
|
||||
setForgotError(result.error);
|
||||
} else {
|
||||
setForgotSent(true);
|
||||
}
|
||||
}, [forgotEmail]);
|
||||
function GoogleSignIn({ hasGoogle }: { hasGoogle: boolean }) {
|
||||
if (!hasGoogle) {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200/80 bg-stone-50 p-4 text-sm text-stone-700">
|
||||
<p className="font-medium">Google sign-in is not configured.</p>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
|
||||
<code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
|
||||
environment to enable it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
||||
{/* Google Fonts */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialsForm({
|
||||
seededEmail,
|
||||
error,
|
||||
}: {
|
||||
seededEmail?: string;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(formData) => {
|
||||
setLocalError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await signInWithCredentials(formData);
|
||||
} catch (err) {
|
||||
// Auth.js throws NEXT_REDIRECT on success — that's the normal
|
||||
// flow. We only care about non-redirect errors here.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes("NEXT_REDIRECT")) {
|
||||
setLocalError("Sign-in failed. Please try again.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<label className="block">
|
||||
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Email
|
||||
</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
defaultValue={seededEmail ?? ""}
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Password
|
||||
</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</label>
|
||||
{(error || localError) && (
|
||||
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
|
||||
{error ?? localError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: isPending
|
||||
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
|
||||
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
|
||||
}}
|
||||
>
|
||||
{isPending ? "Signing in…" : "Sign in with email"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 my-5" aria-hidden="true">
|
||||
<div className="flex-1 h-px bg-stone-200/80" />
|
||||
<span className="text-xs uppercase tracking-wider text-stone-400" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
or
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-stone-200/80" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginClient({
|
||||
hasGoogle,
|
||||
hasCredentials,
|
||||
error,
|
||||
seededEmail,
|
||||
}: LoginClientProps) {
|
||||
// Render the Google button first (or a setup message), then the divider,
|
||||
// then the credentials form if dev login is enabled. The order is the
|
||||
// most-common-first progression: prod users see Google; dev users
|
||||
// see Google at top, email/password below as the fast path.
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen flex flex-col relative overflow-hidden"
|
||||
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
|
||||
>
|
||||
<style jsx global>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
||||
html, body { overflow: hidden; }
|
||||
`}</style>
|
||||
|
||||
{/* Organic background elements */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
||||
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="w-full py-6 px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
|
||||
Route Commerce
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className={`w-full max-w-sm transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"}`}>
|
||||
{/* Card */}
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
|
||||
{/* Subtle top accent */}
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
|
||||
|
||||
<div className="p-8 sm:p-10">
|
||||
{/* Logo & Title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
|
||||
<div
|
||||
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
|
||||
}}
|
||||
>
|
||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
|
||||
<h1
|
||||
className="text-3xl font-semibold text-stone-900"
|
||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
||||
<p
|
||||
className="mt-2 text-sm"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
|
||||
>
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auth.js v5 — primary sign-in: Google OAuth */}
|
||||
<form action={signInWithGoogle} className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full inline-flex items-center justify-center gap-3 rounded-xl border border-stone-200/80 bg-white px-6 py-3.5 text-sm font-semibold text-stone-900 shadow-sm transition-all hover:bg-stone-50 active:scale-[0.98]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||
aria-label="Sign in with Google"
|
||||
>
|
||||
<svg className="h-5 w-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-.98.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 0 0 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84z"
|
||||
/>
|
||||
<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 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Sign in with Google</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Dev login (only visible in development) */}
|
||||
{process.env.NODE_ENV !== "production" && (
|
||||
<form action={signInWithDev} className="space-y-3">
|
||||
<div className="rounded-xl bg-amber-50/70 border border-amber-200/60 px-3 py-2 text-xs text-amber-900">
|
||||
<strong>Dev login</strong> — only available in development.
|
||||
Set <code>ALLOW_DEV_LOGIN=false</code> in <code>.env.local</code> to hide.
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
defaultValue="admin"
|
||||
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
|
||||
placeholder="Username"
|
||||
aria-label="Dev username"
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
defaultValue="dev"
|
||||
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
|
||||
placeholder="Password"
|
||||
aria-label="Dev password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-xl bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||
>
|
||||
Dev sign in (no Google required)
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-stone-200/70" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase tracking-wider">
|
||||
<span className="bg-white/0 px-2 text-stone-400" style={{ background: "linear-gradient(to right, transparent, white 30%, white 70%, transparent)" }}>
|
||||
or sign in with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
|
||||
{globalError && (
|
||||
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{globalError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
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" }}
|
||||
placeholder="you@company.com"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
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"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||
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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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" }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{!forgotPassword && !forgotSent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setForgotPassword(true); setGlobalError(null); }}
|
||||
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Password Reset Form */}
|
||||
{forgotPassword && !forgotSent && (
|
||||
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4 border-t border-stone-200/50 pt-6" aria-label="Password reset form">
|
||||
<p className="text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b6b6b" }}>Enter your email and we'll send you a reset link.</p>
|
||||
{forgotError && (
|
||||
<div role="alert" className="rounded-xl bg-red-50/80 p-3 text-sm text-red-600 border border-red-100">
|
||||
{forgotError}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="email"
|
||||
value={forgotEmail}
|
||||
onChange={(e) => setForgotEmail(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white/90 px-4 py-3.5 text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400 transition-all"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||
placeholder="you@company.com"
|
||||
aria-required="true"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotLoading}
|
||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
|
||||
>
|
||||
{forgotLoading ? "Sending..." : "Send Reset Link"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setForgotPassword(false); setForgotEmail(""); setForgotError(null); }}
|
||||
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
||||
>
|
||||
← Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Reset Email Sent */}
|
||||
{forgotSent && (
|
||||
<div className="mt-6 border-t border-stone-200/50 pt-6" role="status" aria-live="polite">
|
||||
<div className="rounded-xl p-4 text-sm border" style={{ backgroundColor: "#f0fdf4", color: "#166534", borderColor: "#bbf7d0" }}>
|
||||
<strong>Check your inbox.</strong> If an account exists for <span className="font-medium">{forgotEmail}</span>, a reset link has been sent.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setForgotPassword(false); setForgotSent(false); setForgotEmail(""); }}
|
||||
className="mt-4 w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
||||
>
|
||||
← Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<GoogleSignIn hasGoogle={hasGoogle} />
|
||||
{hasCredentials && hasGoogle && <Divider />}
|
||||
{hasCredentials && <CredentialsForm seededEmail={seededEmail} error={error} />}
|
||||
</div>
|
||||
|
||||
{/* Security Trust Badges */}
|
||||
<div className="border-t border-stone-100/50 px-8 py-5" style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-center gap-4 text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>256-bit SSL</span>
|
||||
</div>
|
||||
<span className="text-stone-300">•</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span>SOC 2</span>
|
||||
</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>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="text-center mt-6">
|
||||
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
View Farms
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
|
||||
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
|
||||
© {new Date().getFullYear()} Route Commerce
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6">
|
||||
<Link href="/privacy-policy" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="/terms-and-conditions" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
|
||||
Terms
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Demo mode wrapper
|
||||
function DemoMode() {
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
||||
{/* Organic background elements */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="w-full py-6 px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
|
||||
Route Commerce
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Demo Card */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden p-10">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
|
||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
|
||||
Demo Mode
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
||||
Select a role to explore the platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => { document.cookie = "dev_session=platform_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
|
||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
|
||||
>
|
||||
Platform Admin
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { document.cookie = "dev_session=brand_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
|
||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#c97a3e" }}
|
||||
>
|
||||
Brand Admin
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { document.cookie = "dev_session=store_employee; path=/; max-age=86400"; window.location.replace("/admin"); }}
|
||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#6b8f71" }}
|
||||
>
|
||||
Store Employee
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="text-center mt-6">
|
||||
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
View Farms
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
|
||||
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
|
||||
© {new Date().getFullYear()} Route Commerce
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Inner component that uses useSearchParams - must be wrapped in Suspense
|
||||
function LoginPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const isDemo = searchParams.get("demo") === "1";
|
||||
if (isDemo) return <DemoMode />;
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
export default function LoginClient() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-emerald-600 to-emerald-500 animate-pulse" />
|
||||
<p className="text-stone-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<LoginPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+30
-3
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import LoginClient from "./LoginClient";
|
||||
import { isDevLoginEnabled } from "@/auth.config";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
@@ -21,6 +22,32 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginClient />;
|
||||
}
|
||||
type SearchParams = { error?: string; redirect?: string };
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
// The Google provider is only added to the Auth.js config when these
|
||||
// two env vars are set. Pass the flag down so the client can hide the
|
||||
// button (and surface a helpful message) when Google is unavailable.
|
||||
const hasGoogle = !!(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET);
|
||||
const hasCredentials = isDevLoginEnabled();
|
||||
const params = await searchParams;
|
||||
const error =
|
||||
params?.error === "CredentialsSignin" || params?.error === "MissingCredentials"
|
||||
? "Invalid email or password."
|
||||
: params?.error
|
||||
? "Sign-in failed. Please try again."
|
||||
: null;
|
||||
return (
|
||||
<LoginClient
|
||||
hasGoogle={hasGoogle}
|
||||
hasCredentials={hasCredentials}
|
||||
error={error}
|
||||
seededEmail={hasCredentials ? "admin@route-commerce.local" : undefined}
|
||||
redirectTo={params?.redirect ?? "/admin"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
import { useRouter } from "next/navigation";
|
||||
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>
|
||||
);
|
||||
export default async function LogoutPage() {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
+67
-64
@@ -1,105 +1,108 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
import type { NextAuthConfig, DefaultSession } from "next-auth";
|
||||
import Google from "next-auth/providers/google";
|
||||
|
||||
/**
|
||||
* Edge-compatible Auth.js v5 configuration.
|
||||
* Edge-safe Auth.js v5 configuration.
|
||||
*
|
||||
* This file is imported by `src/middleware.ts`, which runs in the Edge runtime.
|
||||
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
|
||||
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
|
||||
* This file is imported by `src/middleware.ts`, which runs in the Edge
|
||||
* runtime. It must NOT import:
|
||||
* - `pg` / `@auth/pg-adapter` (Node-only)
|
||||
* - `next-auth/providers/credentials` (uses Node `crypto` internally)
|
||||
* - The Drizzle client
|
||||
* - Anything else that touches the database or Node-only APIs
|
||||
*
|
||||
* If you need to add a provider that uses Node-only APIs (e.g. an adapter
|
||||
* implementation), define it in `src/lib/auth.ts` instead and add a thin
|
||||
* placeholder here so the middleware can still reference it.
|
||||
* Provider definitions, callbacks, and pages all live here. The full
|
||||
* server-side handler in `src/lib/auth.ts` extends this with the
|
||||
* Credentials provider (Node-only) for email + password sign-in.
|
||||
*
|
||||
* Both instances share the same session cookie, so the middleware can
|
||||
* read JWTs minted by the server-side handler.
|
||||
*/
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
}
|
||||
|
||||
const hasGoogleCreds = !!(
|
||||
process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
|
||||
);
|
||||
|
||||
const googleProvider = hasGoogleCreds
|
||||
? [
|
||||
Google({
|
||||
clientId: process.env.AUTH_GOOGLE_ID,
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
|
||||
export const authConfig = {
|
||||
// Custom sign-in page (must exist at /login)
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
|
||||
// Trust the host header in dev for callback URLs
|
||||
trustHost: true,
|
||||
|
||||
// Providers — referenced from middleware edge runtime.
|
||||
// The Google provider only needs the env vars at runtime; it does not pull
|
||||
// in any Node-only code. The dev Credentials provider is added in
|
||||
// `src/lib/auth.ts` (server-side only) — it's not safe to import
|
||||
// `next-auth/providers/credentials` from the edge runtime.
|
||||
providers: [
|
||||
Google({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
|
||||
clientSecret:
|
||||
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
|
||||
// No `authorization` override — we want the default scopes (openid email profile)
|
||||
}),
|
||||
],
|
||||
|
||||
// New users are persisted in the database (handled in src/lib/auth.ts)
|
||||
// Default to JWT here so middleware can run in edge runtime; the full
|
||||
// server-side handler in src/lib/auth.ts switches this to "database".
|
||||
session: { strategy: "jwt" },
|
||||
|
||||
pages: { signIn: "/login" },
|
||||
session: { strategy: "jwt" as const },
|
||||
providers: googleProvider,
|
||||
callbacks: {
|
||||
/**
|
||||
* Gate /admin routes. Anything not on the public list and not signed in
|
||||
* gets redirected to /login. This mirrors what the page-level checks do,
|
||||
* but runs first at the edge so unauthorized requests never hit the
|
||||
* server component tree.
|
||||
* Gate /admin, /wholesale, and /protected-example. Anything on those
|
||||
* paths and not signed in is redirected to /login (the default
|
||||
* pages.signIn). Public storefronts and the homepage pass through.
|
||||
*
|
||||
* The actual role-based gating still happens in `getAdminUser()` —
|
||||
* this callback only ensures the user is *signed in*.
|
||||
*/
|
||||
authorized({ auth, request: { nextUrl } }) {
|
||||
const isLoggedIn = !!auth?.user;
|
||||
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
|
||||
const isOnWholesale = nextUrl.pathname.startsWith("/wholesale");
|
||||
const isOnProtectedExample = nextUrl.pathname.startsWith(
|
||||
"/protected-example"
|
||||
);
|
||||
|
||||
if (isOnAdmin) {
|
||||
if (isLoggedIn) return true;
|
||||
return false; // Redirect to /login
|
||||
if (isOnAdmin || isOnWholesale || isOnProtectedExample) {
|
||||
return isLoggedIn;
|
||||
}
|
||||
|
||||
if (isOnProtectedExample) {
|
||||
if (isLoggedIn) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Forward the user id from the database user record into the JWT on
|
||||
* initial sign-in. With database sessions this is what populates
|
||||
* `session.user.id` for downstream server actions.
|
||||
* Forward the user id into the JWT on initial sign-in. With
|
||||
* `session.strategy: "jwt"` this is the field downstream code reads
|
||||
* via `session.user.id`.
|
||||
*/
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = (user as { id?: string }).id ?? token.sub;
|
||||
if (user.id) token.id = user.id;
|
||||
if (user.email) token.email = user.email;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
if (session.user && token?.sub) {
|
||||
(session.user as { id?: string }).id = token.sub;
|
||||
if (session.user) {
|
||||
session.user.id =
|
||||
(typeof token.id === "string" && token.id) ||
|
||||
(typeof token.sub === "string" && token.sub) ||
|
||||
"";
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
// Cookie config — keep default names so legacy `rc_auth_uid` consumers
|
||||
// continue to work until they're migrated. New Auth.js cookies default to
|
||||
// `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod).
|
||||
} satisfies NextAuthConfig;
|
||||
|
||||
/**
|
||||
* Helper: are we in development AND allowed to use the dev credentials
|
||||
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
|
||||
* include the provider in its provider list.
|
||||
* Is the dev Credentials provider enabled?
|
||||
* - Disabled in production (NODE_ENV === "production")
|
||||
* - Can be force-disabled by setting `ALLOW_DEV_LOGIN=false`
|
||||
*
|
||||
* Surfaced so `src/lib/auth.ts` can decide whether to include the
|
||||
* Credentials provider in its providers list.
|
||||
*/
|
||||
export function isDevLoginEnabled(): boolean {
|
||||
return isDev && allowDevLogin;
|
||||
return (
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
process.env.ALLOW_DEV_LOGIN !== "false"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { signOutAction } from "@/actions/auth-actions";
|
||||
|
||||
type AdminHeaderProps = {
|
||||
userRole?: string | null;
|
||||
@@ -89,10 +89,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
||||
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
await signOutAction();
|
||||
}
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import BrandSelector from "./BrandSelector";
|
||||
import { signOutAction } from "@/actions/auth-actions";
|
||||
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
@@ -220,10 +219,15 @@ type SidebarProps = {
|
||||
userRole?: string | null;
|
||||
brandIds?: string[];
|
||||
activeBrandId?: string | null;
|
||||
brands?: Array<{ id: string; name: string; slug: string; logo_url: string | null }>;
|
||||
brands?: { id: string; name: string; slug: string; logo_url: string | null }[];
|
||||
};
|
||||
|
||||
export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands }: SidebarProps) {
|
||||
export default function AdminSidebar({
|
||||
userRole,
|
||||
brandIds,
|
||||
activeBrandId,
|
||||
brands,
|
||||
}: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
@@ -234,15 +238,9 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
: userRole === "brand_admin" ? "Brand Admin"
|
||||
: userRole === "multi_brand_admin" ? "Multi-Brand Manager"
|
||||
: userRole === "store_employee" ? "Store Employee"
|
||||
: null;
|
||||
|
||||
const isMultiBrandAdmin = userRole === "multi_brand_admin";
|
||||
const showAllBrandsOption = userRole === "platform_admin";
|
||||
const showBrandSelector =
|
||||
brands && (showAllBrandsOption || (brandIds && brandIds.length >= 2));
|
||||
|
||||
const isActive = useCallback((href: string) => {
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
return pathname.startsWith(href);
|
||||
@@ -306,10 +304,7 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
|
||||
}, [router, mobileOpen, closeMobileMenu]);
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
await signOutAction();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -404,24 +399,6 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Brand selector (multi-brand admins + platform_admin) */}
|
||||
{showBrandSelector && (
|
||||
<div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
<p
|
||||
className="text-[10px] font-semibold uppercase tracking-widest mb-1.5 px-1"
|
||||
style={{ color: "rgba(195, 195, 193, 0.6)" }}
|
||||
>
|
||||
Active Brand
|
||||
</p>
|
||||
<BrandSelector
|
||||
brands={brands!}
|
||||
activeBrandId={activeBrandId ?? null}
|
||||
showAllBrandsOption={showAllBrandsOption}
|
||||
isMultiBrandAdmin={isMultiBrandAdmin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nav links with keyboard navigation */}
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
|
||||
<ul className="space-y-1" role="list">
|
||||
|
||||
@@ -22,7 +22,7 @@ type StopProductAssignmentProps = {
|
||||
stopId: string;
|
||||
allProducts: Product[];
|
||||
assignedProducts: AssignedProduct[];
|
||||
callerUid: string;
|
||||
callerUid: string | null;
|
||||
};
|
||||
|
||||
type Filter = "all" | "available" | "assigned";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,48 @@
|
||||
// Shared AdminUser type — safe to import from both server and client components
|
||||
//
|
||||
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
|
||||
// `brand_ids` is the full list of brands the admin can act in.
|
||||
// - platform_admin: `brand_id = null`, `brand_ids = []` (in dev) or all brands
|
||||
// (resolved by `listBrandsForAdmin`).
|
||||
// - multi_brand_admin: `brand_id` = selected/cookie brand, `brand_ids` = 2+.
|
||||
// - brand_admin / store_employee / staff: `brand_id` = their single brand,
|
||||
// `brand_ids = [that one]`.
|
||||
// Shared AdminUser type — safe to import from both server and client
|
||||
// components. The shape mirrors what `getAdminUser()` returns and
|
||||
// includes both the user's role and the tenant they belong to.
|
||||
|
||||
export type AdminRole = "platform_admin" | "brand_admin" | "store_employee";
|
||||
|
||||
export type AdminUser = {
|
||||
id?: string;
|
||||
/** user.id from the `users` table — or "dev" for dev_session cookies */
|
||||
id: string;
|
||||
/** user_id (same as id) — kept for legacy callers */
|
||||
user_id: string;
|
||||
/** email from the `users` table, or null for dev shims */
|
||||
email: string | null;
|
||||
/** display name */
|
||||
display_name: string | null;
|
||||
/** tenant id from `tenant_users`, or null for platform_admin */
|
||||
tenant_id: string | null;
|
||||
/**
|
||||
* @deprecated Use `tenant_id` instead. Kept for backward compat with
|
||||
* call sites that haven't been migrated yet. Always mirrors
|
||||
* `tenant_id`; will be removed in a later cleanup pass.
|
||||
*/
|
||||
brand_id: string | null;
|
||||
/**
|
||||
* @deprecated Use `tenant_id` instead. Kept for backward compat with
|
||||
* multi-brand admin code (`AdminSidebar`, `setActiveBrand`, etc.).
|
||||
* In our schema an admin belongs to at most one tenant, so this is
|
||||
* always `[]` for platform_admin or `[tenant_id]` for everyone else.
|
||||
* Will be removed when the multi-brand admin UI is re-thought.
|
||||
*/
|
||||
brand_ids: string[];
|
||||
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
|
||||
/** tenant slug (for storefronts) */
|
||||
tenant_slug: string | null;
|
||||
/** role within the tenant (or platform-wide for platform_admin) */
|
||||
role: AdminRole;
|
||||
/** is the user active? */
|
||||
active: boolean;
|
||||
/** auth provider */
|
||||
auth_provider: "dev" | "google" | "email" | null;
|
||||
|
||||
// ── Permission flags ────────────────────────────────────────────
|
||||
// Derived from the role, but exposed as individual booleans so
|
||||
// existing consumer code (forms, sidebar, etc.) can read them
|
||||
// directly without doing role math. See `permissionsForRole()` in
|
||||
// admin-permissions.ts for the source of truth.
|
||||
can_manage_products: boolean;
|
||||
can_manage_stops: boolean;
|
||||
can_manage_orders: boolean;
|
||||
@@ -24,5 +53,21 @@ export type AdminUser = {
|
||||
can_manage_water_log: boolean;
|
||||
can_manage_reports: boolean;
|
||||
can_manage_settings: boolean;
|
||||
can_manage_billing: boolean;
|
||||
can_manage_branding: boolean;
|
||||
can_manage_marketing: boolean;
|
||||
can_manage_team: boolean;
|
||||
|
||||
/** must the user change their password? (legacy; unused) */
|
||||
must_change_password?: boolean;
|
||||
};
|
||||
|
||||
export type TenantContext = {
|
||||
tenant: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
};
|
||||
user: AdminUser;
|
||||
};
|
||||
|
||||
+197
-157
@@ -1,185 +1,225 @@
|
||||
import { cookies } from "next/headers";
|
||||
import type { AdminUser } from "./admin-permissions-types";
|
||||
export type { AdminUser } from "./admin-permissions-types";
|
||||
import "server-only";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { withPlatformAdmin } from "@/db/client";
|
||||
import { users, tenants, tenantUsers } from "@/db/schema";
|
||||
import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permissions-types";
|
||||
|
||||
/**
|
||||
* Returns the current admin user, or `null` if not authenticated.
|
||||
* Source of truth for the current admin user.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev.
|
||||
* 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee).
|
||||
* 3. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids.
|
||||
* Looks up the Auth.js v5 session, then resolves the user + tenant
|
||||
* from the `users` and `tenant_users` tables.
|
||||
*
|
||||
* `brand_id` is the active brand; `brand_ids` is the full membership list.
|
||||
* For dev sessions without a real DB, `brand_ids` is populated by:
|
||||
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
|
||||
* - store_employee: `[<first real brand>]` if a brand exists, else `[]`
|
||||
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev)
|
||||
* Returns `null` if:
|
||||
* - No Auth.js session (caller not signed in)
|
||||
* - The session email doesn't match any `users.email`
|
||||
* - The user has no `tenant_users` row (not provisioned yet)
|
||||
*
|
||||
* Provisioning: an admin must run
|
||||
* INSERT INTO users (email, ...) VALUES (...)
|
||||
* INSERT INTO tenant_users (tenant_id, user_id, role) VALUES (...)
|
||||
* to grant a Google-sign-in user admin access. Until provisioned, the
|
||||
* layout shows "Access Denied" — correct behavior.
|
||||
*
|
||||
* The previous `dev_session` cookie bypass has been removed. The only
|
||||
* way into the admin is through real Auth.js (Google in production;
|
||||
* for local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
|
||||
*/
|
||||
export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
// ── Mock data mode for UI review ─────────────────────────────────
|
||||
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
|
||||
return buildDevAdmin("platform_admin");
|
||||
}
|
||||
|
||||
// ── Dev session bypass (enabled for testing on all envs) ──────────────
|
||||
const dev = cookieStore.get("dev_session")?.value;
|
||||
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") {
|
||||
return buildDevAdmin(dev);
|
||||
}
|
||||
|
||||
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
|
||||
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
|
||||
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[] = [];
|
||||
let sessionEmail: string | null = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
||||
{ 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
|
||||
}
|
||||
const session = await auth();
|
||||
sessionEmail = session?.user?.email ?? null;
|
||||
} catch (err) {
|
||||
console.error("[admin-permissions] auth() failed:", err);
|
||||
return null;
|
||||
}
|
||||
|
||||
const admin = adminUsers[0] as Record<string, unknown>;
|
||||
if (!admin.active) return null;
|
||||
if (!sessionEmail) return null;
|
||||
|
||||
// Load brand_ids from the admin_user_brands junction
|
||||
const brandIds = await fetchAdminUserBrandIds(supabaseUrl, serviceKey, admin.id as string);
|
||||
return await withPlatformAdmin(async (db) => {
|
||||
const userRows = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, sessionEmail))
|
||||
.limit(1);
|
||||
const user = userRows[0];
|
||||
if (!user) return null;
|
||||
|
||||
return buildAdminUser(admin, brandIds);
|
||||
const membershipRows = await db
|
||||
.select({
|
||||
tenantId: tenants.id,
|
||||
tenantName: tenants.name,
|
||||
tenantSlug: tenants.slug,
|
||||
tenantStatus: tenants.status,
|
||||
role: tenantUsers.role,
|
||||
})
|
||||
.from(tenantUsers)
|
||||
.innerJoin(tenants, eq(tenants.id, tenantUsers.tenantId))
|
||||
.where(eq(tenantUsers.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (membershipRows.length === 0) {
|
||||
// Signed in but not provisioned for any tenant.
|
||||
return null;
|
||||
}
|
||||
|
||||
const m = membershipRows[0];
|
||||
const role = m.role as AdminRole;
|
||||
return buildAdminUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.name,
|
||||
authProvider: user.authProvider,
|
||||
tenantId: m.tenantId,
|
||||
tenantSlug: m.tenantSlug,
|
||||
tenantName: m.tenantName,
|
||||
role,
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load `brand_ids` from the admin_user_brands junction for the given admin row.
|
||||
* Returns an empty array on any failure (e.g. before migration 207 is applied).
|
||||
* Resolves the current admin user AND their tenant. Returns `null` if
|
||||
* the user is not signed in or has no tenant. For platform_admin (no
|
||||
* tenant), `tenant` is `null` and callers should use `withPlatformAdmin`
|
||||
* to query across all tenants.
|
||||
*/
|
||||
async function fetchAdminUserBrandIds(
|
||||
supabaseUrl: string,
|
||||
serviceKey: string,
|
||||
adminRowId: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/admin_user_brands?admin_user_id=eq.${adminRowId}&select=brand_id`,
|
||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json().catch(() => []);
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data
|
||||
.map((row: Record<string, unknown>) => row.brand_id as string)
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
} catch {
|
||||
return [];
|
||||
export async function getCurrentTenant(): Promise<TenantContext | null> {
|
||||
const user = await getAdminUser();
|
||||
if (!user) return null;
|
||||
if (!user.tenant_id) {
|
||||
// platform_admin — no specific tenant
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
user,
|
||||
tenant: {
|
||||
id: user.tenant_id,
|
||||
name: user.display_name ?? user.tenant_slug ?? "Unknown",
|
||||
slug: user.tenant_slug ?? "unknown",
|
||||
status: "active",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildDevAdmin(role: string): AdminUser {
|
||||
// For dev sessions we don't have an admin_user_brands junction row to load.
|
||||
// - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands).
|
||||
// - store_employee: `brand_ids = []` (dev AdminAccessDenied is acceptable;
|
||||
// this is the documented limitation — re-read spec section on getAdminUser
|
||||
// step 1. We skip the spec's "fetch first real brand" complexity here in
|
||||
// favour of keeping dev session cheap and DB-independent).
|
||||
// - brand_admin: `brand_ids = []` (same rationale).
|
||||
// `role` is narrowed to the strict union — we know the dev callers pass
|
||||
// only valid values.
|
||||
const base = {
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Re-exports for backward compat
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permissions-types";
|
||||
|
||||
/**
|
||||
* @deprecated Kept for unit tests that exercise the dev shim path.
|
||||
* Production code should never call this — `getAdminUser()` only reads
|
||||
* the Auth.js session now.
|
||||
*/
|
||||
export function buildDevAdmin(role: AdminRole): AdminUser {
|
||||
const isPlatform = role === "platform_admin";
|
||||
const tenantId = isPlatform ? null : "dev-tenant";
|
||||
return {
|
||||
id: "dev",
|
||||
user_id: "dev",
|
||||
brand_id: null,
|
||||
brand_ids: [] as string[],
|
||||
role: role as AdminUser["role"],
|
||||
email: null,
|
||||
display_name: "Demo Admin",
|
||||
tenant_id: tenantId,
|
||||
brand_id: tenantId, // legacy alias
|
||||
brand_ids: tenantId ? [tenantId] : [], // legacy array alias
|
||||
tenant_slug: isPlatform ? null : "tuxedo",
|
||||
role,
|
||||
active: true,
|
||||
auth_provider: "dev",
|
||||
...permissionsForRole(role),
|
||||
must_change_password: false,
|
||||
};
|
||||
if (role === "store_employee") {
|
||||
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
|
||||
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
|
||||
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
|
||||
}
|
||||
return { ...base, 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 };
|
||||
}
|
||||
|
||||
function buildAdminUser(r: Record<string, unknown>, brandIds: string[]): AdminUser {
|
||||
// The DB column is TEXT (per CLAUDE.md) so the runtime value is a string.
|
||||
// We narrow it to the known union here. If the DB has an unknown role
|
||||
// (e.g. a future role), the migration's CHECK constraint will reject it
|
||||
// before it ever reaches this function.
|
||||
const role = r.role as AdminUser["role"];
|
||||
// `brand_id` is the *legacy* single-brand column — preserved here as-is.
|
||||
// The canonical "active brand" is resolved by `getActiveBrandId` on each
|
||||
// page/action, which considers URL params, the active_brand_id cookie,
|
||||
// and this legacy fallback. Setting `brand_id` here to a sensible default
|
||||
// (legacy → first of brand_ids) keeps the AdminUser shape useful even
|
||||
// for callers that haven't migrated to `getActiveBrandId` yet.
|
||||
const legacyBrandId = (r.brand_id as string | null) ?? null;
|
||||
const base = {
|
||||
id: r.id as string,
|
||||
user_id: r.user_id as string,
|
||||
brand_id: legacyBrandId ?? brandIds[0] ?? null,
|
||||
brand_ids: brandIds,
|
||||
role,
|
||||
active: r.active as boolean,
|
||||
must_change_password: Boolean(r.must_change_password),
|
||||
function buildAdminUser(input: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
authProvider: "dev" | "google" | "email" | null;
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
tenantName: string;
|
||||
role: AdminRole;
|
||||
active: boolean;
|
||||
}): AdminUser {
|
||||
return {
|
||||
id: input.id,
|
||||
user_id: input.id,
|
||||
email: input.email,
|
||||
display_name: input.displayName,
|
||||
tenant_id: input.tenantId,
|
||||
brand_id: input.tenantId, // legacy alias
|
||||
brand_ids: [input.tenantId], // legacy array alias
|
||||
tenant_slug: input.tenantSlug,
|
||||
role: input.role,
|
||||
active: input.active,
|
||||
auth_provider: input.authProvider,
|
||||
...permissionsForRole(input.role),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for "what can a role do". Used by both the
|
||||
* dev shim and the real user lookup so the demo and the real thing
|
||||
* behave identically.
|
||||
*/
|
||||
export function permissionsForRole(role: AdminRole) {
|
||||
if (role === "platform_admin") {
|
||||
return {
|
||||
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,
|
||||
can_manage_billing: true,
|
||||
can_manage_branding: true,
|
||||
can_manage_marketing: true,
|
||||
can_manage_team: true,
|
||||
};
|
||||
}
|
||||
if (role === "brand_admin") {
|
||||
return {
|
||||
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: false,
|
||||
can_manage_water_log: true,
|
||||
can_manage_reports: true,
|
||||
can_manage_settings: true,
|
||||
can_manage_billing: true,
|
||||
can_manage_branding: true,
|
||||
can_manage_marketing: true,
|
||||
can_manage_team: true,
|
||||
};
|
||||
}
|
||||
// store_employee
|
||||
return {
|
||||
can_manage_products: false,
|
||||
can_manage_stops: false,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: false,
|
||||
can_manage_refunds: false,
|
||||
can_manage_users: false,
|
||||
can_manage_water_log: false,
|
||||
can_manage_reports: false,
|
||||
can_manage_settings: false,
|
||||
can_manage_billing: false,
|
||||
can_manage_branding: false,
|
||||
can_manage_marketing: false,
|
||||
can_manage_team: false,
|
||||
};
|
||||
if (role === "platform_admin") {
|
||||
return { ...base, 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 };
|
||||
}
|
||||
if (role === "store_employee") {
|
||||
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
|
||||
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
|
||||
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
|
||||
}
|
||||
return { ...base, can_manage_products: Boolean(r.can_manage_products), can_manage_stops: Boolean(r.can_manage_stops),
|
||||
can_manage_orders: Boolean(r.can_manage_orders), can_manage_pickup: Boolean(r.can_manage_pickup),
|
||||
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_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
|
||||
}
|
||||
|
||||
+73
-120
@@ -1,136 +1,89 @@
|
||||
import NextAuth from "next-auth";
|
||||
import PostgresAdapter from "@auth/pg-adapter";
|
||||
import { Pool } from "pg";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import {
|
||||
authConfig,
|
||||
isDevLoginEnabled,
|
||||
} from "@/auth.config";
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* Build the dev Credentials provider. Lives here (Node-only) because
|
||||
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
|
||||
* that the middleware uses.
|
||||
* Auth.js (NextAuth v5) — server-side configuration.
|
||||
*
|
||||
* This file is Node-only. It is imported by:
|
||||
* - `src/app/api/auth/[...nextauth]/route.ts` (the OAuth + credentials handlers)
|
||||
* - Server actions that call `signIn` / `signOut`
|
||||
* - `src/lib/admin-permissions.ts` (reads `auth()` for the current user)
|
||||
*
|
||||
* The middleware imports a separate, edge-safe instance built from
|
||||
* `src/auth.config.ts`. Both instances share the same JWT cookie, so the
|
||||
* middleware can read sessions minted here.
|
||||
*
|
||||
* Providers:
|
||||
* - Google OAuth — active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set.
|
||||
* - Email + password (Credentials) — active in dev only; backed by the
|
||||
* `users.password_hash` column. In production, set ALLOW_DEV_LOGIN=false
|
||||
* (the default) and the provider is omitted entirely.
|
||||
*
|
||||
* For local dev, run `npm run db:seed` to create the seeded admin user
|
||||
* (`admin@route-commerce.local` / `admin`). The `authorize` function
|
||||
* looks up the user by email, verifies the password against the stored
|
||||
* hash, and returns the real user record. No `dev_session` cookie
|
||||
* bypass; this is real Auth.js sign-in.
|
||||
*/
|
||||
function buildDevCredentialsProvider() {
|
||||
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authConfig, isDevLoginEnabled } from "@/auth.config";
|
||||
import { withDb } from "@/db/client";
|
||||
import { users } from "@/db/schema";
|
||||
import { verifyPassword } from "@/lib/passwords";
|
||||
|
||||
function buildCredentialsProvider() {
|
||||
return Credentials({
|
||||
id: "dev-login",
|
||||
name: "Dev login",
|
||||
id: "credentials",
|
||||
name: "Email + password",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
/**
|
||||
* Returns the user on success, or `null` on any failure. Auth.js
|
||||
* never throws from `authorize` — a throw is treated as a 500.
|
||||
*/
|
||||
async authorize(creds) {
|
||||
if (!isDevLoginEnabled()) return null;
|
||||
// Any non-empty username/password combo is accepted; this is purely a
|
||||
// local convenience for smoke testing without Google OAuth.
|
||||
const username = String(creds?.username ?? "").trim();
|
||||
const email = String(creds?.email ?? "").trim().toLowerCase();
|
||||
const password = String(creds?.password ?? "");
|
||||
if (!username || !password) return null;
|
||||
if (!email || !password) return null;
|
||||
|
||||
return {
|
||||
id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
name: username,
|
||||
email: `${username}@dev.local`,
|
||||
// Custom field surfaced via `jwt` callback if needed
|
||||
devRole: "platform_admin",
|
||||
} as unknown as { id: string; name: string; email: string };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Postgres pool for Auth.js. Reuses the same database the rest of
|
||||
* the app talks to (via `pg`). Lives behind a module-level singleton so
|
||||
* Next.js hot reload doesn't open a new pool on every request.
|
||||
*
|
||||
* Note: in production, `DATABASE_URL` should be the only DB env var. The
|
||||
* Supabase project URL / service role key are no longer required for auth
|
||||
* (they are still used elsewhere until the rest of the app is migrated off
|
||||
* the @supabase client — see CLAUDE.md).
|
||||
*/
|
||||
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
|
||||
|
||||
function getPool(): Pool {
|
||||
if (globalForPool.__pgPool) return globalForPool.__pgPool;
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ??
|
||||
process.env.SUPABASE_DB_URL ??
|
||||
process.env.POSTGRES_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
// Don't throw at module load — let route handlers return a clean 500
|
||||
// if env is missing. The smoke test instructions tell the user to
|
||||
// set DATABASE_URL.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up."
|
||||
);
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
// Reasonable defaults; override via connection string if you need more
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
});
|
||||
globalForPool.__pgPool = pool;
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final server-side Auth.js config.
|
||||
*
|
||||
* Builds on `authConfig` (edge-safe) and layers on:
|
||||
* 1. The Postgres database adapter
|
||||
* 2. The dev Credentials provider (only in development)
|
||||
*
|
||||
* Note: when using a database adapter the session strategy is fixed to
|
||||
* "database" — Auth.js will persist sessions in the `sessions` table.
|
||||
*/
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
// Use JWT sessions to match the edge-friendly config in `authConfig`.
|
||||
// The middleware (running on the edge) cannot reach the database, so it
|
||||
// must use JWT. The Postgres adapter is still wired up so that user
|
||||
// records are created/updated when a new OAuth sign-in happens — but
|
||||
// the session itself is stored in the cookie as an encrypted JWT.
|
||||
adapter: PostgresAdapter(getPool()),
|
||||
// `session.strategy` is inherited from `authConfig` ("jwt")
|
||||
providers: [
|
||||
// Re-declare the providers from authConfig and append the dev
|
||||
// credentials provider if dev login is enabled. (NextAuth merges by
|
||||
// provider id, so this overrides the edge stubs.)
|
||||
...authConfig.providers,
|
||||
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
|
||||
],
|
||||
events: {
|
||||
/**
|
||||
* First-time sign-in: auto-create a `platform_admin` row in
|
||||
* `admin_users` keyed to this auth.js user id, mirroring the legacy
|
||||
* `rc_auth_uid` flow. This is the seam between the new auth layer
|
||||
* and the existing admin authorization model.
|
||||
*/
|
||||
async signIn({ user }) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const userId = user.id;
|
||||
if (!userId) return;
|
||||
// Fire and forget — don't block sign-in on a missing admin_users row.
|
||||
await pool.query(
|
||||
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
// Note: we don't auto-create here; the existing `getAdminUser()`
|
||||
// in `src/lib/admin-permissions.ts` is the source of truth for
|
||||
// role lookups and is unchanged. After this migration the user
|
||||
// is authenticated; the existing `dev_session` demo path still
|
||||
// works for the smoke test.
|
||||
} catch (e) {
|
||||
// The `users` table is global (not tenant-scoped), so we use
|
||||
// `withDb` rather than `withTenant` — no GUC to set.
|
||||
const u = await withDb(async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
});
|
||||
if (!u || !u.passwordHash) return null;
|
||||
if (!verifyPassword(password, u.passwordHash)) return null;
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name ?? undefined,
|
||||
email: u.email,
|
||||
};
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[auth] signIn event error (non-fatal):", e);
|
||||
console.error("[auth] credentials authorize failed:", err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const providers = [
|
||||
...authConfig.providers,
|
||||
...(isDevLoginEnabled() ? [buildCredentialsProvider()] : []),
|
||||
];
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
providers,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import "server-only";
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Password hashing + verification.
|
||||
*
|
||||
* Format: `scrypt$N$salt$hash` (hex-encoded).
|
||||
*
|
||||
* - scrypt: algorithm identifier (future-proof — easy to migrate to
|
||||
* argon2id by adding a new branch in `verifyPassword`)
|
||||
* - N: scrypt cost parameter (CPU/memory). 2^14 (16384) is a
|
||||
* reasonable default for an interactive login on modern
|
||||
* hardware (~50ms per hash on a typical server)
|
||||
* - salt: 16 random bytes, hex-encoded
|
||||
* - hash: 64-byte derived key, hex-encoded
|
||||
*
|
||||
* Why scrypt and not bcrypt / argon2?
|
||||
* - No extra dependency. Node's `crypto` module has it built in.
|
||||
* - Argon2id is the modern recommendation but requires a native module
|
||||
* (`argon2` or `@node-rs/argon2`) which complicates the install.
|
||||
* When we add a native build step, we should switch to argon2id.
|
||||
* - bcrypt is fine but not better than scrypt for our use case, and
|
||||
* also requires a native module.
|
||||
*
|
||||
* All comparisons are constant-time (`timingSafeEqual`) to defeat
|
||||
* timing-based side-channel attacks.
|
||||
*/
|
||||
|
||||
const KEY_LEN = 64;
|
||||
const SALT_LEN = 16;
|
||||
const DEFAULT_N = 16384; // 2^14
|
||||
const ALGO = "scrypt";
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
if (typeof plain !== "string" || plain.length === 0) {
|
||||
throw new Error("hashPassword: password must be a non-empty string");
|
||||
}
|
||||
const salt = randomBytes(SALT_LEN).toString("hex");
|
||||
const hash = scryptSync(plain, salt, KEY_LEN, { N: DEFAULT_N }).toString("hex");
|
||||
return `${ALGO}$${DEFAULT_N}$${salt}$${hash}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(plain: string, stored: string): boolean {
|
||||
if (typeof plain !== "string" || typeof stored !== "string") return false;
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 4 || parts[0] !== ALGO) return false;
|
||||
const n = Number.parseInt(parts[1], 10);
|
||||
if (!Number.isFinite(n) || n < 1024 || n > 1_000_000) return false;
|
||||
const salt = parts[2];
|
||||
const expectedHex = parts[3];
|
||||
if (!salt || !expectedHex) return false;
|
||||
|
||||
let actual: Buffer;
|
||||
let expected: Buffer;
|
||||
try {
|
||||
actual = scryptSync(plain, salt, KEY_LEN, { N: n });
|
||||
expected = Buffer.from(expectedHex, "hex");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (actual.length !== expected.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(actual, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// NextAuth v5 middleware
|
||||
//
|
||||
// Runs on every non-static request in the Edge runtime. It uses a
|
||||
// lightweight NextAuth instance built from the edge-safe `authConfig` —
|
||||
// NOT the full `src/lib/auth.ts` (which uses `pg` and is Node-only).
|
||||
//
|
||||
// Responsibilities:
|
||||
// 1. Allow Auth.js to read/write its own session cookie
|
||||
// 2. Protect /admin/*, /wholesale/*, /protected-example — redirect to
|
||||
// /login if not authenticated
|
||||
// 3. Redirect away from /login when the user already has a session
|
||||
// 4. Add a handful of baseline security headers
|
||||
//
|
||||
// The legacy `dev_session` cookie bypass has been removed. The only way
|
||||
// into the admin is through real Auth.js — Google in production, or the
|
||||
// seeded Credentials provider in dev (see `src/lib/auth.ts`).
|
||||
|
||||
import NextAuth from "next-auth";
|
||||
import { authConfig } from "@/auth.config";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const { auth } = NextAuth(authConfig);
|
||||
|
||||
export default auth((req) => {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
const isAuthed = !!req.auth;
|
||||
|
||||
const isAdmin = pathname.startsWith("/admin");
|
||||
const isLogin = pathname === "/login";
|
||||
|
||||
if (isAdmin && !isAuthed) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.searchParams.set("redirect", pathname);
|
||||
return addSecurityHeaders(NextResponse.redirect(url));
|
||||
}
|
||||
|
||||
if (isLogin && isAuthed) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/admin";
|
||||
return addSecurityHeaders(NextResponse.redirect(url));
|
||||
}
|
||||
|
||||
return addSecurityHeaders(NextResponse.next());
|
||||
});
|
||||
|
||||
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 = {
|
||||
matcher: [
|
||||
// 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)).*)",
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -0,0 +1,111 @@
|
||||
-- _000_auth_schema.sql
|
||||
--
|
||||
-- Local stand-in for the Supabase `auth` schema. Supabase ships a
|
||||
-- built-in `auth.users` table and `auth.uid()` / `auth.role()` functions
|
||||
-- that SECURITY DEFINER RPCs read. For a direct-Postgres deployment
|
||||
-- (no Supabase platform), we recreate the minimum surface those RPCs
|
||||
-- depend on, and use Postgres session GUCs to thread the caller's
|
||||
-- identity from the application layer.
|
||||
--
|
||||
-- Production auth model:
|
||||
-- - Auth.js v5 manages the user session (Google OAuth in /login,
|
||||
-- `dev_session` cookie for the demo flow).
|
||||
-- - Each `pg` connection that calls a SECURITY DEFINER RPC first
|
||||
-- runs `SELECT set_config('app.current_user_id', $1, true)` so
|
||||
-- `auth.uid()` returns the correct value inside the RPC.
|
||||
-- - The app-level middleware (`getAdminUser()`) is the primary
|
||||
-- authorization gate; the RPCs are a defense-in-depth check that
|
||||
-- the caller is in `admin_users` and not a foreign brand.
|
||||
--
|
||||
-- This file is local-only and should NOT be pushed to a Supabase-hosted
|
||||
-- DB (the schema already exists there).
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. Schema
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. auth.users — minimal Supabase-compatible shape
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Columns the migrations actually read:
|
||||
-- id, email, raw_user_meta_data, raw_app_meta_data, encrypted_password
|
||||
-- (Supabase's full schema has ~30 columns; the SECURITY DEFINER
|
||||
-- functions in this codebase only need the four above.)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE,
|
||||
raw_user_meta_data JSONB DEFAULT '{}'::jsonb,
|
||||
raw_app_meta_data JSONB DEFAULT '{}'::jsonb,
|
||||
encrypted_password TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Mirror Supabase's `auth.identities` for the `update_admin_user`
|
||||
-- trigger that writes back to `auth.users`. Most migrations don't
|
||||
-- touch it; kept here so a stray FK / view doesn't blow up.
|
||||
CREATE TABLE IF NOT EXISTS auth.identities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
identity_data JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (provider, provider_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS auth_identities_user_id_idx
|
||||
ON auth.identities (user_id);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. auth.uid() / auth.role() — session helpers
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- These mirror Supabase's signature. They read Postgres session GUCs
|
||||
-- (`app.current_user_id` and `app.current_user_role`) that the
|
||||
-- application layer sets before calling SECURITY DEFINER RPCs:
|
||||
--
|
||||
-- await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
|
||||
-- await client.rpc("get_admin_users", { p_brand_id });
|
||||
--
|
||||
-- The `true` argument makes the setting transaction-local, so it
|
||||
-- auto-resets at COMMIT / ROLLBACK — no leakage across pooled
|
||||
-- connections.
|
||||
|
||||
CREATE OR REPLACE FUNCTION auth.uid()
|
||||
RETURNS UUID
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT NULLIF(current_setting('app.current_user_id', true), '')::UUID;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION auth.role()
|
||||
RETURNS TEXT
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE(NULLIF(current_setting('app.current_user_role', true), ''), 'anon');
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 4. notify_pgrst — stub for PostgREST schema-reload signaling
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Some migrations `NOTIFY pgrst, 'reload schema'` to tell PostgREST to
|
||||
-- refresh its cache. In a direct-pg deployment, no PostgREST runs, so
|
||||
-- this is a no-op.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.notify_pgrst()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
-- no-op: no PostgREST to notify in a direct-pg deployment
|
||||
NULL;
|
||||
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,61 @@
|
||||
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 Google provider is only present when AUTH_GOOGLE_ID +
|
||||
// AUTH_GOOGLE_SECRET are set.
|
||||
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();
|
||||
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);
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("unauthed /admin redirects to /login", async ({ page }) => {
|
||||
await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
|
||||
expect(page.url()).toMatch(/\/login/);
|
||||
});
|
||||
|
||||
test("unauthed /wholesale redirects to /login", async ({ page }) => {
|
||||
await page.goto(`${BASE}/wholesale`, { waitUntil: "domcontentloaded" });
|
||||
expect(page.url()).toMatch(/\/login/);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +1,36 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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");
|
||||
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||
|
||||
// Fill credentials
|
||||
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
|
||||
await page.fill("#password", process.env.TEST_ADMIN_PASSWORD!);
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Login page — Google OAuth is the only login path
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
test.describe("Login page", () => {
|
||||
test("renders the Google sign-in button", async ({ page }) => {
|
||||
await page.goto(`${BASE}/login`);
|
||||
await expect(page.getByRole("button", { name: /continue with google/i })).toBeVisible();
|
||||
});
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
test("shows a friendly 'not configured' message when Google OAuth env is missing", async ({ page }) => {
|
||||
// The page either shows the Google button (when AUTH_GOOGLE_ID/SECRET
|
||||
// are set) or a setup message (when they aren't). We accept both as
|
||||
// correct renderings.
|
||||
await page.goto(`${BASE}/login`);
|
||||
const hasGoogle = await page
|
||||
.getByRole("button", { name: /continue with google/i })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasSetup = await page
|
||||
.getByText(/Google sign-in is not configured/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasGoogle || hasSetup).toBe(true);
|
||||
});
|
||||
|
||||
// Wait for navigation to /admin — the JSON response + client nav must happen
|
||||
await page.waitForURL("**/admin", { timeout: 10000 });
|
||||
|
||||
// Admin dashboard must be rendered (Control Center heading or admin layout)
|
||||
await expect(page.locator("body")).not.toContainText("Access Denied");
|
||||
await expect(page.locator("body")).not.toContainText("Login failed");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Login failure: bad password shows error, stays on /login
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
test("login with wrong password shows error and stays on /login", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
|
||||
await page.fill("#password", "wrongpassword123!");
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Error message should appear
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 5000 });
|
||||
// Should NOT navigate away from login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Login with missing fields shows validation error
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
test("login with missing email shows validation error", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
// Don't fill email, only password
|
||||
await page.fill("#password", "something");
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Browser validation should fire (email required)
|
||||
await expect(page.locator("#email")).toHaveAttribute("required", "");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Session persistence: after login, navigating to /admin
|
||||
// should load without re-authenticating
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
test("admin session persists across page reloads", async ({ page }) => {
|
||||
// Login first
|
||||
await page.goto("/login");
|
||||
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
|
||||
await page.fill("#password", process.env.TEST_ADMIN_PASSWORD!);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL("**/admin", { timeout: 10000 });
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
// Should still be on /admin (session cookie keeps user logged in)
|
||||
await expect(page).toHaveURL(/\/admin/);
|
||||
await expect(page.locator("body")).not.toContainText("Access Denied");
|
||||
test("/admin redirects to /login when not authenticated", async ({ page }) => {
|
||||
const res = await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
|
||||
// After redirect, the URL should be /login (with ?redirect=/admin).
|
||||
expect(page.url()).toMatch(/\/login(\?|$)/);
|
||||
expect(res?.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
+30
-43
@@ -3,39 +3,9 @@ import { test, expect } from "@playwright/test";
|
||||
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||
|
||||
test.describe("Smoke Tests", () => {
|
||||
test("Login — dev_session platform_admin lands on admin dashboard", async ({ page }) => {
|
||||
await page.context().addCookies([
|
||||
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||
]);
|
||||
|
||||
await page.goto(`${BASE}/admin`);
|
||||
await expect(page).toHaveURL(/\/admin/, { timeout: 5000 });
|
||||
|
||||
// Admin sidebar nav should be loaded (sidebar is rendered by layout)
|
||||
await expect(page.locator("aside").first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("Admin Settings — page loads", async ({ page }) => {
|
||||
await page.context().addCookies([
|
||||
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||
]);
|
||||
|
||||
await page.goto(`${BASE}/admin/settings`);
|
||||
|
||||
// Wait for redirect to complete (may go to /login first)
|
||||
await page.waitForURL(/\/admin\/settings/, { timeout: 10000 });
|
||||
|
||||
// Page renders without crash — main content area should exist
|
||||
await expect(page.locator("main").first()).toBeVisible({ timeout: 8000 });
|
||||
});
|
||||
|
||||
test("Time Tracking — page loads", async ({ page }) => {
|
||||
await page.context().addCookies([
|
||||
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||
]);
|
||||
|
||||
await page.goto(`${BASE}/admin/time-tracking`);
|
||||
await expect(page).toHaveURL(/\/admin\/time-tracking/, { timeout: 5000 });
|
||||
test("Homepage loads", async ({ page }) => {
|
||||
const res = await page.goto(`${BASE}/`);
|
||||
expect(res?.status()).toBeLessThan(500);
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -46,17 +16,34 @@ test.describe("Smoke Tests", () => {
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/tuxedo`);
|
||||
|
||||
// Hero brand name — use a more stable anchor
|
||||
await expect(page.locator("h1").first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// "Why Choose" section heading is a strong signal the page rendered correctly
|
||||
await expect(page.getByRole("heading", { name: /why choose/i })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// No console errors (filter known non-critical)
|
||||
await expect(page.locator("h1").first()).toBeVisible({ timeout: 10_000 });
|
||||
const criticalErrors = errors.filter(
|
||||
(e) => !e.includes("favicon") && !e.includes("Warning:")
|
||||
(e) => !e.includes("favicon") && !e.includes("Warning:"),
|
||||
);
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("Indian River Direct storefront — homepage loads", async ({ page }) => {
|
||||
const res = await page.goto(`${BASE}/indian-river-direct`);
|
||||
expect(res?.status()).toBeLessThan(500);
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("/admin redirects to /login when unauthenticated", async ({ page }) => {
|
||||
await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
|
||||
expect(page.url()).toMatch(/\/login(\?|$)/);
|
||||
});
|
||||
|
||||
test("Login page renders the Google CTA (or 'not configured' notice)", async ({ page }) => {
|
||||
await page.goto(`${BASE}/login`);
|
||||
const hasGoogle = await page
|
||||
.getByRole("button", { name: /continue with google/i })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasSetup = await page
|
||||
.getByText(/Google sign-in is not configured/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasGoogle || hasSetup).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Unit tests for the auth server actions in src/actions/auth-actions.ts.
|
||||
*
|
||||
* Mocks `@/lib/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,
|
||||
}));
|
||||
|
||||
// `server-only` is a runtime guard that throws if imported outside a
|
||||
// server context. Vitest is a Node env, so the guard fires — stub it.
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
// Import after mocks.
|
||||
const { signInWithGoogle, signOutAction } = await import(
|
||||
"@/actions/auth-actions"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
signInMock.mockReset();
|
||||
signOutMock.mockReset();
|
||||
});
|
||||
|
||||
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,192 @@
|
||||
/**
|
||||
* Unit tests for `getAdminUser()`. The dev_session cookie bypass has been
|
||||
* removed; the only path into the admin is through a real Auth.js
|
||||
* session. These tests mock the `auth()` function and the DB layer
|
||||
* to exercise the lookup.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Stub the server-only guard so the module can be imported under vitest.
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
// Mock the Drizzle client wrapper so we don't need a real DB.
|
||||
const mockSelect = vi.fn();
|
||||
const mockWithPlatformAdmin = vi.fn(async (fn: any) => fn({ select: mockSelect }));
|
||||
vi.mock("@/db/client", () => ({
|
||||
withPlatformAdmin: (fn: any) => mockWithPlatformAdmin(fn),
|
||||
}));
|
||||
|
||||
// Mock the auth() function. The default mock returns null (no session).
|
||||
const authMock = vi.fn();
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
auth: () => authMock(),
|
||||
}));
|
||||
|
||||
// Mock cookies() so we don't read a real cookie store.
|
||||
const cookieStoreGet = vi.fn();
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: () =>
|
||||
Promise.resolve({
|
||||
get: (name: string) => cookieStoreGet(name),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-permissions";
|
||||
|
||||
beforeEach(() => {
|
||||
mockSelect.mockReset();
|
||||
authMock.mockReset();
|
||||
cookieStoreGet.mockReset();
|
||||
});
|
||||
|
||||
describe("getAdminUser()", () => {
|
||||
it("returns null when there is no Auth.js session", async () => {
|
||||
authMock.mockResolvedValue(null);
|
||||
cookieStoreGet.mockReturnValue(undefined);
|
||||
const u = await getAdminUser();
|
||||
expect(u).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the session has no email", async () => {
|
||||
authMock.mockResolvedValue({ user: { name: "no-email" } });
|
||||
const u = await getAdminUser();
|
||||
expect(u).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the email is not in the users table", async () => {
|
||||
authMock.mockResolvedValue({ user: { email: "unknown@example.com" } });
|
||||
// First select: users. Returns empty.
|
||||
mockSelect.mockReturnValueOnce({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const u = await getAdminUser();
|
||||
expect(u).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the user exists but has no tenant_users row", async () => {
|
||||
authMock.mockResolvedValue({ user: { email: "no-tenant@example.com" } });
|
||||
// First select: users — returns the user
|
||||
mockSelect
|
||||
.mockReturnValueOnce({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [
|
||||
{
|
||||
id: "user-1",
|
||||
email: "no-tenant@example.com",
|
||||
name: "No Tenant",
|
||||
authProvider: "google",
|
||||
authSubject: "google-sub",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
// Second select: tenantUsers — returns empty
|
||||
.mockReturnValueOnce({
|
||||
from: () => ({
|
||||
innerJoin: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const u = await getAdminUser();
|
||||
expect(u).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a fully-populated AdminUser for a provisioned brand_admin", async () => {
|
||||
authMock.mockResolvedValue({ user: { email: "admin@tuxedo.example" } });
|
||||
mockSelect
|
||||
.mockReturnValueOnce({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [
|
||||
{
|
||||
id: "user-tux",
|
||||
email: "admin@tuxedo.example",
|
||||
name: "Tux Admin",
|
||||
authProvider: "google",
|
||||
authSubject: "google-tux",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: () => ({
|
||||
innerJoin: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [
|
||||
{
|
||||
tenantId: "tenant-tux",
|
||||
tenantName: "Tuxedo Citrus",
|
||||
tenantSlug: "tuxedo",
|
||||
tenantStatus: "active",
|
||||
role: "brand_admin",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const u = await getAdminUser();
|
||||
expect(u).not.toBeNull();
|
||||
expect(u?.email).toBe("admin@tuxedo.example");
|
||||
expect(u?.tenant_id).toBe("tenant-tux");
|
||||
expect(u?.tenant_slug).toBe("tuxedo");
|
||||
expect(u?.role).toBe("brand_admin");
|
||||
expect(u?.can_manage_products).toBe(true);
|
||||
expect(u?.can_manage_team).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDevAdmin()", () => {
|
||||
it("returns a platform_admin with no tenant", () => {
|
||||
const u = buildDevAdmin("platform_admin");
|
||||
expect(u.role).toBe("platform_admin");
|
||||
expect(u.tenant_id).toBeNull();
|
||||
expect(u.tenant_slug).toBeNull();
|
||||
expect(u.can_manage_billing).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a brand_admin tied to the tuxedo tenant", () => {
|
||||
const u = buildDevAdmin("brand_admin");
|
||||
expect(u.role).toBe("brand_admin");
|
||||
expect(u.tenant_slug).toBe("tuxedo");
|
||||
expect(u.can_manage_users).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a store_employee with limited permissions", () => {
|
||||
const u = buildDevAdmin("store_employee");
|
||||
expect(u.role).toBe("store_employee");
|
||||
expect(u.can_manage_products).toBe(false);
|
||||
expect(u.can_manage_orders).toBe(true);
|
||||
expect(u.can_manage_billing).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionsForRole()", () => {
|
||||
it("platform_admin can do everything", () => {
|
||||
const p = permissionsForRole("platform_admin");
|
||||
expect(Object.values(p).every((v) => v === true)).toBe(true);
|
||||
});
|
||||
it("brand_admin can manage most things but not users", () => {
|
||||
const p = permissionsForRole("brand_admin");
|
||||
expect(p.can_manage_users).toBe(false);
|
||||
expect(p.can_manage_billing).toBe(true);
|
||||
});
|
||||
it("store_employee is restricted to orders + pickup", () => {
|
||||
const p = permissionsForRole("store_employee");
|
||||
expect(p.can_manage_orders).toBe(true);
|
||||
expect(p.can_manage_pickup).toBe(true);
|
||||
expect(p.can_manage_products).toBe(false);
|
||||
expect(p.can_manage_billing).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tests for the password helper. Exercises:
|
||||
* - hash round-trip (verify(hashed) returns true for the same plaintext)
|
||||
* - wrong password returns false
|
||||
* - tampered/malformed stored values return false (no exception)
|
||||
* - distinct passwords produce distinct hashes (salting works)
|
||||
* - format is `scrypt$N$salt$hash` and self-describing
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { hashPassword, verifyPassword } from "@/lib/passwords";
|
||||
|
||||
// `src/lib/passwords.ts` uses `import "server-only"`. Stub it so the test
|
||||
// runner can import the module.
|
||||
import { vi } from "vitest";
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
describe("hashPassword / verifyPassword", () => {
|
||||
it("round-trips: verify(hash(p)) is true for the same plaintext", () => {
|
||||
const h = hashPassword("admin");
|
||||
expect(verifyPassword("admin", h)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for the wrong password", () => {
|
||||
const h = hashPassword("admin");
|
||||
expect(verifyPassword("wrong", h)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty password (without throwing)", () => {
|
||||
const h = hashPassword("admin");
|
||||
expect(verifyPassword("", h)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a tampered stored value (no exception)", () => {
|
||||
expect(verifyPassword("admin", "not-a-real-hash")).toBe(false);
|
||||
expect(verifyPassword("admin", "scrypt$999$abc$def")).toBe(false);
|
||||
expect(verifyPassword("admin", "")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a random salt — same password produces different hashes", () => {
|
||||
const a = hashPassword("admin");
|
||||
const b = hashPassword("admin");
|
||||
expect(a).not.toBe(b);
|
||||
expect(verifyPassword("admin", a)).toBe(true);
|
||||
expect(verifyPassword("admin", b)).toBe(true);
|
||||
});
|
||||
|
||||
it("hashes in the self-describing `scrypt$N$salt$hash` format", () => {
|
||||
const h = hashPassword("admin");
|
||||
const parts = h.split("$");
|
||||
expect(parts).toHaveLength(4);
|
||||
expect(parts[0]).toBe("scrypt");
|
||||
expect(Number.parseInt(parts[1], 10)).toBeGreaterThan(0);
|
||||
expect(parts[2]).toMatch(/^[0-9a-f]+$/);
|
||||
expect(parts[3]).toMatch(/^[0-9a-f]+$/);
|
||||
expect(parts[3].length).toBe(128); // 64 bytes hex = 128 chars
|
||||
});
|
||||
|
||||
it("handles long passwords", () => {
|
||||
const long = "x".repeat(1024);
|
||||
const h = hashPassword(long);
|
||||
expect(verifyPassword(long, h)).toBe(true);
|
||||
expect(verifyPassword(long + "x", h)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles unicode passwords", () => {
|
||||
const h = hashPassword("пароль密碼🔐");
|
||||
expect(verifyPassword("пароль密碼🔐", h)).toBe(true);
|
||||
expect(verifyPassword("пароль密碼", h)).toBe(false);
|
||||
});
|
||||
|
||||
it("throws on empty input to hashPassword (caller bug, fail fast)", () => {
|
||||
expect(() => hashPassword("")).toThrow();
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -19,7 +19,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@/db": ["./db"],
|
||||
"@/db/*": ["./db/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
|
||||
{ find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") },
|
||||
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
|
||||
{ find: "@/db", replacement: path.resolve(__dirname, "db") },
|
||||
],
|
||||
},
|
||||
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