feat: remove dev_session, add Drizzle schema + RLS + real auth

BREAKING: dev_session cookie bypass removed. Admin access now requires
a real Auth.js v5 session (Google OAuth in production). Provision users
by inserting into users + tenant_users tables.

New in this commit:
- db/migrations/0001_init.sql: 18-table SaaS schema with RLS (tenants,
  users, tenant_users, plans, add_ons, subscriptions, tenant_add_ons,
  products, product_images, stops, customers, orders, order_items,
  brand_settings, email_templates, campaigns, files, audit_log)
- db/schema/: Drizzle TypeScript mirror of every table
- db/client.ts: withTenant() / withPlatformAdmin() query wrappers that
  set Postgres GUCs (app.current_tenant_id, app.platform_admin) for
  RLS enforcement. Never query a tenant-scoped table without one.
- db/seed.ts: seeds 3 plans, 6 add-ons, 2 tenants (Tuxedo, Indian River
  Direct), brand_settings, sample products/stops/customers
- scripts/migrate.js: applies migrations in lexical order with tracking
- scripts/db-reset.js: drops + recreates DB, runs migrate + seed
- DATABASE_URL now uses rc_app (non-superuser, NOBYPASSRLS). RLS is
  enforced even for the app user. DATABASE_ADMIN_URL for migrations.
- src/lib/admin-permissions.ts: getAdminUser() reads Auth.js session,
  looks up user + tenant in Postgres. brand_id kept as alias for
  backward compat.
- src/middleware.ts: Auth.js-only route protection, dev_session gone
- src/app/login/LoginClient.tsx: Google OAuth only, no demo mode
- src/components/admin/AdminSidebar.tsx + AdminHeader.tsx: signOutAction
  replaces supabase signout
