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
|
### Authentication & Authorization
|
||||||
|
|
||||||
**Dev mode** bypasses Supabase auth entirely via `dev_session` cookie set by `/login`:
|
**Auth.js v5 (NextAuth)** is the active auth system. Config lives in `src/lib/auth.ts`, with the route handler at `src/app/api/auth/[...nextauth]/route.ts`. The login page (`src/app/login/LoginClient.tsx`) renders a "Continue with Google" button alongside the email/password form, both backed by Auth.js providers. Server-side code reads the session via `await auth()` from `@/lib/auth`; client code that needs to sign out can call the `signOutAction` server action from `@/actions/auth-actions`.
|
||||||
|
|
||||||
|
**Providers (see `src/lib/auth.ts`):**
|
||||||
|
- **Google OAuth** — primary sign-in. Active when `AUTH_GOOGLE_ID` + `AUTH_GOOGLE_SECRET` are set. Users auto-provision as `platform_admin` if their Google `sub` is UUID-shaped (rare) — for Google sign-ins, `admin_users` rows must be provisioned manually by an existing admin until the `email`-based provisioning flow lands.
|
||||||
|
- **Email/password (Supabase-backed)** — wraps the existing `auth/v1/token?grant_type=password` flow. This is transitional; once Supabase auth is fully removed, this provider goes away.
|
||||||
|
|
||||||
|
**Demo / dev mode** still works through a `dev_session` cookie:
|
||||||
- `dev_session=platform_admin` — full access, all brands
|
- `dev_session=platform_admin` — full access, all brands
|
||||||
- `dev_session=brand_admin` — full access to assigned brand only
|
- `dev_session=brand_admin` — full access to assigned brand only
|
||||||
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
|
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
|
||||||
|
- The login page renders "Demo Mode" buttons that set this cookie client-side; the middleware also auto-issues `dev_session=platform_admin` for the `/admin` demo flow when Supabase isn't configured.
|
||||||
|
|
||||||
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and 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.
|
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:
|
- ✅ 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
|
||||||
- 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.
|
- ✅ Google + Credentials (Supabase-backed) providers configured
|
||||||
- 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.
|
- ✅ `getAdminUser()` reads from `auth()` session
|
||||||
- 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.
|
- ✅ Middleware uses `auth()` wrapper
|
||||||
- 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.
|
- ✅ Old `/api/login`, `/api/logout`, `/api/auth/uid`, `/api/set-auth-cookie` removed
|
||||||
- `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.
|
- ⏳ Add `email` column to `admin_users` and provision Google users by email (TODO)
|
||||||
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it.
|
- ⏳ Switch the `admin_users` lookup in `getAdminUser()` to direct `pg` (TODO — needs `src/lib/db.ts`)
|
||||||
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior.
|
- ⏳ Remove the email/password (Supabase) provider when Supabase auth is fully cut over (TODO)
|
||||||
|
- ⏳ The `rc_auth_uid` cookie is no longer the source of truth, but `actions/admin/users.ts` still reads it for backward compat with pre-existing sessions — the `DEV_FORCE_UID` constant and its branches are now dead code (the `/api/force-admin` route that set it was deleted) and should be removed in a follow-up
|
||||||
|
|
||||||
### Server Actions Pattern
|
### Server Actions Pattern
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"lint:fix": "eslint --fix",
|
"lint:fix": "eslint --fix",
|
||||||
"migrate": "node supabase/push-migrations.js",
|
"migrate": "node scripts/migrate.js",
|
||||||
"migrate:one": "node supabase/push-migrations.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",
|
"type-check": "npx tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:e2e": "playwright test --project=local",
|
||||||
|
"test:e2e:prod": "PLAYWRIGHT_PROD=1 playwright test",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.96.0",
|
"@anthropic-ai/sdk": "^0.96.0",
|
||||||
"@auth/pg-adapter": "^1.11.2",
|
|
||||||
"@clerk/nextjs": "^7.4.2",
|
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
"@stripe/react-stripe-js": "^3.10.0",
|
|
||||||
"@stripe/stripe-js": "^5.10.0",
|
|
||||||
"@supabase/ssr": "^0.10.2",
|
"@supabase/ssr": "^0.10.2",
|
||||||
"@supabase/supabase-js": "^2.105.3",
|
"@supabase/supabase-js": "^2.105.3",
|
||||||
"@upstash/ratelimit": "^2.0.8",
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
"@upstash/redis": "^1.38.0",
|
"@upstash/redis": "^1.38.0",
|
||||||
|
"drizzle-orm": "^0.36.4",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
@@ -57,13 +63,19 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-kit": "^0.30.6",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.5",
|
"eslint-config-next": "16.2.5",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.9.3"
|
"tsx": "^4.22.4",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"overrides": "{}"
|
"overrides": "{}"
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -1,6 +1,9 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
const LOCAL_BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||||
|
const PROD_BASE = "https://route-commerce-platform.vercel.app";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
@@ -9,16 +12,19 @@ export default defineConfig({
|
|||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: "list",
|
reporter: "list",
|
||||||
use: {
|
use: {
|
||||||
baseURL: "https://route-commerce-platform.vercel.app",
|
baseURL: LOCAL_BASE,
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
|
{
|
||||||
|
name: "local",
|
||||||
|
use: { ...devices["Desktop Chrome"], baseURL: LOCAL_BASE },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "production",
|
name: "production",
|
||||||
use: {
|
// `PLAYWRIGHT_PROD=1 npx playwright test` to run against the live site.
|
||||||
...devices["Desktop Chrome"],
|
testMatch: /.*\.prod\.spec\.ts$/,
|
||||||
baseURL: "https://route-commerce-platform.vercel.app",
|
use: { ...devices["Desktop Chrome"], baseURL: PROD_BASE },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { auth } from "@/lib/auth";
|
||||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current user's Supabase auth password.
|
||||||
|
*
|
||||||
|
* Reads the Auth.js v5 session to identify the user. The session's
|
||||||
|
* `user.id` is either:
|
||||||
|
* - a Supabase auth user id (UUID) for email/password sign-ins
|
||||||
|
* - a Google `sub` (non-UUID) for Google sign-ins — these are not
|
||||||
|
* provisioned in Supabase auth, so the RPC will reject them. Google
|
||||||
|
* users must be provisioned in Supabase auth separately.
|
||||||
|
*
|
||||||
|
* The password update itself runs as a SECURITY DEFINER PL/pgSQL function
|
||||||
|
* (`update_user_password`) inside the database, called directly via the
|
||||||
|
* shared `pg` pool. No Supabase REST hop required.
|
||||||
|
*/
|
||||||
export async function updatePasswordAction(
|
export async function updatePasswordAction(
|
||||||
newPassword: string
|
newPassword: string
|
||||||
): Promise<{ error?: string }> {
|
): Promise<{ error?: string; userId?: string }> {
|
||||||
const cookieStore = await cookies();
|
const session = await auth();
|
||||||
const uid =
|
const uid = session?.user?.id;
|
||||||
cookieStore.get("rc_auth_uid")?.value ??
|
|
||||||
cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
return { error: "Not authenticated. Please log in again." };
|
return { error: "Not authenticated. Please log in again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = createServiceClient(
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
if (!UUID_REGEX.test(uid)) {
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
return {
|
||||||
);
|
error:
|
||||||
|
"Password change is not available for social sign-in accounts. Please contact an admin.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await service.rpc("update_user_password", {
|
try {
|
||||||
p_user_id: uid,
|
// The RPC is SECURITY DEFINER and returns a single row (or raises).
|
||||||
p_password: newPassword,
|
// We SELECT it (rather than SELECT update_user_password(...)) so the
|
||||||
});
|
// call stays a normal parameterized query and we can read the result.
|
||||||
|
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
|
||||||
if (error) return { error: error.message };
|
return { userId: uid };
|
||||||
return {};
|
} catch (err) {
|
||||||
}
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to update password.";
|
||||||
|
return { error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+243
-539
@@ -1,18 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies, headers } from "next/headers";
|
import "server-only";
|
||||||
import { createServerClient } from "@supabase/ssr";
|
import { cookies } from "next/headers";
|
||||||
import { NextRequest } from "next/server";
|
import { pool, query } from "@/lib/db";
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
|
||||||
import { supabase as publicSupabase } from "@/lib/supabase";
|
|
||||||
import { getMockTableData, mockBrands } from "@/lib/mock-data";
|
import { getMockTableData, mockBrands } from "@/lib/mock-data";
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||||
|
|
||||||
export type AdminUserRow = {
|
export type AdminUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string | null;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
phone_number: string | null;
|
phone_number: string | null;
|
||||||
@@ -75,169 +72,17 @@ export type UpdateAdminUserInput = {
|
|||||||
phone_number?: string | null;
|
phone_number?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── SSR client for authenticated requests ─────────────────────────────────
|
// ─── Row mapping ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
async function getAuthClient() {
|
// `admin_users` schema (after migration 204 + 034 + 037):
|
||||||
const cookieStore = await cookies();
|
// id, user_id, display_name, email, phone_number, role, brand_id,
|
||||||
const headerStore = await headers();
|
// can_manage_<X> (BOOLEAN each), active, must_change_password,
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
||||||
return {
|
return {
|
||||||
id: String(row.id ?? ""),
|
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,
|
display_name: (row.display_name as string | null) ?? null,
|
||||||
email: String(row.email ?? ""),
|
email: String(row.email ?? ""),
|
||||||
phone_number: (row.phone_number as string | null) ?? null,
|
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 }> {
|
async function sendWelcomeEmailSafe(input: {
|
||||||
const service = getServiceClient();
|
to: string;
|
||||||
|
name: string;
|
||||||
// Ensure caller has an admin_users record
|
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||||
if (callerUid) {
|
password: string;
|
||||||
const { data: existing } = await service
|
}): Promise<void> {
|
||||||
.from("admin_users")
|
try {
|
||||||
.select("id")
|
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||||
.eq("user_id", callerUid)
|
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
||||||
.maybeSingle();
|
await sendWelcomeEmail({
|
||||||
|
to: input.to,
|
||||||
if (!existing) {
|
name: input.name,
|
||||||
// auto-creating admin_users for uid
|
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||||
const { data: authData } = await service.auth.admin.listUsers();
|
brandName: "Tuxedo Corn",
|
||||||
const authUser = authData?.users?.find((u) => u.id === callerUid);
|
tempPassword: input.password,
|
||||||
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata;
|
});
|
||||||
await service.from("admin_users").insert({
|
} catch {
|
||||||
user_id: callerUid,
|
// welcome email is best-effort; never block user creation
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 } {
|
// ─── Public actions ─────────────────────────────────────────────────────────
|
||||||
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";
|
|
||||||
|
|
||||||
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
||||||
if (useMockData) {
|
if (useMockData) {
|
||||||
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||||
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 {
|
return {
|
||||||
user: {
|
users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers,
|
||||||
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,
|
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 }> {
|
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||||
// Dev bypass check
|
if (useMockData) {
|
||||||
const cookieStore = await cookies();
|
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||||
const headerStore = await headers();
|
const idx = mockUsers.findIndex((u) => u.id === input.id);
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
if (idx === -1) return { user: null, error: "User not found" };
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
const merged: AdminUserRow = { ...mockUsers[idx] };
|
||||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
if (input.role !== undefined) merged.role = input.role;
|
||||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
|
||||||
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
|
if (input.active !== undefined) merged.active = input.active;
|
||||||
if (rcAuthUid === DEV_FORCE_UID) {
|
if (input.display_name !== undefined) merged.display_name = input.display_name;
|
||||||
const service = getServiceClient();
|
if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
|
||||||
const { data, error } = await service
|
if (input.flags) {
|
||||||
.from("admin_users")
|
for (const [k, v] of Object.entries(input.flags)) {
|
||||||
.update({
|
if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
|
||||||
role: input.role ?? undefined,
|
}
|
||||||
brand_id: input.brand_id ?? undefined,
|
}
|
||||||
can_manage_products: input.flags?.can_manage_products ?? undefined,
|
mockUsers[idx] = merged;
|
||||||
can_manage_stops: input.flags?.can_manage_stops ?? undefined,
|
return { user: merged, error: null };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
|
try {
|
||||||
p_id: input.id,
|
// Build a partial SET clause. Each `can_manage_*` column is set
|
||||||
p_role: input.role ?? null,
|
// individually — the input's `flags` partial is spread across them.
|
||||||
p_brand_id: input.brand_id ?? null,
|
const sets: string[] = [];
|
||||||
p_flags: input.flags ?? null,
|
const params: unknown[] = [];
|
||||||
p_active: input.active ?? null,
|
const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); };
|
||||||
p_display_name: input.display_name ?? null,
|
|
||||||
p_phone_number: input.phone_number ?? null,
|
if (input.role !== undefined) push("role", input.role);
|
||||||
});
|
if (input.brand_id !== undefined) push("brand_id", input.brand_id);
|
||||||
const rows = result.data as AdminUserRow[] | null;
|
if (input.active !== undefined) push("active", input.active);
|
||||||
return { user: rows?.[0] ?? null, error: result.error };
|
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 }> {
|
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
// Dev bypass check
|
if (useMockData) {
|
||||||
const cookieStore = await cookies();
|
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||||
const headerStore = await headers();
|
const idx = mockUsers.findIndex((u) => u.id === id);
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
if (idx === -1) return { success: false, error: "User not found" };
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
mockUsers.splice(idx, 1);
|
||||||
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);
|
|
||||||
}
|
|
||||||
return { success: true, error: null };
|
return { success: true, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await callRpcWithAuth<boolean>("delete_admin_user", { p_id: id });
|
try {
|
||||||
return { success: result.data ?? false, error: result.error };
|
// 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 }> {
|
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
||||||
const cookieStore = await cookies();
|
if (useMockData) {
|
||||||
const headerStore = await headers();
|
const mockUsers = getMockTableData("users") as AdminUserRow[];
|
||||||
const cookieHeader = headerStore.get("cookie") || "";
|
const u = mockUsers.find((m) => m.id === userId);
|
||||||
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
|
if (!u) return { success: false, error: "User not found" };
|
||||||
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
|
u.must_change_password = true;
|
||||||
|
return { success: true, error: 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production path — use service role via direct update
|
try {
|
||||||
const service = getServiceClient();
|
const { rowCount } = await query(
|
||||||
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
|
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
||||||
return { success: !error, error: error?.message ?? null };
|
[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, {
|
* No auth service anymore (no Supabase, no Auth.js password-reset
|
||||||
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
|
* endpoint). A platform admin can reset access by deleting +
|
||||||
});
|
* re-creating the user, or by toggling `must_change_password` via the
|
||||||
return { success: !error, error: error?.message ?? null };
|
* 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 }> {
|
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
||||||
if (useMockData) {
|
if (useMockData) {
|
||||||
const brands = mockBrands.map(b => ({ id: b.id, name: b.name }));
|
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
|
||||||
return { brands, 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");
|
// Keep `pool` reachable so bundlers don't tree-shake the import — the
|
||||||
return { brands: data ?? [], error: error?.message ?? null };
|
// 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.
|
* Logs an audit event to the audit_logs table.
|
||||||
*
|
*
|
||||||
* In dev mode (dev_session cookie), uses the dev user identity.
|
* Resolves the admin user from the Auth.js session via getAdminUser().
|
||||||
* In production (Supabase auth), resolves the admin user from admin_users.
|
|
||||||
*
|
|
||||||
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
|
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
|
||||||
*/
|
*/
|
||||||
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
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
|
// Public version for storefront pages — uses slug, no auth required
|
||||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
const response = await fetch(
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
|
||||||
{
|
}
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
||||||
const data = await response.json();
|
// doesn't crash the prerender — the page just falls back to its
|
||||||
return {
|
// default brand name and revalidates from a real request later.
|
||||||
success: true,
|
try {
|
||||||
settings: data,
|
const response = await fetch(
|
||||||
wholesaleEnabled: data?.wholesale_enabled,
|
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
||||||
};
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
settings: data,
|
||||||
|
wholesaleEnabled: data?.wholesale_enabled,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveBrandSettings(params: {
|
export async function saveBrandSettings(params: {
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { createServerClient } from "@supabase/ssr";
|
|
||||||
|
|
||||||
export type LoginWithPasswordResult =
|
|
||||||
| { success: true; redirect: true }
|
|
||||||
| { success: false; error: string };
|
|
||||||
|
|
||||||
export async function loginWithPassword(
|
|
||||||
email: string,
|
|
||||||
password: string
|
|
||||||
): Promise<LoginWithPasswordResult> {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
|
||||||
return { success: false, error: "Server misconfiguration." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return cookieStore.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
|
||||||
cookieStore.set(name, value, options);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data.user) {
|
|
||||||
return { success: false, error: error?.message || "Invalid credentials" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the rc_auth_uid cookie that getAdminUser() reads
|
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
|
||||||
cookieStore.set("rc_auth_uid", data.user.id, {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
secure: isProd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, redirect: true };
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import { logAuditEvent } from "@/actions/audit";
|
|||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
type MarkPickupResult =
|
type MarkPickupResult =
|
||||||
| { success: true; pickup_completed_at: string; pickup_completed_by: string }
|
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export async function markPickupComplete(
|
export async function markPickupComplete(
|
||||||
@@ -23,6 +23,9 @@ export async function markPickupComplete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
// `user_id` is null for Google-authenticated admins who haven't been
|
||||||
|
// linked to a Supabase auth user yet. Pass null through; downstream
|
||||||
|
// audit/assignment RPCs will surface a clearer error.
|
||||||
const performedBy = adminUser.user_id;
|
const performedBy = adminUser.user_id;
|
||||||
|
|
||||||
// brand_admin: verify the order belongs to their brand
|
// brand_admin: verify the order belongs to their brand
|
||||||
|
|||||||
+47
-30
@@ -110,22 +110,30 @@ export type StopForSitemap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
// Get all active stops with their brand slug
|
if (!supabaseUrl || !supabaseKey) return [];
|
||||||
const response = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) return [];
|
// Get all active stops with their brand slug.
|
||||||
|
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
|
||||||
|
// crash the prerender — the sitemap just renders without stop URLs.
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const stops = await response.json();
|
if (!response.ok) return [];
|
||||||
return Array.isArray(stops) ? stops : [];
|
|
||||||
|
const stops = await response.json();
|
||||||
|
return Array.isArray(stops) ? stops : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,24 +163,33 @@ export async function getPublicStopsForBrand(
|
|||||||
): Promise<PublicStop[]> {
|
): Promise<PublicStop[]> {
|
||||||
if (!brandSlug) return [];
|
if (!brandSlug) return [];
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
const response = await fetch(
|
if (!supabaseUrl || !supabaseKey) return [];
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
|
||||||
next: {
|
|
||||||
revalidate: 300,
|
|
||||||
tags: ["stops", `brand:${brandSlug}:stops`],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) return [];
|
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
||||||
|
// doesn't crash the prerender — the page just renders with no stops
|
||||||
|
// and revalidates from a real request once the cache is warm.
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||||
|
next: {
|
||||||
|
revalidate: 300,
|
||||||
|
tags: ["stops", `brand:${brandSlug}:stops`],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const stops = await response.json();
|
if (!response.ok) return [];
|
||||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
|
||||||
|
const stops = await response.json();
|
||||||
|
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export default async function DebugAuthPage() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
|
||||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
|
||||||
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
|
|
||||||
|
|
||||||
let adminUsersStatus = "not_tried";
|
|
||||||
let adminUsersResult: string | null = null;
|
|
||||||
|
|
||||||
if (rcAuthUid) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
if (supabaseUrl && serviceKey) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
adminUsersStatus = String(res.status);
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
adminUsersResult = JSON.stringify(data);
|
|
||||||
} catch (e) {
|
|
||||||
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "missing_env_vars";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "no_rc_auth_uid_cookie";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
|
|
||||||
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
|
|
||||||
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
|
|
||||||
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { AdminUserRow } from "@/actions/admin/users";
|
import { AdminUserRow } from "@/actions/admin/users";
|
||||||
import { logUserActivity } from "@/actions/admin/audit";
|
|
||||||
|
|
||||||
type ProfilePageProps = {
|
type ProfilePageProps = {
|
||||||
currentUser: AdminUserRow;
|
currentUser: AdminUserRow;
|
||||||
@@ -21,53 +19,24 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
|||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
const [emailError, setEmailError] = useState<string | null>(null);
|
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) {
|
async function handleSaveProfile(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError("Profile editing is temporarily unavailable. Contact a platform admin.");
|
||||||
try {
|
setSaving(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEmailChange(e: React.FormEvent) {
|
async function handleEmailChange(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setChangingEmail(true);
|
setChangingEmail(true);
|
||||||
setEmailError(null);
|
setEmailError("Email changes are temporarily unavailable. Contact a platform admin.");
|
||||||
try {
|
setChangingEmail(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
|
|||||||
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||||
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
||||||
|
|
||||||
|
// Uses cookies() via getAdminUser — must be dynamic to avoid the
|
||||||
|
// "couldn't be rendered statically" build error.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function SquareSyncSettingsPage() {
|
export default async function SquareSyncSettingsPage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function TestAuthPage() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
|
|
||||||
let adminUser = null;
|
|
||||||
let error: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
adminUser = await getAdminUser();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error = e instanceof Error ? e.message : String(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_auth_uid")
|
|
||||||
? <span className="text-emerald-400">SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
|
|
||||||
: <span className="text-red-400">NOT SET</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_access_token")
|
|
||||||
? <span className="text-yellow-400">SET (not needed)</span>
|
|
||||||
: <span className="text-zinc-500">NOT SET (OK)</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
|
||||||
{error ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-red-400 font-bold">ERROR</p>
|
|
||||||
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
|
|
||||||
</div>
|
|
||||||
) : adminUser ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
|
|
||||||
{JSON.stringify({
|
|
||||||
id: adminUser.id,
|
|
||||||
user_id: adminUser.user_id,
|
|
||||||
role: adminUser.role,
|
|
||||||
brand_id: adminUser.brand_id,
|
|
||||||
active: adminUser.active,
|
|
||||||
}, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-red-400">NOT AUTHENTICATED — null returned</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-4 border-t border-zinc-800">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
|
|
||||||
Go to Admin
|
|
||||||
</a>
|
|
||||||
<form action="/api/logout" method="POST">
|
|
||||||
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function TestPage() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
let adminUser = null;
|
|
||||||
let adminUserError: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
adminUser = await getAdminUser();
|
|
||||||
} catch (e: any) {
|
|
||||||
adminUserError = e?.message ?? String(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Server cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}...`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token present?</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_access_token")
|
|
||||||
? <span className="text-emerald-400">YES — {allCookies.find(c => c.name === "rc_access_token")?.value.length} chars</span>
|
|
||||||
: <span className="text-red-400">NO</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
|
||||||
{adminUserError ? (
|
|
||||||
<p className="text-red-400">ERROR: {adminUserError}</p>
|
|
||||||
) : (
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-sm overflow-auto">
|
|
||||||
{JSON.stringify(adminUser, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
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;
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { updatePasswordAction } from "@/actions/admin/password";
|
import { updatePasswordAction } from "@/actions/admin/password";
|
||||||
@@ -12,22 +12,6 @@ export default function ChangePasswordPage() {
|
|||||||
const [confirm, setConfirm] = useState("");
|
const [confirm, setConfirm] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [checkingSession, setCheckingSession] = useState(true);
|
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/auth/uid")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (!data.uid) {
|
|
||||||
router.push("/login");
|
|
||||||
} else {
|
|
||||||
setUserId(data.uid);
|
|
||||||
setCheckingSession(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => router.push("/login"));
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -51,30 +35,17 @@ export default function ChangePasswordPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userId) {
|
// Audit log (best-effort — the password change itself was the source of truth)
|
||||||
logUserActivity({
|
logUserActivity({
|
||||||
user_id: userId,
|
user_id: result.userId ?? "unknown",
|
||||||
activity_type: "password_change",
|
activity_type: "password_change",
|
||||||
details: {},
|
details: {},
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
|
||||||
|
|
||||||
router.push("/admin");
|
router.push("/admin");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkingSession) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 text-center">
|
|
||||||
<p className="text-zinc-500 text-sm">Checking session...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
@@ -147,4 +118,4 @@ export default function ChangePasswordPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
export default function DevLoginPage() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
|
||||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 shadow-xl">
|
|
||||||
<h1 className="text-2xl font-bold text-zinc-100 mb-4">Dev Login</h1>
|
|
||||||
<p className="text-zinc-400 mb-6">Click below to login as platform admin:</p>
|
|
||||||
<form action="/api/dev-login" method="POST">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full rounded-xl bg-emerald-600 px-6 py-4 text-base font-bold text-white hover:bg-emerald-500 transition-colors"
|
|
||||||
>
|
|
||||||
Login as Platform Admin
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+168
-506
@@ -1,546 +1,208 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import { signInWithGoogle, signInWithCredentials } from "@/actions/auth-actions";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
|
|
||||||
|
|
||||||
function LoginForm() {
|
type LoginClientProps = {
|
||||||
const [email, setEmail] = useState("");
|
hasGoogle: boolean;
|
||||||
const [password, setPassword] = useState("");
|
hasCredentials: boolean;
|
||||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
/** Server-rendered error message, if any (from ?error=...) */
|
||||||
const [loading, setLoading] = useState(false);
|
error: string | null;
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
/** Pre-fill the email in dev (for the seeded admin). */
|
||||||
const [forgotPassword, setForgotPassword] = useState(false);
|
seededEmail?: string;
|
||||||
const [forgotEmail, setForgotEmail] = useState("");
|
/** Where to send the user after a successful sign-in. */
|
||||||
const [forgotSent, setForgotSent] = useState(false);
|
redirectTo?: string;
|
||||||
const [forgotLoading, setForgotLoading] = useState(false);
|
};
|
||||||
const [forgotError, setForgotError] = useState<string | null>(null);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
function GoogleSignIn({ hasGoogle }: { hasGoogle: boolean }) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
if (!hasGoogle) {
|
||||||
setMounted(true);
|
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>
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
<p className="mt-1 text-stone-600">
|
||||||
e.preventDefault();
|
Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
|
||||||
setGlobalError(null);
|
<code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
|
||||||
if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
|
environment to enable it.
|
||||||
if (!password.trim()) { setGlobalError("Please enter your password."); return; }
|
</p>
|
||||||
setLoading(true);
|
</div>
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
<form action={signInWithGoogle}>
|
||||||
{/* Google Fonts */}
|
<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>{`
|
<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");
|
@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; }
|
html, body { overflow: hidden; }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* Organic background elements */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
<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 -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 -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 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>
|
</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="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"}`}>
|
<div className="w-full max-w-sm">
|
||||||
{/* Card */}
|
|
||||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
|
<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="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">
|
<div className="p-8 sm:p-10">
|
||||||
{/* Logo & Title */}
|
|
||||||
<div className="text-center mb-8">
|
<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}>
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</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
|
Welcome back
|
||||||
</h1>
|
</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
|
Sign in to your account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auth.js v5 — primary sign-in: Google OAuth */}
|
<GoogleSignIn hasGoogle={hasGoogle} />
|
||||||
<form action={signInWithGoogle} className="space-y-3">
|
{hasCredentials && hasGoogle && <Divider />}
|
||||||
<button
|
{hasCredentials && <CredentialsForm seededEmail={seededEmail} error={error} />}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</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>
|
</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 type { Metadata } from "next";
|
||||||
import LoginClient from "./LoginClient";
|
import LoginClient from "./LoginClient";
|
||||||
|
import { isDevLoginEnabled } from "@/auth.config";
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||||
|
|
||||||
@@ -21,6 +22,32 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
type SearchParams = { error?: string; redirect?: string };
|
||||||
return <LoginClient />;
|
|
||||||
}
|
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";
|
export default async function LogoutPage() {
|
||||||
import { useRouter } from "next/navigation";
|
await signOut({ redirectTo: "/login" });
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
|
|
||||||
export default function LogoutPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
|
|
||||||
document.cookie = "dev_session=;path=/;max-age=0";
|
|
||||||
document.cookie = "rc_auth_uid=;path=/;max-age=0";
|
|
||||||
document.cookie = "rc_auth_token=;path=/;max-age=0";
|
|
||||||
// Clear shopping cart on logout
|
|
||||||
localStorage.removeItem("route_commerce_cart");
|
|
||||||
localStorage.removeItem("route_commerce_stop");
|
|
||||||
|
|
||||||
// Sign out from Supabase and clear server cart
|
|
||||||
supabase.auth.getUser().then(async ({ data }) => {
|
|
||||||
if (data.user?.id) {
|
|
||||||
const { clearServerCart } = await import("@/actions/checkout");
|
|
||||||
clearServerCart(data.user.id).catch(() => {});
|
|
||||||
}
|
|
||||||
supabase.auth.signOut().then(() => {
|
|
||||||
router.push("/login");
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-100 px-6 py-12">
|
|
||||||
<div className="mx-auto max-w-md">
|
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center">
|
|
||||||
<p className="text-slate-500">Signing out...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+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";
|
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.
|
* This file is imported by `src/middleware.ts`, which runs in the Edge
|
||||||
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
|
* runtime. It must NOT import:
|
||||||
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
|
* - `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
|
* Provider definitions, callbacks, and pages all live here. The full
|
||||||
* implementation), define it in `src/lib/auth.ts` instead and add a thin
|
* server-side handler in `src/lib/auth.ts` extends this with the
|
||||||
* placeholder here so the middleware can still reference it.
|
* 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 = {
|
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,
|
trustHost: true,
|
||||||
|
pages: { signIn: "/login" },
|
||||||
// Providers — referenced from middleware edge runtime.
|
session: { strategy: "jwt" as const },
|
||||||
// The Google provider only needs the env vars at runtime; it does not pull
|
providers: googleProvider,
|
||||||
// in any Node-only code. The dev Credentials provider is added in
|
|
||||||
// `src/lib/auth.ts` (server-side only) — it's not safe to import
|
|
||||||
// `next-auth/providers/credentials` from the edge runtime.
|
|
||||||
providers: [
|
|
||||||
Google({
|
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
|
|
||||||
clientSecret:
|
|
||||||
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
|
|
||||||
// No `authorization` override — we want the default scopes (openid email profile)
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
|
|
||||||
// New users are persisted in the database (handled in src/lib/auth.ts)
|
|
||||||
// Default to JWT here so middleware can run in edge runtime; the full
|
|
||||||
// server-side handler in src/lib/auth.ts switches this to "database".
|
|
||||||
session: { strategy: "jwt" },
|
|
||||||
|
|
||||||
callbacks: {
|
callbacks: {
|
||||||
/**
|
/**
|
||||||
* Gate /admin routes. Anything not on the public list and not signed in
|
* Gate /admin, /wholesale, and /protected-example. Anything on those
|
||||||
* gets redirected to /login. This mirrors what the page-level checks do,
|
* paths and not signed in is redirected to /login (the default
|
||||||
* but runs first at the edge so unauthorized requests never hit the
|
* pages.signIn). Public storefronts and the homepage pass through.
|
||||||
* server component tree.
|
*
|
||||||
|
* The actual role-based gating still happens in `getAdminUser()` —
|
||||||
|
* this callback only ensures the user is *signed in*.
|
||||||
*/
|
*/
|
||||||
authorized({ auth, request: { nextUrl } }) {
|
authorized({ auth, request: { nextUrl } }) {
|
||||||
const isLoggedIn = !!auth?.user;
|
const isLoggedIn = !!auth?.user;
|
||||||
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
|
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
|
||||||
|
const isOnWholesale = nextUrl.pathname.startsWith("/wholesale");
|
||||||
const isOnProtectedExample = nextUrl.pathname.startsWith(
|
const isOnProtectedExample = nextUrl.pathname.startsWith(
|
||||||
"/protected-example"
|
"/protected-example"
|
||||||
);
|
);
|
||||||
|
if (isOnAdmin || isOnWholesale || isOnProtectedExample) {
|
||||||
if (isOnAdmin) {
|
return isLoggedIn;
|
||||||
if (isLoggedIn) return true;
|
|
||||||
return false; // Redirect to /login
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOnProtectedExample) {
|
|
||||||
if (isLoggedIn) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forward the user id from the database user record into the JWT on
|
* Forward the user id into the JWT on initial sign-in. With
|
||||||
* initial sign-in. With database sessions this is what populates
|
* `session.strategy: "jwt"` this is the field downstream code reads
|
||||||
* `session.user.id` for downstream server actions.
|
* via `session.user.id`.
|
||||||
*/
|
*/
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
if (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;
|
return token;
|
||||||
},
|
},
|
||||||
|
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (session.user && token?.sub) {
|
if (session.user) {
|
||||||
(session.user as { id?: string }).id = token.sub;
|
session.user.id =
|
||||||
|
(typeof token.id === "string" && token.id) ||
|
||||||
|
(typeof token.sub === "string" && token.sub) ||
|
||||||
|
"";
|
||||||
}
|
}
|
||||||
return session;
|
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;
|
} satisfies NextAuthConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: are we in development AND allowed to use the dev credentials
|
* Is the dev Credentials provider enabled?
|
||||||
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
|
* - Disabled in production (NODE_ENV === "production")
|
||||||
* include the provider in its provider list.
|
* - 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 {
|
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 { useRouter } from "next/navigation";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { signOutAction } from "@/actions/auth-actions";
|
||||||
|
|
||||||
type AdminHeaderProps = {
|
type AdminHeaderProps = {
|
||||||
userRole?: string | null;
|
userRole?: string | null;
|
||||||
@@ -89,10 +89,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
|||||||
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
|
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
document.cookie = "dev_session=;path=/;max-age=0";
|
await signOutAction();
|
||||||
await supabase.auth.signOut();
|
|
||||||
router.push("/login");
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { signOutAction } from "@/actions/auth-actions";
|
||||||
import BrandSelector from "./BrandSelector";
|
|
||||||
|
|
||||||
// Elegant warm sidebar design
|
// Elegant warm sidebar design
|
||||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||||
@@ -220,10 +219,15 @@ type SidebarProps = {
|
|||||||
userRole?: string | null;
|
userRole?: string | null;
|
||||||
brandIds?: string[];
|
brandIds?: string[];
|
||||||
activeBrandId?: string | null;
|
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 pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
@@ -234,15 +238,9 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
|
|||||||
|
|
||||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||||
: userRole === "brand_admin" ? "Brand Admin"
|
: userRole === "brand_admin" ? "Brand Admin"
|
||||||
: userRole === "multi_brand_admin" ? "Multi-Brand Manager"
|
|
||||||
: userRole === "store_employee" ? "Store Employee"
|
: userRole === "store_employee" ? "Store Employee"
|
||||||
: null;
|
: 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) => {
|
const isActive = useCallback((href: string) => {
|
||||||
if (href === "/admin") return pathname === "/admin";
|
if (href === "/admin") return pathname === "/admin";
|
||||||
return pathname.startsWith(href);
|
return pathname.startsWith(href);
|
||||||
@@ -306,10 +304,7 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
|
|||||||
}, [router, mobileOpen, closeMobileMenu]);
|
}, [router, mobileOpen, closeMobileMenu]);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
document.cookie = "dev_session=;path=/;max-age=0";
|
await signOutAction();
|
||||||
await supabase.auth.signOut();
|
|
||||||
router.push("/login");
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -404,24 +399,6 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 links with keyboard navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
|
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
|
||||||
<ul className="space-y-1" role="list">
|
<ul className="space-y-1" role="list">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type StopProductAssignmentProps = {
|
|||||||
stopId: string;
|
stopId: string;
|
||||||
allProducts: Product[];
|
allProducts: Product[];
|
||||||
assignedProducts: AssignedProduct[];
|
assignedProducts: AssignedProduct[];
|
||||||
callerUid: string;
|
callerUid: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Filter = "all" | "available" | "assigned";
|
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
|
// Shared AdminUser type — safe to import from both server and client
|
||||||
//
|
// components. The shape mirrors what `getAdminUser()` returns and
|
||||||
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
|
// includes both the user's role and the tenant they belong to.
|
||||||
// `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
|
export type AdminRole = "platform_admin" | "brand_admin" | "store_employee";
|
||||||
// (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]`.
|
|
||||||
export type AdminUser = {
|
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;
|
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;
|
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[];
|
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;
|
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_products: boolean;
|
||||||
can_manage_stops: boolean;
|
can_manage_stops: boolean;
|
||||||
can_manage_orders: boolean;
|
can_manage_orders: boolean;
|
||||||
@@ -24,5 +53,21 @@ export type AdminUser = {
|
|||||||
can_manage_water_log: boolean;
|
can_manage_water_log: boolean;
|
||||||
can_manage_reports: boolean;
|
can_manage_reports: boolean;
|
||||||
can_manage_settings: 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;
|
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 "server-only";
|
||||||
import type { AdminUser } from "./admin-permissions-types";
|
import { eq } from "drizzle-orm";
|
||||||
export type { AdminUser } from "./admin-permissions-types";
|
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:
|
* Looks up the Auth.js v5 session, then resolves the user + tenant
|
||||||
* 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev.
|
* from the `users` and `tenant_users` tables.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* `brand_id` is the active brand; `brand_ids` is the full membership list.
|
* Returns `null` if:
|
||||||
* For dev sessions without a real DB, `brand_ids` is populated by:
|
* - No Auth.js session (caller not signed in)
|
||||||
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
|
* - The session email doesn't match any `users.email`
|
||||||
* - store_employee: `[<first real brand>]` if a brand exists, else `[]`
|
* - The user has no `tenant_users` row (not provisioned yet)
|
||||||
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev)
|
*
|
||||||
|
* 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> {
|
export async function getAdminUser(): Promise<AdminUser | null> {
|
||||||
const cookieStore = await cookies();
|
let sessionEmail: string | null = null;
|
||||||
|
|
||||||
// ── 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[] = [];
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const session = await auth();
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
sessionEmail = session?.user?.email ?? null;
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
} catch (err) {
|
||||||
);
|
console.error("[admin-permissions] auth() failed:", err);
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json().catch(() => []);
|
|
||||||
adminUsers = Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// fetch failed silently
|
|
||||||
}
|
|
||||||
|
|
||||||
// First login — auto-create platform_admin via SECURITY DEFINER RPC
|
|
||||||
if (adminUsers.length === 0) {
|
|
||||||
// Check if uid is a valid UUID before trying to insert
|
|
||||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
if (!UUID_REGEX.test(uid)) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
|
|
||||||
body: JSON.stringify({ p_user_id: uid }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (res.ok) {
|
|
||||||
const inserted = await res.json().catch(() => null);
|
|
||||||
if (inserted && inserted.length > 0) {
|
|
||||||
return buildAdminUser(inserted[0] as Record<string, unknown>, []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// RPC failed silently
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const admin = adminUsers[0] as Record<string, unknown>;
|
if (!sessionEmail) return null;
|
||||||
if (!admin.active) return null;
|
|
||||||
|
|
||||||
// Load brand_ids from the admin_user_brands junction
|
return await withPlatformAdmin(async (db) => {
|
||||||
const brandIds = await fetchAdminUserBrandIds(supabaseUrl, serviceKey, admin.id as string);
|
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.
|
* Resolves the current admin user AND their tenant. Returns `null` if
|
||||||
* Returns an empty array on any failure (e.g. before migration 207 is applied).
|
* 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(
|
export async function getCurrentTenant(): Promise<TenantContext | null> {
|
||||||
supabaseUrl: string,
|
const user = await getAdminUser();
|
||||||
serviceKey: string,
|
if (!user) return null;
|
||||||
adminRowId: string
|
if (!user.tenant_id) {
|
||||||
): Promise<string[]> {
|
// platform_admin — no specific tenant
|
||||||
try {
|
return null;
|
||||||
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 [];
|
|
||||||
}
|
}
|
||||||
|
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.
|
// Re-exports for backward compat
|
||||||
// - 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
|
export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permissions-types";
|
||||||
// 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).
|
* @deprecated Kept for unit tests that exercise the dev shim path.
|
||||||
// `role` is narrowed to the strict union — we know the dev callers pass
|
* Production code should never call this — `getAdminUser()` only reads
|
||||||
// only valid values.
|
* the Auth.js session now.
|
||||||
const base = {
|
*/
|
||||||
|
export function buildDevAdmin(role: AdminRole): AdminUser {
|
||||||
|
const isPlatform = role === "platform_admin";
|
||||||
|
const tenantId = isPlatform ? null : "dev-tenant";
|
||||||
|
return {
|
||||||
id: "dev",
|
id: "dev",
|
||||||
user_id: "dev",
|
user_id: "dev",
|
||||||
brand_id: null,
|
email: null,
|
||||||
brand_ids: [] as string[],
|
display_name: "Demo Admin",
|
||||||
role: role as AdminUser["role"],
|
tenant_id: tenantId,
|
||||||
|
brand_id: tenantId, // legacy alias
|
||||||
|
brand_ids: tenantId ? [tenantId] : [], // legacy array alias
|
||||||
|
tenant_slug: isPlatform ? null : "tuxedo",
|
||||||
|
role,
|
||||||
active: true,
|
active: true,
|
||||||
|
auth_provider: "dev",
|
||||||
|
...permissionsForRole(role),
|
||||||
must_change_password: false,
|
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 {
|
function buildAdminUser(input: {
|
||||||
// The DB column is TEXT (per CLAUDE.md) so the runtime value is a string.
|
id: string;
|
||||||
// We narrow it to the known union here. If the DB has an unknown role
|
email: string | null;
|
||||||
// (e.g. a future role), the migration's CHECK constraint will reject it
|
displayName: string | null;
|
||||||
// before it ever reaches this function.
|
authProvider: "dev" | "google" | "email" | null;
|
||||||
const role = r.role as AdminUser["role"];
|
tenantId: string;
|
||||||
// `brand_id` is the *legacy* single-brand column — preserved here as-is.
|
tenantSlug: string;
|
||||||
// The canonical "active brand" is resolved by `getActiveBrandId` on each
|
tenantName: string;
|
||||||
// page/action, which considers URL params, the active_brand_id cookie,
|
role: AdminRole;
|
||||||
// and this legacy fallback. Setting `brand_id` here to a sensible default
|
active: boolean;
|
||||||
// (legacy → first of brand_ids) keeps the AdminUser shape useful even
|
}): AdminUser {
|
||||||
// for callers that haven't migrated to `getActiveBrandId` yet.
|
return {
|
||||||
const legacyBrandId = (r.brand_id as string | null) ?? null;
|
id: input.id,
|
||||||
const base = {
|
user_id: input.id,
|
||||||
id: r.id as string,
|
email: input.email,
|
||||||
user_id: r.user_id as string,
|
display_name: input.displayName,
|
||||||
brand_id: legacyBrandId ?? brandIds[0] ?? null,
|
tenant_id: input.tenantId,
|
||||||
brand_ids: brandIds,
|
brand_id: input.tenantId, // legacy alias
|
||||||
role,
|
brand_ids: [input.tenantId], // legacy array alias
|
||||||
active: r.active as boolean,
|
tenant_slug: input.tenantSlug,
|
||||||
must_change_password: Boolean(r.must_change_password),
|
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 "server-only";
|
||||||
import PostgresAdapter from "@auth/pg-adapter";
|
|
||||||
import { Pool } from "pg";
|
|
||||||
import Credentials from "next-auth/providers/credentials";
|
|
||||||
import {
|
|
||||||
authConfig,
|
|
||||||
isDevLoginEnabled,
|
|
||||||
} from "@/auth.config";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the dev Credentials provider. Lives here (Node-only) because
|
* Auth.js (NextAuth v5) — server-side configuration.
|
||||||
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
|
*
|
||||||
* that the middleware uses.
|
* 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({
|
return Credentials({
|
||||||
id: "dev-login",
|
id: "credentials",
|
||||||
name: "Dev login",
|
name: "Email + password",
|
||||||
credentials: {
|
credentials: {
|
||||||
username: { label: "Username", type: "text" },
|
email: { label: "Email", type: "email" },
|
||||||
password: { label: "Password", type: "password" },
|
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) {
|
async authorize(creds) {
|
||||||
if (!isDevLoginEnabled()) return null;
|
if (!isDevLoginEnabled()) return null;
|
||||||
// Any non-empty username/password combo is accepted; this is purely a
|
const email = String(creds?.email ?? "").trim().toLowerCase();
|
||||||
// local convenience for smoke testing without Google OAuth.
|
|
||||||
const username = String(creds?.username ?? "").trim();
|
|
||||||
const password = String(creds?.password ?? "");
|
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 {
|
try {
|
||||||
const pool = getPool();
|
// The `users` table is global (not tenant-scoped), so we use
|
||||||
const userId = user.id;
|
// `withDb` rather than `withTenant` — no GUC to set.
|
||||||
if (!userId) return;
|
const u = await withDb(async (db) => {
|
||||||
// Fire and forget — don't block sign-in on a missing admin_users row.
|
const rows = await db
|
||||||
await pool.query(
|
.select()
|
||||||
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
|
.from(users)
|
||||||
[userId]
|
.where(eq(users.email, email))
|
||||||
);
|
.limit(1);
|
||||||
// Note: we don't auto-create here; the existing `getAdminUser()`
|
return rows[0] ?? null;
|
||||||
// in `src/lib/admin-permissions.ts` is the source of truth for
|
});
|
||||||
// role lookups and is unchanged. After this migration the user
|
if (!u || !u.passwordHash) return null;
|
||||||
// is authenticated; the existing `dev_session` demo path still
|
if (!verifyPassword(password, u.passwordHash)) return null;
|
||||||
// works for the smoke test.
|
return {
|
||||||
} catch (e) {
|
id: u.id,
|
||||||
|
name: u.name ?? undefined,
|
||||||
|
email: u.email,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
// 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";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||||
// Login flow: credentials → /api/login JSON → redirect to /admin
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
test("login with valid credentials redirects to /admin and shows admin dashboard", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// Navigate to login page
|
|
||||||
await page.goto("/login");
|
|
||||||
|
|
||||||
// Fill credentials
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
|
// Login page — Google OAuth is the only login path
|
||||||
await page.fill("#password", process.env.TEST_ADMIN_PASSWORD!);
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
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
|
test("shows a friendly 'not configured' message when Google OAuth env is missing", async ({ page }) => {
|
||||||
await page.click('button[type="submit"]');
|
// 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
|
test("/admin redirects to /login when not authenticated", async ({ page }) => {
|
||||||
await page.waitForURL("**/admin", { timeout: 10000 });
|
const res = await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
|
||||||
|
// After redirect, the URL should be /login (with ?redirect=/admin).
|
||||||
// Admin dashboard must be rendered (Control Center heading or admin layout)
|
expect(page.url()).toMatch(/\/login(\?|$)/);
|
||||||
await expect(page.locator("body")).not.toContainText("Access Denied");
|
expect(res?.status()).toBeLessThan(500);
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
|
|||||||
+30
-43
@@ -3,39 +3,9 @@ import { test, expect } from "@playwright/test";
|
|||||||
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
test.describe("Smoke Tests", () => {
|
test.describe("Smoke Tests", () => {
|
||||||
test("Login — dev_session platform_admin lands on admin dashboard", async ({ page }) => {
|
test("Homepage loads", async ({ page }) => {
|
||||||
await page.context().addCookies([
|
const res = await page.goto(`${BASE}/`);
|
||||||
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
expect(res?.status()).toBeLessThan(500);
|
||||||
]);
|
|
||||||
|
|
||||||
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 });
|
|
||||||
await expect(page.locator("body")).toBeVisible();
|
await expect(page.locator("body")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,17 +16,34 @@ test.describe("Smoke Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(`${BASE}/tuxedo`);
|
await page.goto(`${BASE}/tuxedo`);
|
||||||
|
await expect(page.locator("h1").first()).toBeVisible({ timeout: 10_000 });
|
||||||
// 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)
|
|
||||||
const criticalErrors = errors.filter(
|
const criticalErrors = errors.filter(
|
||||||
(e) => !e.includes("favicon") && !e.includes("Warning:")
|
(e) => !e.includes("favicon") && !e.includes("Warning:"),
|
||||||
);
|
);
|
||||||
expect(criticalErrors).toHaveLength(0);
|
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": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"@/db": ["./db"],
|
||||||
|
"@/db/*": ["./db/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"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