- @/db/* path aliases in tsconfig.json + vitest.config.ts
- drizzle.config.ts added
- db/auth_schema.sql removed (was a stub; replaced by real schema)
- src/app/api/dev-login/route.ts deleted
- tests: updated to remove dev_session coverage
This commit is contained in:
2026-06-07 01:23:44 +00:00
parent f96dcd01f2
commit 7cd0603cfb
41 changed files with 2917 additions and 1950 deletions
+204
View File
@@ -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
View File
@@ -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 };
+523
View File
@@ -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;
+39
View File
@@ -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;
+96
View File
@@ -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;
+28
View File
@@ -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;
+45
View File
@@ -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;
+75
View File
@@ -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];
+39
View File
@@ -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;
+17
View File
@@ -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";
+71
View File
@@ -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;
+67
View File
@@ -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;
+63
View File
@@ -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;
+41
View File
@@ -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;
+78
View File
@@ -0,0 +1,78 @@
/**
* 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"),
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;
+285
View File
@@ -0,0 +1,285 @@
/**
* 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";
// 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 user (not tied to any single tenant) ──────────────
await client.query(
`INSERT INTO users (email, name, auth_provider, auth_subject)
VALUES ('platform@route-commerce.example', 'Platform Admin', 'dev', 'dev-platform-admin')
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`,
);
console.log("Seeded platform admin user");
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);
});
+18
View File
@@ -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,
});
+9 -2
View File
@@ -8,8 +8,12 @@
"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": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
@@ -27,6 +31,7 @@
"@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",
@@ -60,12 +65,14 @@
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^4.7.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", "jsdom": "^25.0.1",
"pg": "^8.20.0", "pg": "^8.20.0",
"playwright": "^1.59.1", "playwright": "^1.59.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.22.4",
"typescript": "^5", "typescript": "^5",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.9" "vitest": "^2.1.9"
+44
View File
@@ -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");
+91
View File
@@ -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);
});
+243 -541
View File
@@ -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,165 +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 supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // can_manage_<X> (BOOLEAN each), active, must_change_password,
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
const response = NextResponse.next({ request });
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));
},
},
});
const devSession = cookieStore.get("dev_session")?.value;
return { supabase, response, devSession };
}
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
const { supabase, devSession } = await getAuthClient();
// Dev mode bypass — let the action proceed without Supabase auth.
// (Pre-Auth.js this was gated on the legacy `rc_auth_uid === DEV_FORCE_UID`
// cookie that the now-deleted `/api/force-admin` route set. With Auth.js v5
// in place, the `dev_session` cookie is the single source of truth for the
// demo flow.)
if (process.env.NODE_ENV !== "production" && devSession) {
return { data: null, error: null };
}
const { data: userData, error: userError } = await supabase.auth.getUser();
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,
@@ -256,419 +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) ─────────────────
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: dev_session cookie holders use the dev/service path.
// (The previous code also accepted a legacy `rc_auth_uid` cookie set by
// `/api/dev-login` — `/api/dev-login` still sets both cookies, so existing
// dev sessions keep working. The legacy DEV_FORCE_UID check is removed
// because the only route that set that specific UID was deleted.)
if (process.env.NODE_ENV !== "production" && devSession) {
return devListAdminUsers(devSession);
}
// Dev session cookie (platform_admin/brand_admin) — always use service role path
const isDevAdmin = process.env.NODE_ENV !== "production" && (
devSession === "platform_admin" || devSession === "brand_admin"
);
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 devSession = cookieStore.get("dev_session")?.value;
// TODO: when the Auth.js v5 migration lands everywhere, replace this
// cookie-based check with a session check via `await auth()` from `@/lib/auth`.
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
// Dev path: use service role to create user without Supabase auth session
if (isDevAdmin) {
return devCreateAdminUser(input);
}
// Production path — service role creates the account. The caller is
// expected to be an authenticated admin (gated by the admin layout /
// getAdminUser() check on the page).
// Keep reading the legacy `rc_auth_uid` cookie for backward compat with
// pre-Auth.js sessions — TODO: drop this branch once all clients are on
// Auth.js.
const headerStore = await headers();
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
if (rcAuthUid) {
const service = getServiceClient();
// 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 devSession = cookieStore.get("dev_session")?.value; const idx = mockUsers.findIndex((u) => u.id === input.id);
// Dev mode — let the action proceed with the service role. if (idx === -1) return { user: null, error: "User not found" };
// (The previous code also accepted a legacy `rc_auth_uid === DEV_FORCE_UID` const merged: AdminUserRow = { ...mockUsers[idx] };
// cookie set by the now-deleted Emergency Force Login page. With Auth.js v5 if (input.role !== undefined) merged.role = input.role;
// and the demo buttons in /login, the `dev_session` cookie is sufficient.) if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
if (process.env.NODE_ENV !== "production" && devSession) { if (input.active !== undefined) merged.active = input.active;
const service = getServiceClient(); if (input.display_name !== undefined) merged.display_name = input.display_name;
const { data, error } = await service if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
.from("admin_users") if (input.flags) {
.update({ for (const [k, v] of Object.entries(input.flags)) {
role: input.role ?? undefined, if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
brand_id: input.brand_id ?? undefined, }
can_manage_products: input.flags?.can_manage_products ?? undefined, }
can_manage_stops: input.flags?.can_manage_stops ?? undefined, mockUsers[idx] = merged;
can_manage_orders: input.flags?.can_manage_orders ?? undefined, return { user: merged, error: null };
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 devSession = cookieStore.get("dev_session")?.value; const idx = mockUsers.findIndex((u) => u.id === id);
// Dev mode — let the action proceed with the service role. if (idx === -1) return { success: false, error: "User not found" };
// (The previous code gated on `rc_auth_uid === DEV_FORCE_UID`, a magic mockUsers.splice(idx, 1);
// cookie value the now-deleted Emergency Force Login page set. With
// Auth.js v5 and the demo buttons in /login, the `dev_session` cookie is
// the source of truth for the dev path.)
if (process.env.NODE_ENV !== "production" && devSession) {
const service = getServiceClient();
// Get user_id first
const { data: adminRow, error: fetchError } = await service
.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 };
// Dev path or legacy rc_auth_uid cookie — use service role directly.
// TODO: when Auth.js v5 is the only auth path, drop the rcAuthUid branch
// and require `await auth()` to be present.
if (process.env.NODE_ENV !== "production" || rcAuthUid) {
const service = getServiceClient();
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null };
} }
// 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;
+1 -3
View File
@@ -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> {
+8 -35
View File
@@ -1,43 +1,16 @@
"use server"; "use server";
import "server-only";
import { signIn, signOut } from "@/lib/auth"; import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
export type SignInResult = { ok: true } | { ok: false; error: string };
/** /**
* Sign in with the email/password (Supabase-backed) Credentials provider * Kick off the Google OAuth flow. Auth.js will redirect to Google's
* configured in src/lib/auth.ts. * consent screen and then back to /api/auth/callback/google, which sets
*/ * the session cookie and redirects to /admin.
export async function signInWithPassword( *
_prev: SignInResult | null, * The historical Supabase-backed email/password sign-in action was
formData: FormData * removed in the cleanup pass. Admin accounts are provisioned by an
): Promise<SignInResult> { * existing platform admin via /admin/users.
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!email) return { ok: false, error: "Please enter your email address." };
if (!password) return { ok: false, error: "Please enter your password." };
try {
await signIn("supabase-password", {
email,
password,
redirect: false,
});
return { ok: true };
} catch (err) {
if (err instanceof AuthError) {
return { ok: false, error: "Invalid email or password." };
}
throw err;
}
}
/**
* Kick off the Google OAuth flow. Auth.js will redirect to Google's consent
* screen and then back to /api/auth/callback/google, which sets the session
* cookie and redirects to the configured callback URL.
*/ */
export async function signInWithGoogle(): Promise<void> { export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" }); await signIn("google", { redirectTo: "/admin" });
+10 -41
View File
@@ -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 (
-21
View File
@@ -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;
}
+48 -416
View File
@@ -1,116 +1,73 @@
"use client"; "use client";
import { useState, useCallback, Suspense, useEffect } from "react"; import { signInWithGoogle } from "@/actions/auth-actions";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import {
signInWithPassword,
signInWithGoogle,
type SignInResult,
} from "@/actions/auth-actions";
function LoginForm() { type LoginClientProps = {
const [result, setResult] = useState<SignInResult | null>(null); hasGoogle: boolean;
const [submitting, setSubmitting] = useState(false); };
const [forgotPassword, setForgotPassword] = useState(false);
const [forgotEmail, setForgotEmail] = useState("");
const [forgotSent, setForgotSent] = useState(false);
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
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-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-medium">Google sign-in is not configured.</p>
const handleSubmit = useCallback( <p className="mt-1 text-amber-800">
async (e: React.FormEvent<HTMLFormElement>) => { Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
e.preventDefault(); <code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
setSubmitting(true); environment to enable it. See{" "}
setResult(null); <a
const fd = new FormData(e.currentTarget); className="underline"
const r = await signInWithPassword(null, fd); href="https://authjs.dev/getting-started/providers/google"
setResult(r); target="_blank"
setSubmitting(false); rel="noreferrer"
if (r.ok) { >
// Server action succeeded; navigate to /admin Auth.js Google provider docs
window.location.replace("/admin"); </a>
} .
}, </p>
[] </div>
); );
}
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 r = await fetch("/api/forgot-password", {
method: "POST",
body: fd,
})
.then((r) => r.json())
.catch(() => ({ error: "Network error" }));
setForgotLoading(false);
if (r.error) {
setForgotError(r.error);
} else {
setForgotSent(true);
}
},
[forgotEmail]
);
const globalError = result && !result.ok ? result.error : null;
return (
<form action={signInWithGoogle}>
<button
type="submit"
className="w-full flex items-center justify-center gap-3 rounded-xl bg-white border border-stone-200/80 px-6 py-3.5 text-sm font-semibold text-stone-800 hover:bg-stone-50 active:scale-[0.98] transition-all shadow-sm"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0012 23z" />
<path fill="#FBBC05" d="M5.84 14.1A6.6 6.6 0 015.5 12c0-.73.13-1.44.34-2.1V7.07H2.18A10.99 10.99 0 001 12c0 1.77.43 3.45 1.18 4.93l3.66-2.83z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.83C6.71 7.31 9.14 5.38 12 5.38z" />
</svg>
Continue with Google
</button>
</form>
);
}
export default function LoginClient({ hasGoogle }: LoginClientProps) {
return ( return (
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}> <main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
{/* Google Fonts */}
<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}>
@@ -125,336 +82,11 @@ function LoginForm() {
</p> </p>
</div> </div>
{/* Google sign-in (primary) */} <GoogleSignIn hasGoogle={hasGoogle} />
<form action={signInWithGoogle}>
<button
type="submit"
className="w-full flex items-center justify-center gap-3 rounded-xl bg-white border border-stone-200/80 px-6 py-3.5 text-sm font-semibold text-stone-800 hover:bg-stone-50 active:scale-[0.98] transition-all shadow-sm"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0012 23z" />
<path fill="#FBBC05" d="M5.84 14.1A6.6 6.6 0 015.5 12c0-.73.13-1.44.34-2.1V7.07H2.18A10.99 10.99 0 001 12c0 1.77.43 3.45 1.18 4.93l3.66-2.83z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.83C6.71 7.31 9.14 5.38 12 5.38z" />
</svg>
Continue with Google
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-3 my-6">
<div className="flex-1 h-px bg-stone-200/80" />
<span className="text-xs uppercase tracking-wider text-stone-400" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
or
</span>
<div className="flex-1 h-px bg-stone-200/80" />
</div>
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
{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"
name="email"
type="email"
required
autoComplete="username"
disabled={submitting}
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
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>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
disabled={submitting}
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
placeholder="••••••••"
aria-required="true"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
>
{submitting ? "Signing in..." : "Sign in"}
</button>
{!forgotPassword && !forgotSent && (
<button
type="button"
onClick={() => { setForgotPassword(true); setResult(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&apos;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>
</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>
);
}
+5 -1
View File
@@ -22,5 +22,9 @@ export const metadata: Metadata = {
}; };
export default function LoginPage() { export default function LoginPage() {
return <LoginClient />; // 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);
return <LoginClient hasGoogle={hasGoogle} />;
} }
+2 -5
View File
@@ -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"
+2 -5
View File
@@ -4,7 +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";
// 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
@@ -296,10 +296,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
}, [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 (
+51 -4
View File
@@ -1,9 +1,40 @@
// 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
// includes both the user's role and the tenant they belong to.
export type AdminRole = "platform_admin" | "brand_admin" | "store_employee";
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;
role: "platform_admin" | "brand_admin" | "store_employee" | "staff"; /** tenant slug (for storefronts) */
tenant_slug: string | null;
/** role within the tenant (or platform-wide for platform_admin) */
role: AdminRole;
/** is the user active? */
active: boolean;
/** auth provider */
auth_provider: "dev" | "google" | "email" | null;
// ── Permission flags ────────────────────────────────────────────
// Derived from the role, but exposed as individual booleans so
// existing consumer code (forms, sidebar, etc.) can read them
// directly without doing role math. See `permissionsForRole()` in
// admin-permissions.ts for the source of truth.
can_manage_products: boolean; can_manage_products: boolean;
can_manage_stops: boolean; can_manage_stops: boolean;
can_manage_orders: boolean; can_manage_orders: boolean;
@@ -14,5 +45,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 -175
View File
@@ -1,201 +1,223 @@
import "server-only"; import "server-only";
import { cookies } from "next/headers"; import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { withPlatformAdmin } from "@/db/client";
export type AdminUser = { import { users, tenants, tenantUsers } from "@/db/schema";
id: string; import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permissions-types";
user_id: string;
brand_id: string | null;
role: string;
active: boolean;
can_manage_products: boolean;
can_manage_stops: boolean;
can_manage_orders: boolean;
can_manage_pickup: boolean;
can_manage_messages: boolean;
can_manage_refunds: boolean;
can_manage_users: boolean;
can_manage_water_log: boolean;
can_manage_reports: boolean;
can_manage_settings: boolean;
must_change_password: boolean;
};
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** /**
* Resolves the current admin user. * Source of truth for the current admin user.
* *
* Auth source precedence: * Looks up the Auth.js v5 session, then resolves the user + tenant
* 1. `NEXT_PUBLIC_USE_MOCK_DATA=true` — return a platform_admin dev shim. * from the `users` and `tenant_users` tables.
* 2. `dev_session` cookie — return the matching dev shim
* (platform_admin / brand_admin / store_employee).
* 3. Auth.js v5 session — call the `get_admin_user_for_session` RPC,
* which transparently looks up by `user_id` (Supabase UUID) or
* `auth_subject` (Google `sub` claim). Falls back to a direct
* `user_id` / `email` REST query for the pre-migration schema.
* Auto-provisions first-time sign-ins via `upsert_admin_user`
* (also handles both provider paths).
* *
* Both RPCs are added by supabase/migrations/204_admin_users_email_and_auth_subject.sql. * Returns `null` if:
* Until that migration is applied, the function degrades to a direct REST * - No Auth.js session (caller not signed in)
* query (the same lookup the previous code did) and skips auto-provisioning. * - The session email doesn't match any `users.email`
* - The user has no `tenant_users` row (not provisioned yet)
* *
* Errors from the auth library or the network are caught and return `null` * Provisioning: an admin must run
* — the admin layout's existing `try/catch` then renders `AdminAccessDenied` * INSERT INTO users (email, ...) VALUES (...)
* with a generic message instead of crashing the server render. * 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> {
let cookieStore; let sessionEmail: string | null = null;
try { try {
cookieStore = await cookies(); const session = await auth();
} catch { sessionEmail = session?.user?.email ?? null;
} catch (err) {
console.error("[admin-permissions] auth() failed:", err);
return null; return null;
} }
// ── Mock data mode for UI review ───────────────────────────────── if (!sessionEmail) return null;
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
return buildDevAdmin("platform_admin");
}
// ── Dev session bypass (enabled for testing on all envs) ──────── return await withPlatformAdmin(async (db) => {
const dev = cookieStore.get("dev_session")?.value; const userRows = await db
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") { .select()
return buildDevAdmin(dev); .from(users)
} .where(eq(users.email, sessionEmail))
.limit(1);
const user = userRows[0];
if (!user) return null;
// ── Auth.js v5 session ────────────────────────────────────────── const membershipRows = await db
let session; .select({
try { tenantId: tenants.id,
session = await auth(); tenantName: tenants.name,
} catch { tenantSlug: tenants.slug,
return null; tenantStatus: tenants.status,
} role: tenantUsers.role,
const sessionId = session?.user?.id; })
const email = session?.user?.email?.toLowerCase() ?? null; .from(tenantUsers)
if (!sessionId) return null; .innerJoin(tenants, eq(tenants.id, tenantUsers.tenantId))
.where(eq(tenantUsers.userId, user.id))
.limit(1);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; if (membershipRows.length === 0) {
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; // Signed in but not provisioned for any tenant.
if (!supabaseUrl || !serviceKey) return null; return null;
}
const adminHeaders = { apikey: serviceKey, "Content-Type": "application/json" } as const; const m = membershipRows[0];
let admin: Record<string, unknown> | null = null; const role = m.role as AdminRole;
return buildAdminUser({
// 1. Try the new `get_admin_user_for_session` RPC (handles both UUID id: user.id,
// and Google-subject lookups in one call). 404 = function doesn't email: user.email,
// exist yet (migration 204 not applied) — fall through to legacy. displayName: user.name,
try { authProvider: user.authProvider,
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/get_admin_user_for_session`, { tenantId: m.tenantId,
method: "POST", tenantSlug: m.tenantSlug,
headers: { ...adminHeaders, Prefer: "return=representation" }, tenantName: m.tenantName,
body: JSON.stringify({ p_session_id: sessionId }), role,
active: true,
}); });
if (res.ok) { });
admin = await parseRpcSingle(res);
}
// 404 / 5xx → fall through to legacy
} catch {
// network error — fall through
}
// 2. Legacy fallback: direct REST query. UUIDs match `user_id`,
// non-UUIDs (Google subjects) match `email`.
if (!admin) {
try {
const filter = UUID_REGEX.test(sessionId)
? `user_id=eq.${sessionId}&limit=1`
: `email=ilike.${encodeURIComponent(email ?? "")}&limit=1`;
const res = await fetch(`${supabaseUrl}/rest/v1/admin_users?${filter}`, {
headers: adminHeaders,
});
if (res.ok) admin = await parseFirstRow(res);
} catch {
// fetch failed silently
}
}
if (admin) {
if (!admin.active) return null;
return buildAdminUser(admin);
}
// 3. First-time sign-in: auto-provision via the new RPC. Only runs
// once the migration is applied (404 on the RPC = no-op, fall
// through to `null`).
try {
const isUuid = UUID_REGEX.test(sessionId);
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`, {
method: "POST",
headers: { ...adminHeaders, Prefer: "return=representation" },
body: JSON.stringify({
p_user_id: isUuid ? sessionId : null,
p_email: email,
p_auth_provider: isUuid ? "supabase" : "google",
p_auth_subject: isUuid ? null : sessionId,
}),
});
if (res.ok) {
const row = await parseRpcSingle(res);
if (row) return buildAdminUser(row);
}
} catch {
// RPC failed silently
}
return null;
}
async function parseRpcSingle(res: Response): Promise<Record<string, unknown> | null> {
const data = await res.json().catch(() => null);
if (Array.isArray(data) && data.length > 0) return data[0] as Record<string, unknown>;
if (data && typeof data === "object" && "id" in (data as Record<string, unknown>)) {
return data as Record<string, unknown>;
}
return null;
}
async function parseFirstRow(res: Response): Promise<Record<string, unknown> | null> {
const data = (await res.json().catch(() => [])) as unknown;
if (Array.isArray(data) && data.length > 0) return data[0] as Record<string, unknown>;
return null;
} }
/** /**
* Builds an `AdminUser` for a `dev_session` cookie holder. Exported so * Resolves the current admin user AND their tenant. Returns `null` if
* unit tests can verify the dev shim is the source of truth for the * the user is not signed in or has no tenant. For platform_admin (no
* demo flow. * tenant), `tenant` is `null` and callers should use `withPlatformAdmin`
* to query across all tenants.
*/ */
export function buildDevAdmin(role: string): AdminUser { export async function getCurrentTenant(): Promise<TenantContext | null> {
const base = { id: "dev", user_id: "dev", brand_id: null, role, active: true, must_change_password: false }; const user = await getAdminUser();
if (role === "store_employee") { if (!user) return null;
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true, if (!user.tenant_id) {
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false, // platform_admin — no specific tenant
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false }; return null;
} }
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true, return {
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true, user,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true }; tenant: {
id: user.tenant_id,
name: user.display_name ?? user.tenant_slug ?? "Unknown",
slug: user.tenant_slug ?? "unknown",
status: "active",
},
};
} }
function buildAdminUser(r: Record<string, unknown>): AdminUser { // ────────────────────────────────────────────────────────────────────────
const role = r.role as string; // Re-exports for backward compat
const base = { id: r.id as string, user_id: r.user_id as string, brand_id: r.brand_id as string | null, // ────────────────────────────────────────────────────────────────────────
role, active: r.active as boolean, must_change_password: Boolean(r.must_change_password) };
if (role === "platform_admin") { export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permissions-types";
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 }; * @deprecated Kept for unit tests that exercise the dev shim path.
} * Production code should never call this — `getAdminUser()` only reads
if (role === "store_employee") { * the Auth.js session now.
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, export function buildDevAdmin(role: AdminRole): AdminUser {
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false }; const isPlatform = role === "platform_admin";
} const tenantId = isPlatform ? null : "dev-tenant";
return { ...base, can_manage_products: Boolean(r.can_manage_products), can_manage_stops: Boolean(r.can_manage_stops), return {
can_manage_orders: Boolean(r.can_manage_orders), can_manage_pickup: Boolean(r.can_manage_pickup), id: "dev",
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds), user_id: "dev",
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log), email: null,
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) }; display_name: "Demo Admin",
tenant_id: tenantId,
brand_id: tenantId, // legacy alias
tenant_slug: isPlatform ? null : "tuxedo",
role,
active: true,
auth_provider: "dev",
...permissionsForRole(role),
must_change_password: false,
};
}
function buildAdminUser(input: {
id: string;
email: string | null;
displayName: string | null;
authProvider: "dev" | "google" | "email" | null;
tenantId: string;
tenantSlug: string;
tenantName: string;
role: AdminRole;
active: boolean;
}): AdminUser {
return {
id: input.id,
user_id: input.id,
email: input.email,
display_name: input.displayName,
tenant_id: input.tenantId,
brand_id: input.tenantId, // legacy alias
tenant_slug: input.tenantSlug,
role: input.role,
active: input.active,
auth_provider: input.authProvider,
...permissionsForRole(input.role),
};
}
/**
* Single source of truth for "what can a role do". Used by both the
* dev shim and the real user lookup so the demo and the real thing
* behave identically.
*/
export function permissionsForRole(role: AdminRole) {
if (role === "platform_admin") {
return {
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
can_manage_billing: true,
can_manage_branding: true,
can_manage_marketing: true,
can_manage_team: true,
};
}
if (role === "brand_admin") {
return {
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: false,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
can_manage_billing: true,
can_manage_branding: true,
can_manage_marketing: true,
can_manage_team: true,
};
}
// store_employee
return {
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
can_manage_settings: false,
can_manage_billing: false,
can_manage_branding: false,
can_manage_marketing: false,
can_manage_team: false,
};
} }
+19 -65
View File
@@ -4,12 +4,18 @@ import "server-only";
* Auth.js (NextAuth v5) configuration. * Auth.js (NextAuth v5) configuration.
* *
* Providers: * Providers:
* - Google OAuth (real, primary; only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set) * - Google OAuth only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET
* - Credentials (email/password, wraps the existing Supabase auth flow so the login * are set.
* page keeps working during the cutover. Will be removed when Supabase auth is gone.)
* *
* Session strategy: JWT. No database adapter — admin user lookup is handled by * Supabase is no longer used for auth (or anything else) on this platform.
* the existing SECURITY DEFINER RPCs + Supabase REST in `getAdminUser()`. * The historical Supabase-backed Credentials provider was removed in the
* cleanup pass. New admin users are provisioned manually by an existing
* platform admin via /admin/users (the action creates an `admin_users`
* row linked to the Google `sub` after the user signs in for the first
* time).
*
* Session strategy: JWT. No database adapter — admin user lookup is
* delegated to `getAdminUser()` in `src/lib/admin-permissions.ts`.
* *
* Required env vars (production): * Required env vars (production):
* - AUTH_SECRET — JWT signing secret * - AUTH_SECRET — JWT signing secret
@@ -17,15 +23,15 @@ import "server-only";
* - AUTH_GOOGLE_ID — Google OAuth client id * - AUTH_GOOGLE_ID — Google OAuth client id
* - AUTH_GOOGLE_SECRET — Google OAuth client secret * - AUTH_GOOGLE_SECRET — Google OAuth client secret
* *
* Backward compatibility: the legacy `rc_auth_uid` cookie and `dev_session` cookie * Backward compatibility: the `dev_session` cookie was the source of
* are still read by `src/lib/admin-permissions.ts` (via `getAdminUser()`) and the * truth for the demo flow but has been removed — `getAdminUser()` and
* middleware, so the dev/demo flow keeps working. New code should call `auth()` * the middleware now use only the Auth.js session. The legacy
* from this file instead of reading cookies directly. * `rc_auth_uid` cookie was retired earlier — see the
* final report for the cleanup notes.
*/ */
import NextAuth, { type DefaultSession } from "next-auth"; import NextAuth, { type DefaultSession } from "next-auth";
import Google from "next-auth/providers/google"; import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
declare module "next-auth" { declare module "next-auth" {
interface Session { interface Session {
@@ -39,8 +45,6 @@ const hasGoogleCreds = !!(
process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
); );
// Google provider is only added when both env vars are set so the build
// doesn't fail on hosts where Google isn't configured yet.
const googleProvider = hasGoogleCreds const googleProvider = hasGoogleCreds
? [ ? [
Google({ Google({
@@ -50,59 +54,9 @@ const googleProvider = hasGoogleCreds
] ]
: []; : [];
// Credentials provider wraps the existing Supabase email/password flow.
// It returns a user with `id` = Supabase auth user id, which `getAdminUser()`
// then uses to look up `admin_users.user_id`. The JWT persists `id` and `email`.
const credentialsProvider = [
Credentials({
id: "supabase-password",
name: "Email and password",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(creds) {
const email = typeof creds?.email === "string" ? creds.email.trim() : "";
const password = typeof creds?.password === "string" ? creds.password : "";
if (!email || !password) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) return null;
try {
const res = await fetch(
`${supabaseUrl}/auth/v1/token?grant_type=password`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAnonKey,
},
body: JSON.stringify({ email, password }),
}
);
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as
| { user?: { id?: string; email?: string }; access_token?: string }
| null;
const userId = data?.user?.id;
if (!userId) return null;
return {
id: userId,
email: data?.user?.email ?? email,
name: data?.user?.email ?? email,
};
} catch {
return null;
}
},
}),
];
export const { handlers, auth, signIn, signOut } = NextAuth({ export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true, trustHost: true,
providers: [...googleProvider, ...credentialsProvider], providers: googleProvider,
session: { strategy: "jwt" }, session: { strategy: "jwt" },
pages: { pages: {
signIn: "/login", signIn: "/login",
@@ -110,8 +64,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
callbacks: { callbacks: {
async jwt({ token, user }) { async jwt({ token, user }) {
if (user) { if (user) {
// user.id comes from the provider's authorize() return (Supabase user id) // `user.id` is the provider's stable subject — for Google sign-ins
// or from Google's `sub` claim for Google sign-ins. // this is the opaque `sub` claim.
if (user.id) token.id = user.id; if (user.id) token.id = user.id;
if (user.email) token.email = user.email; if (user.email) token.email = user.email;
} }
+11 -35
View File
@@ -1,17 +1,19 @@
// NextAuth v5 + Supabase Auth Middleware // NextAuth v5 middleware
// //
// Runs on every non-static request. Responsibilities: // Runs on every non-static request. Responsibilities:
// 1. Allow Auth.js v5 to read/write its own session cookie // 1. Allow Auth.js v5 to read/write its own session cookie
// 2. Protect /admin/* and /wholesale/* — redirect to /login if not authenticated // 2. Protect /admin/* and /wholesale/* — redirect to /login if not authenticated
// 3. Redirect away from /login when the user already has a session // 3. Redirect away from /login when the user already has a session
// 4. Preserve the `dev_session` cookie bypass (demo flow) // 4. Add a handful of baseline security headers
// 5. Add a handful of baseline security headers
// //
// Backward compatibility: the legacy `rc_auth_uid` / `rc_uid` cookies are // The legacy `dev_session` cookie bypass has been removed. The only way
// intentionally no longer read here — `getAdminUser()` in src/lib/admin-permissions.ts // into the admin is through real Auth.js (Google in production; for
// is the single source of truth and reads the Auth.js session instead. Pages // local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
// still gated by `getAdminUser()` will continue to enforce auth even if a stale //
// `rc_auth_uid` cookie is present. // Backward compatibility: the legacy `rc_auth_uid` / `rc_uid` cookies
// are intentionally no longer read here — `getAdminUser()` in
// src/lib/admin-permissions.ts is the single source of truth and reads
// the Auth.js session instead.
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
@@ -19,38 +21,12 @@ import { NextResponse } from "next/server";
export default auth((req) => { export default auth((req) => {
const { pathname } = req.nextUrl; const { pathname } = req.nextUrl;
// ── Auth detection ────────────────────────────────────────────────── const isAuthed = !!req.auth;
// Auth.js session takes priority; `dev_session` cookie is the demo bypass.
const hasSession = !!req.auth;
const devSession = req.cookies.get("dev_session")?.value;
const isDevSession =
devSession === "platform_admin" ||
devSession === "brand_admin" ||
devSession === "store_employee";
const isAuthed = hasSession || isDevSession;
const isAdmin = pathname.startsWith("/admin"); const isAdmin = pathname.startsWith("/admin");
const isLogin = pathname === "/login"; const isLogin = pathname === "/login";
if (isAdmin && !isAuthed) { if (isAdmin && !isAuthed) {
// Demo auto-login: when no real auth is configured, issue a platform_admin
// dev cookie so the rest of the admin shell renders. Mirrors the old
// `dev_session` middleware fallback.
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
const url = req.nextUrl.clone();
url.pathname = "/admin";
url.searchParams.set("demo", "1");
const res = NextResponse.redirect(url);
res.cookies.set("dev_session", "platform_admin", {
path: "/",
maxAge: 60 * 60 * 24,
httpOnly: true,
sameSite: "lax",
});
return addSecurityHeaders(res);
}
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
url.pathname = "/login"; url.pathname = "/login";
url.searchParams.set("redirect", pathname); url.searchParams.set("redirect", pathname);
+111
View File
@@ -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;
$$;
+9 -71
View File
@@ -11,9 +11,8 @@ test.describe("Auth.js v5 endpoints", () => {
const res = await ctx.get("/api/auth/providers"); const res = await ctx.get("/api/auth/providers");
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const providers = (await res.json()) as Record<string, unknown>; const providers = (await res.json()) as Record<string, unknown>;
// The Credentials provider is always present. The Google provider is // The Google provider is only present when AUTH_GOOGLE_ID +
// only present when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set. // AUTH_GOOGLE_SECRET are set.
expect(providers).toHaveProperty("supabase-password");
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) { if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
expect(providers).toHaveProperty("google"); expect(providers).toHaveProperty("google");
} }
@@ -35,7 +34,6 @@ test.describe("Auth.js v5 endpoints", () => {
const res = await ctx.get("/api/auth/session"); const res = await ctx.get("/api/auth/session");
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = await res.json(); const body = await res.json();
// Auth.js returns `null` (not `{}`) when no session is active.
expect(body).toBeNull(); expect(body).toBeNull();
await ctx.dispose(); await ctx.dispose();
}); });
@@ -48,76 +46,16 @@ test.describe("Middleware — protected route gating", () => {
test("unauthed /login renders the login form (200)", async ({ page }) => { test("unauthed /login renders the login form (200)", async ({ page }) => {
const res = await page.goto(`${BASE}/login`); const res = await page.goto(`${BASE}/login`);
expect(res?.status()).toBe(200); expect(res?.status()).toBe(200);
// The login form has an aria-label of "Sign in form" await expect(page.locator("body")).toBeVisible();
await expect(page.getByRole("form", { name: /sign in form/i })).toBeVisible();
}); });
test("dev_session=platform_admin lets /admin render (200)", async ({ page, context }) => { test("unauthed /admin redirects to /login", async ({ page }) => {
await context.addCookies([ await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" }, expect(page.url()).toMatch(/\/login/);
]);
const res = await page.goto(`${BASE}/admin`);
expect(res?.status()).toBe(200);
await expect(page).toHaveURL(/\/admin/);
// The admin layout renders a sidebar (aside element)
await expect(page.locator("aside").first()).toBeVisible({ timeout: 5_000 });
}); });
test("authed user hitting /login is redirected to /admin", async ({ page, context }) => { test("unauthed /wholesale redirects to /login", async ({ page }) => {
await context.addCookies([ await page.goto(`${BASE}/wholesale`, { waitUntil: "domcontentloaded" });
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" }, expect(page.url()).toMatch(/\/login/);
]);
await page.goto(`${BASE}/login`);
// Middleware redirects authed users from /login to /admin
await page.waitForURL(/\/admin/, { timeout: 5_000 });
});
});
// ─────────────────────────────────────────────────────────────────────
// Admin pages — load cleanly with dev_session
// ─────────────────────────────────────────────────────────────────────
test.describe("Admin pages — load with dev_session", () => {
test.beforeEach(async ({ context }) => {
await context.addCookies([
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
]);
});
for (const path of ["/admin", "/admin/products", "/admin/orders", "/admin/settings"]) {
test(`${path} renders without crashing`, async ({ page }) => {
const res = await page.goto(`${BASE}${path}`);
expect(res?.status()).toBe(200);
await expect(page.locator("body")).toBeVisible();
// The admin layout renders the sidebar; this confirms the auth
// gate didn't bounce us back to /login.
await expect(page.locator("aside").first()).toBeVisible({ timeout: 8_000 });
await expect(page.locator("body")).not.toContainText("Access Denied");
});
}
});
// ─────────────────────────────────────────────────────────────────────
// Logout flow
// ─────────────────────────────────────────────────────────────────────
test.describe("Logout", () => {
test("/logout clears the dev_session cookie and redirects to /login", async ({ page, context }) => {
await context.addCookies([
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
]);
// /logout is a server component that calls signOut and redirects.
await page.goto(`${BASE}/logout`);
// The Auth.js signOut flow sets a callback URL in the URL, or the
// custom /logout page redirects to /login directly. Either way we
// should land on /login (or near it).
await page.waitForURL(/\/login/, { timeout: 8_000 });
// The dev_session cookie should be cleared (or Auth.js session
// cookie cleared). At minimum, navigating to /admin again should
// require re-auth — we can verify by hitting /admin and checking
// that we don't have a 200 with the sidebar (the dev session may
// be re-issued by the demo flow middleware, so we don't assert
// strict denial here; just that logout *did* go to /login).
}); });
}); });
+23 -104
View File
@@ -3,115 +3,34 @@ import { test, expect } from "@playwright/test";
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000"; const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Login form renders the right affordances // Login page — Google OAuth is the only login path
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
test.describe("Login form rendering", () => { test.describe("Login page", () => {
test("renders Google button, email + password fields, and Sign in button", async ({ page }) => { test("renders the Google sign-in button", async ({ page }) => {
await page.goto(`${BASE}/login`); await page.goto(`${BASE}/login`);
// The Google button is the primary CTA at the top of the form.
await expect(page.getByRole("button", { name: /continue with google/i })).toBeVisible(); await expect(page.getByRole("button", { name: /continue with google/i })).toBeVisible();
// Email + password inputs
await expect(page.locator("#email")).toBeVisible();
await expect(page.locator("#password")).toBeVisible();
// The "Sign in" submit button is on the email/password form (not on the
// Google one). Use the form's aria-label to scope the lookup.
const signInForm = page.getByRole("form", { name: /sign in form/i });
await expect(signInForm.getByRole("button", { name: /^sign in$/i })).toBeVisible();
}); });
test("missing email surfaces browser required validation", async ({ page }) => { test("shows a friendly 'not configured' message when Google OAuth env is missing", async ({ page }) => {
// The page either shows the Google button (when AUTH_GOOGLE_ID/SECRET
// are set) or a setup message (when they aren't). We accept both as
// correct renderings.
await page.goto(`${BASE}/login`); await page.goto(`${BASE}/login`);
await page.locator("#password").fill("something"); const hasGoogle = await page
// Don't fill email .getByRole("button", { name: /continue with google/i })
const signInForm = page.getByRole("form", { name: /sign in form/i }); .isVisible()
await signInForm.getByRole("button", { name: /^sign in$/i }).click(); .catch(() => false);
// The email input has `required` — the browser should block submission const hasSetup = await page
// and the email field remains focused. We don't assert on URL change. .getByText(/Google sign-in is not configured/i)
await expect(page.locator("#email")).toHaveAttribute("required", ""); .isVisible()
}); .catch(() => false);
}); expect(hasGoogle || hasSetup).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────
// Demo mode (?demo=1) — the path that works without a Supabase backend test("/admin redirects to /login when not authenticated", async ({ page }) => {
// ───────────────────────────────────────────────────────────────────── const res = await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
test.describe("Demo mode (?demo=1)", () => { // After redirect, the URL should be /login (with ?redirect=/admin).
test("Platform Admin button sets dev_session and lands on /admin", async ({ page, context }) => { expect(page.url()).toMatch(/\/login(\?|$)/);
await page.goto(`${BASE}/login?demo=1`); expect(res?.status()).toBeLessThan(500);
await page.getByRole("button", { name: /platform admin/i }).click();
// The click sets the cookie client-side and navigates. We should land
// on the admin dashboard.
await page.waitForURL(/\/admin/, { timeout: 10_000 });
const cookies = await context.cookies();
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("platform_admin");
});
test("Brand Admin button sets dev_session=brand_admin", async ({ page, context }) => {
await page.goto(`${BASE}/login?demo=1`);
await page.getByRole("button", { name: /brand admin/i }).click();
await page.waitForURL(/\/admin/, { timeout: 10_000 });
const cookies = await context.cookies();
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("brand_admin");
});
test("Store Employee button sets dev_session=store_employee", async ({ page, context }) => {
await page.goto(`${BASE}/login?demo=1`);
await page.getByRole("button", { name: /store employee/i }).click();
await page.waitForURL(/\/admin/, { timeout: 10_000 });
const cookies = await context.cookies();
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("store_employee");
});
});
// ─────────────────────────────────────────────────────────────────────
// Credentials sign-in (skipped if env vars aren't set — needs a real
// Supabase auth user to actually succeed)
// ─────────────────────────────────────────────────────────────────────
test.describe("Credentials sign-in", () => {
test.skip(
!process.env.TEST_ADMIN_EMAIL || !process.env.TEST_ADMIN_PASSWORD,
"Set TEST_ADMIN_EMAIL and TEST_ADMIN_PASSWORD to run the credentials flow against a real Supabase backend.",
);
test("valid credentials redirect to /admin", async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
await page.locator("#password").fill(process.env.TEST_ADMIN_PASSWORD!);
const signInForm = page.getByRole("form", { name: /sign in form/i });
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
await page.waitForURL(/\/admin/, { timeout: 15_000 });
await expect(page.locator("body")).not.toContainText("Access Denied");
});
test("wrong password shows an error and stays on /login", async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
await page.locator("#password").fill("definitely-wrong-password");
const signInForm = page.getByRole("form", { name: /sign in form/i });
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
// The error banner uses role="alert"
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 8_000 });
await expect(page).toHaveURL(/\/login/);
});
test("session persists across reload", async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
await page.locator("#password").fill(process.env.TEST_ADMIN_PASSWORD!);
const signInForm = page.getByRole("form", { name: /sign in form/i });
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
await page.waitForURL(/\/admin/, { timeout: 15_000 });
await page.reload();
await expect(page).toHaveURL(/\/admin/);
await expect(page.locator("body")).not.toContainText("Access Denied");
}); });
}); });
+30 -43
View File
@@ -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);
});
});
+6 -67
View File
@@ -1,8 +1,8 @@
/** /**
* Unit tests for the auth server actions in src/actions/auth-actions.ts. * Unit tests for the auth server actions in src/actions/auth-actions.ts.
* *
* Mocks `@/lib/auth` and `next-auth` to test the action wrappers in * Mocks `@/lib/auth` to test the action wrappers in isolation from the
* isolation from the network and the Auth.js runtime. * network and the Auth.js runtime.
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -15,19 +15,12 @@ vi.mock("@/lib/auth", () => ({
signOut: signOutMock, signOut: signOutMock,
})); }));
const authErrors: Array<{ name: string; message?: string }> = []; // `server-only` is a runtime guard that throws if imported outside a
vi.mock("next-auth", () => ({ // server context. Vitest is a Node env, so the guard fires — stub it.
AuthError: class AuthError extends Error { vi.mock("server-only", () => ({}));
override name = "AuthError";
constructor(message?: string) {
super(message);
authErrors.push({ name: this.name, message });
}
},
}));
// Import after mocks. // Import after mocks.
const { signInWithPassword, signInWithGoogle, signOutAction } = await import( const { signInWithGoogle, signOutAction } = await import(
"@/actions/auth-actions" "@/actions/auth-actions"
); );
@@ -36,60 +29,6 @@ beforeEach(() => {
signOutMock.mockReset(); signOutMock.mockReset();
}); });
describe("signInWithPassword", () => {
it("returns ok:false when email is missing", async () => {
const fd = new FormData();
fd.set("password", "x");
const result = await signInWithPassword(null, fd);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/email/i);
expect(signInMock).not.toHaveBeenCalled();
});
it("returns ok:false when password is missing", async () => {
const fd = new FormData();
fd.set("email", "a@b.com");
const result = await signInWithPassword(null, fd);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/password/i);
expect(signInMock).not.toHaveBeenCalled();
});
it("trims email and passes credentials to signIn", async () => {
signInMock.mockResolvedValue(undefined);
const fd = new FormData();
fd.set("email", " admin@brand.test ");
fd.set("password", "secret");
const result = await signInWithPassword(null, fd);
expect(result).toEqual({ ok: true });
expect(signInMock).toHaveBeenCalledWith("supabase-password", {
email: "admin@brand.test",
password: "secret",
redirect: false,
});
});
it("returns ok:false with a friendly message on AuthError", async () => {
signInMock.mockRejectedValue(new Error("auth failed")); // not an AuthError
const fd = new FormData();
fd.set("email", "a@b.com");
fd.set("password", "wrong");
await expect(signInWithPassword(null, fd)).rejects.toThrow("auth failed");
});
it("catches AuthError and returns ok:false", async () => {
// The mocked AuthError is registered as a real class via the mock
// factory above, so we can construct one here.
const { AuthError } = await import("next-auth");
signInMock.mockRejectedValue(new AuthError("invalid credentials"));
const fd = new FormData();
fd.set("email", "a@b.com");
fd.set("password", "wrong");
const result = await signInWithPassword(null, fd);
expect(result).toEqual({ ok: false, error: "Invalid email or password." });
});
});
describe("signInWithGoogle", () => { describe("signInWithGoogle", () => {
it("calls signIn with the google provider and /admin redirect", async () => { it("calls signIn with the google provider and /admin redirect", async () => {
signInMock.mockResolvedValue(undefined); signInMock.mockResolvedValue(undefined);
+165 -311
View File
@@ -1,338 +1,192 @@
/** /**
* @vitest-environment node * Unit tests for `getAdminUser()`. The dev_session cookie bypass has been
* * removed; the only path into the admin is through a real Auth.js
* Unit tests for `getAdminUser()` in src/lib/admin-permissions.ts. * session. These tests mock the `auth()` function and the DB layer
* * to exercise the lookup.
* Exercises the three auth sources and the two lookup paths:
* - dev_session cookie → buildDevAdmin shim
* - NEXT_PUBLIC_USE_MOCK_DATA → buildDevAdmin shim
* - Auth.js session → REST RPC (preferred) or REST query (fallback)
* + upsert_admin_user auto-provisioning for first-time sign-ins.
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Stub the server-only guard so the module can be imported under vitest.
// ── Module mocks ────────────────────────────────────────────────────
//
// vi.mock is hoisted, so all factory bodies must avoid closure over
// outer-scope imports. The actual implementations are stubbed and
// re-configured in `beforeEach`.
const cookiesMock = vi.fn();
vi.mock("next/headers", () => ({ cookies: cookiesMock }));
const authMock = vi.fn();
vi.mock("@/lib/auth", () => ({ auth: authMock }));
// `server-only` is a runtime guard that throws if imported outside a
// server context. Vitest is a Node env, so the guard fires and breaks
// the test setup — stub it.
vi.mock("server-only", () => ({})); vi.mock("server-only", () => ({}));
// Import AFTER the mocks. // Mock the Drizzle client wrapper so we don't need a real DB.
const { getAdminUser, buildDevAdmin } = await import("@/lib/admin-permissions"); const mockSelect = vi.fn();
const mockWithPlatformAdmin = vi.fn(async (fn: any) => fn({ select: mockSelect }));
vi.mock("@/db/client", () => ({
withPlatformAdmin: (fn: any) => mockWithPlatformAdmin(fn),
}));
// ── Test helpers ─────────────────────────────────────────────────── // Mock the auth() function. The default mock returns null (no session).
const authMock = vi.fn();
vi.mock("@/lib/auth", () => ({
auth: () => authMock(),
}));
const UUID = "00000000-0000-0000-0000-000000000001"; // Mock cookies() so we don't read a real cookie store.
const NON_UUID = "google-sub-1234567890"; const cookieStoreGet = vi.fn();
vi.mock("next/headers", () => ({
cookies: () =>
Promise.resolve({
get: (name: string) => cookieStoreGet(name),
}),
}));
function mockCookies(value: string | undefined) { import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-permissions";
cookiesMock.mockResolvedValue({
get: (name: string) =>
name === "dev_session" && value ? { name, value } : undefined,
});
}
function mockAuthSession(session: { user?: { id?: string; email?: string } } | null) { beforeEach(() => {
authMock.mockResolvedValue(session); mockSelect.mockReset();
} authMock.mockReset();
cookieStoreGet.mockReset();
});
function jsonResponse(body: unknown, init: ResponseInit = {}) { describe("getAdminUser()", () => {
return new Response(JSON.stringify(body), { it("returns null when there is no Auth.js session", async () => {
status: 200, authMock.mockResolvedValue(null);
headers: { "content-type": "application/json" }, cookieStoreGet.mockReturnValue(undefined);
...init, const u = await getAdminUser();
}); expect(u).toBeNull();
}
function adminRow(overrides: Record<string, unknown> = {}) {
return {
id: "row-1",
user_id: UUID,
brand_id: null,
role: "platform_admin",
active: true,
must_change_password: false,
...overrides,
};
}
// ── Tests ──────────────────────────────────────────────────────────
describe("getAdminUser — dev_session bypass", () => {
beforeEach(() => {
cookiesMock.mockReset();
authMock.mockReset();
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
}); });
it("returns platform_admin shim for dev_session=platform_admin", async () => { it("returns null when the session has no email", async () => {
mockCookies("platform_admin"); 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(); const u = await getAdminUser();
expect(u).not.toBeNull(); expect(u).not.toBeNull();
expect(u?.role).toBe("platform_admin"); expect(u?.email).toBe("admin@tuxedo.example");
expect(u?.can_manage_users).toBe(true); expect(u?.tenant_id).toBe("tenant-tux");
expect(authMock).not.toHaveBeenCalled(); expect(u?.tenant_slug).toBe("tuxedo");
});
it("returns brand_admin shim for dev_session=brand_admin", async () => {
mockCookies("brand_admin");
const u = await getAdminUser();
expect(u?.role).toBe("brand_admin"); expect(u?.role).toBe("brand_admin");
expect(u?.can_manage_users).toBe(true); expect(u?.can_manage_products).toBe(true);
}); expect(u?.can_manage_team).toBe(true);
it("returns store_employee shim for dev_session=store_employee", async () => {
mockCookies("store_employee");
const u = await getAdminUser();
expect(u?.role).toBe("store_employee");
expect(u?.can_manage_users).toBe(false);
expect(u?.can_manage_orders).toBe(true);
expect(u?.can_manage_pickup).toBe(true);
});
it("falls through to auth() for an unrecognized dev_session value", async () => {
mockCookies("hacker");
mockAuthSession(null);
const u = await getAdminUser();
expect(u).toBeNull();
expect(authMock).toHaveBeenCalledTimes(1);
}); });
}); });
describe("getAdminUser — mock data mode", () => { describe("buildDevAdmin()", () => {
beforeEach(() => { it("returns a platform_admin with no tenant", () => {
cookiesMock.mockReset();
authMock.mockReset();
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.NEXT_PUBLIC_USE_MOCK_DATA;
});
it("returns platform_admin shim when NEXT_PUBLIC_USE_MOCK_DATA=true", async () => {
process.env.NEXT_PUBLIC_USE_MOCK_DATA = "true";
mockCookies(undefined);
const u = await getAdminUser();
expect(u?.role).toBe("platform_admin");
expect(authMock).not.toHaveBeenCalled();
});
});
describe("getAdminUser — Auth.js v5 session", () => {
const SUPABASE_URL = "https://example.supabase.co";
const SERVICE_KEY = "service-role-key";
beforeEach(() => {
cookiesMock.mockReset();
authMock.mockReset();
process.env.NEXT_PUBLIC_SUPABASE_URL = SUPABASE_URL;
process.env.SUPABASE_SERVICE_ROLE_KEY = SERVICE_KEY;
delete process.env.NEXT_PUBLIC_USE_MOCK_DATA;
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
});
it("returns null when auth() returns no session", async () => {
mockCookies(undefined);
mockAuthSession(null);
const u = await getAdminUser();
expect(u).toBeNull();
});
it("returns null when env vars are missing", async () => {
mockCookies(undefined);
mockAuthSession({ user: { id: UUID, email: "a@b.com" } });
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
const u = await getAdminUser();
expect(u).toBeNull();
});
it("calls the new get_admin_user_for_session RPC first and returns the row", async () => {
mockCookies(undefined);
mockAuthSession({ user: { id: UUID, email: "admin@brand.test" } });
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse(adminRow()));
vi.stubGlobal("fetch", fetchMock);
const u = await getAdminUser();
expect(u?.role).toBe("platform_admin");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe(
`${SUPABASE_URL}/rest/v1/rpc/get_admin_user_for_session`,
);
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
p_session_id: UUID,
});
});
it("falls back to the legacy user_id REST query when the RPC is missing (404)", async () => {
mockCookies(undefined);
mockAuthSession({ user: { id: UUID, email: "admin@brand.test" } });
const fetchMock = vi.fn()
// RPC returns 404 (migration 204 not applied yet)
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
// Legacy query succeeds
.mockResolvedValueOnce(jsonResponse([adminRow()]));
vi.stubGlobal("fetch", fetchMock);
const u = await getAdminUser();
expect(u?.role).toBe("platform_admin");
expect(fetchMock).toHaveBeenCalledTimes(2);
const secondUrl = fetchMock.mock.calls[1][0] as string;
expect(secondUrl).toContain("/rest/v1/admin_users?");
expect(secondUrl).toContain(`user_id=eq.${UUID}`);
});
it("falls back to the email REST query for a non-UUID Google subject when the RPC is missing", async () => {
mockCookies(undefined);
mockAuthSession({ user: { id: NON_UUID, email: "Admin@Brand.Test" } });
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
.mockResolvedValueOnce(jsonResponse([adminRow({ user_id: NON_UUID })]));
vi.stubGlobal("fetch", fetchMock);
const u = await getAdminUser();
expect(u).not.toBeNull();
const secondUrl = fetchMock.mock.calls[1][0] as string;
expect(secondUrl).toContain("email=ilike.admin%40brand.test");
});
it("returns null when the admin_users row is inactive", async () => {
mockCookies(undefined);
mockAuthSession({ user: { id: UUID, email: "a@b.com" } });
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(jsonResponse([adminRow({ active: false })])),
);
const u = await getAdminUser();
expect(u).toBeNull();
});
it("returns null when no row exists and auto-provisioning is unavailable", async () => {
mockCookies(undefined);
mockAuthSession({ user: { id: UUID, email: "a@b.com" } });
const fetchMock = vi.fn()
// 1. RPC returns []
.mockResolvedValueOnce(jsonResponse([]))
// 2. legacy REST also returns []
.mockResolvedValueOnce(jsonResponse([]))
// 3. upsert_admin_user also returns []
.mockResolvedValueOnce(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
const u = await getAdminUser();
expect(u).toBeNull();
});
it("auto-provisions a first-time sign-in via upsert_admin_user", async () => {
mockCookies(undefined);
mockAuthSession({ user: { id: UUID, email: "new@brand.test" } });
const fetchMock = vi.fn()
// 1. RPC returns []
.mockResolvedValueOnce(jsonResponse([]))
// 2. legacy REST also returns []
.mockResolvedValueOnce(jsonResponse([]))
// 3. upsert returns the freshly-minted platform_admin row
.mockResolvedValueOnce(
jsonResponse([adminRow({ email: "new@brand.test" })]),
);
vi.stubGlobal("fetch", fetchMock);
const u = await getAdminUser();
expect(u).not.toBeNull();
expect(u?.role).toBe("platform_admin");
const upsertCall = fetchMock.mock.calls[2];
expect(upsertCall[0]).toBe(`${SUPABASE_URL}/rest/v1/rpc/upsert_admin_user`);
expect(JSON.parse(upsertCall[1].body)).toEqual({
p_user_id: UUID,
p_email: "new@brand.test",
p_auth_provider: "supabase",
p_auth_subject: null,
});
});
it("auto-provisions a Google sign-in with p_auth_provider=google and no p_user_id", async () => {
mockCookies(undefined);
mockAuthSession({ user: { id: NON_UUID, email: "new@brand.test" } });
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse([])) // RPC miss
.mockResolvedValueOnce(jsonResponse([])) // legacy miss
.mockResolvedValueOnce(jsonResponse([adminRow({ user_id: null, auth_subject: NON_UUID })]));
vi.stubGlobal("fetch", fetchMock);
const u = await getAdminUser();
expect(u).not.toBeNull();
const upsertCall = fetchMock.mock.calls[2];
expect(JSON.parse(upsertCall[1].body)).toEqual({
p_user_id: null,
p_email: "new@brand.test",
p_auth_provider: "google",
p_auth_subject: NON_UUID,
});
});
it("returns null when cookies() throws (defensive)", async () => {
cookiesMock.mockRejectedValue(new Error("cookies unavailable"));
const u = await getAdminUser();
expect(u).toBeNull();
});
it("returns null when auth() throws (defensive)", async () => {
mockCookies(undefined);
authMock.mockRejectedValue(new Error("auth blew up"));
const u = await getAdminUser();
expect(u).toBeNull();
});
});
describe("buildDevAdmin", () => {
it("platform_admin has all permissions", () => {
const u = buildDevAdmin("platform_admin"); const u = buildDevAdmin("platform_admin");
expect(u.role).toBe("platform_admin"); expect(u.role).toBe("platform_admin");
expect(u.can_manage_users).toBe(true); expect(u.tenant_id).toBeNull();
expect(u.can_manage_settings).toBe(true); expect(u.tenant_slug).toBeNull();
expect(u.can_manage_products).toBe(true); expect(u.can_manage_billing).toBe(true);
}); });
it("store_employee is restricted to orders + pickup", () => { 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"); const u = buildDevAdmin("store_employee");
expect(u.role).toBe("store_employee"); expect(u.role).toBe("store_employee");
expect(u.can_manage_orders).toBe(true);
expect(u.can_manage_pickup).toBe(true);
expect(u.can_manage_users).toBe(false);
expect(u.can_manage_settings).toBe(false);
expect(u.can_manage_products).toBe(false); expect(u.can_manage_products).toBe(false);
}); expect(u.can_manage_orders).toBe(true);
expect(u.can_manage_billing).toBe(false);
it("returns shim with brand_id=null", () => { });
const u = buildDevAdmin("platform_admin"); });
expect(u.brand_id).toBeNull();
expect(u.must_change_password).toBe(false); describe("permissionsForRole()", () => {
expect(u.active).toBe(true); 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);
}); });
}); });
+3 -1
View File
@@ -19,7 +19,9 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"],
"@/db": ["./db"],
"@/db/*": ["./db/*"]
} }
}, },
"include": [ "include": [
+6 -4
View File
@@ -5,10 +5,12 @@ import path from "node:path";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: [
// Mirrors `tsconfig.json` paths: { "@/*": ["./src/*"] } { find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
"@": 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: { test: {
environment: "node", environment: "node",