20 Commits

Author SHA1 Message Date
tyler 4b02154188 Merge remote-tracking branch 'crispygoat/main'
Deploy to route.crispygoat.com / deploy (push) Failing after 1m59s
# Conflicts:
#	src/actions/admin/password.ts
#	src/actions/brand-settings.ts
#	src/actions/stops.ts
#	src/app/change-password/page.tsx
#	src/app/login/LoginClient.tsx
#	src/app/logout/page.tsx
#	src/auth.config.ts
#	src/lib/admin-permissions.ts
#	src/lib/auth.ts
#	src/lib/db.ts
2026-06-07 01:56:43 +00:00
tyler 6e71596daf Merge branch 'feature/drizzle-rls-real-auth'
# Conflicts:
#	CLAUDE.md
#	package.json
#	src/app/api/auth/[...nextauth]/route.ts
#	src/app/login/LoginClient.tsx
#	src/auth.config.ts
#	src/components/admin/AdminSidebar.tsx
#	src/lib/admin-permissions-types.ts
#	src/lib/admin-permissions.ts
#	src/lib/auth.ts
#	src/middleware.ts
2026-06-07 01:55:06 +00:00
tyler 4da7aae5ce feat(auth): seed admin user + email/password login UI
- db/seed.ts: upserts admin@route-commerce.local with a scrypt password
  hash (default password 'admin', override with SEED_ADMIN_PASSWORD)
  and grants them the platform_admin role on the Tuxedo tenant so
  getAdminUser() resolves it for the Credentials provider's authorize
  function
- src/app/login/page.tsx: reads ?error=... from the URL and passes
  hasCredentials + seededEmail + error to the client so the form
  pre-fills in dev and surfaces 'Invalid email or password' cleanly
- src/app/login/LoginClient.tsx: adds the email + password form below
  the Google button, with a divider, dev-mode pre-fill, and local error
  handling for client-side failures (server-side failures come back
  through the ?error=... param)
2026-06-07 01:51:53 +00:00
tyler ccfd89be75 feat(auth): wire Credentials provider + edge-safe authConfig
- src/auth.config.ts: edge-safe Auth.js config (no pg, no Credentials)
  imported by the middleware; exports authConfig + isDevLoginEnabled
- src/lib/auth.ts: extends authConfig with the Credentials provider
  (Node-only) for email + password sign-in via users.password_hash
- src/middleware.ts: uses authConfig (edge-safe) instead of the full
  src/lib/auth.ts so the edge bundle stays small
- src/actions/auth-actions.ts: adds signInWithCredentials that calls
  the Credentials provider; signInWithGoogle stays the production path
2026-06-07 01:50:53 +00:00
tyler 42f28b32a4 test(passwords): add unit tests for encode/verify/round-trip 2026-06-07 01:46:52 +00:00
tyler 720ffe5262 feat(auth): add password_hash column + bcrypt helper for Auth.js Credentials provider
- Migration 0002 adds nullable password_hash to users (idempotent)
- src/lib/passwords.ts: encode/verify with self-describing format
  (algo$N$salt$hash) so we can migrate to a stronger KDF later
- Schema adds passwordHash column; OAuth-only users leave it null
2026-06-07 01:36:27 +00:00
tyler 7cd0603cfb 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
2026-06-07 01:23:44 +00:00
tyler f96dcd01f2 feat(db+auth): add pg pool, admin_users email/provider migration, refactor auth lookup
- Create src/lib/db.ts (shared pg.Pool, query/withTx helpers, server-only)
- Add @types/pg devDep
- Migration 204: add email, auth_provider, auth_subject columns to
  admin_users; backfill from auth.users; new upsert_admin_user accepts
  multi-provider args; new get_admin_user_for_session RPC resolves
  Auth.js session id (UUID or Google sub) to an admin row
- Refactor getAdminUser() to use pg + new RPC; auto-provisions on first
  Google sign-in using session.user.email
- Refactor updatePasswordAction to call update_user_password via pg
  (drops the Supabase REST hop)
- Delete orphaned src/actions/login.ts (replaced by auth-actions.ts)
- Drop remaining DEV_FORCE_UID references in users.ts; dev path now
  uses dev_session cookie (the only cookie the dev flow can set)
- Update AdminUser type: user_id is now string | null (Google users
  have no Supabase auth id); add email + auth_provider fields
- Fix downstream type errors: StopProductAssignment.callerUid accepts
  null; pickup.ts performedBy widens to string | null
- Bump vitest config, tests/, and other earlier cleanup changes
2026-06-06 23:41:41 +00:00
tyler 3f731f7739 fix(admin): wrap post-auth data loads in try/catch to surface and survive SC render errors
Deploy to route.crispygoat.com / deploy (push) Successful in 3m9s
The /admin page was throwing a Server Components render error (digest
4266906817) in production. The exact throw point was not visible from
the browser (Next.js hides it for security) and server logs were not
available at debugging time.

The layout and page both call data-loading functions (getActiveBrandId,
listBrandsForAdmin, supabase brands lookup) that could throw on a
transient DB/network failure, and these calls had no try/catch. A
single failed call would crash the entire admin shell.

Wrap each call in try/catch with console.error so:
  1. The page renders with sensible defaults if a call fails
  2. The actual error is logged server-side (visible via the digest in
     the admin error boundary or PM2/Docker logs)
  3. The admin shell stays functional even if a single data source is
     down

No behavior change on the happy path.
2026-06-06 22:35:24 +00:00
tyler 5654ebaecd chore(auth): remove legacy rc_auth_uid/rc_uid/upsert_admin_user path; scope sign-in to ADMIN_ALLOWED_EMAILS; silence lockfile warning
Deploy to route.crispygoat.com / deploy (push) Successful in 3m14s
Cleanup after Auth.js v5 became the only sign-in path. The platform
had three overlapping auth modes (dev cookie, legacy rc_auth_uid, Auth.js
JWT) and a pile of dead-code pages/routes that only existed to support
the legacy path.

What changed:

* getAdminUser() now has only two auth paths:
    1. dev_session cookie (auto-issued by src/proxy.ts for /admin/* when
       ALLOW_DEV_LOGIN is enabled)
    2. Auth.js v5 JWT (the encrypted cookie + auth() lookup)
  The legacy rc_auth_uid/rc_uid branch and the Supabase REST fetch
  against admin_users are gone.

* The signIn callback in src/lib/auth.ts enforces ADMIN_ALLOWED_EMAILS
  when set. Unset = open mode (backward compatible with demo/dev). Dev
  credentials provider is exempt. The new env var is wired through
  .env.example and .gitea/workflows/deploy.yml (read from
  secrets.ADMIN_ALLOWED_EMAILS, written to the server .env file).

* change-password/page.tsx now uses auth() server-side instead of
  fetching the deleted /api/auth/uid endpoint. The form is split into
  page.tsx (server component, auth check) + ChangePasswordForm.tsx
  (client component, form state). updatePasswordAction now reads the
  user id from auth() instead of the rc_auth_uid cookie.

* Deleted 14 dead-code files:
    - Pages: login2, logout, auth/callback, admin/debug-auth,
      admin/test-auth
    - API routes: api/login, api/logout, api/auth/uid, api/force-admin,
      api/set-auth-cookie, api/debug-cookie, api/debug-me,
      api/debug-auth
    - Actions: src/actions/login.ts
  These were the old email/password login, the old Supabase OAuth
  callback, the old /api/auth/uid probe, and a pile of debug endpoints
  that have been superseded by the new proxy + the new /login page.

* next.config.ts: set outputFileTracingRoot: '.' to silence the
  Next.js 16 lockfile-inference warning. Without this the build
  walked up from package.json looking for a lockfile, found the
  homelab runner's stale act cache at /home/tyler/.cache/act/.../package-lock.json,
  and warned on every build. '. resolves to the project root in both
  dev and CI, so it's the right answer.

Out of scope (deferred):

* src/actions/admin/users.ts still uses rc_auth_uid internally for its
  dev-bypass logic. It works (the rc_auth_uid branch is gated on
  NODE_ENV != 'production' and DEV_FORCE_UID), but it's now genuinely
  unreachable in production. Clean up in a follow-up.

Pre-flight:
* npx tsc --noEmit: clean
* npm run lint (touched files): clean
* npm run build: clean — proxy picked up, no lockfile warning, all
  93 static pages generated.
2026-06-06 22:13:56 +00:00
tyler 1cecbce392 fix(auth): port dev auto-login to Next.js 16 proxy.ts
Deploy to route.crispygoat.com / deploy (push) Successful in 3m8s
Next.js 16 renamed middleware.ts to proxy.ts and only allows one of the
two. Delete src/middleware.ts and rewrite src/proxy.ts to do the dev
auto-login + /login bounce + /admin gate explicitly (no NextAuth auth()
wrapper — auth.handler() returns a NextMiddleware taking (req, event),
which makes wrapping it awkward when we also need to set cookies on the
response).

Drop the now-dead 'authorized' callback from auth.config.ts (it was only
fired by the NextAuth wrapper, which we no longer use).

Migration 209: add a defensive unique constraint on admin_users.user_id
so the ON CONFLICT (user_id) clause in the new RPC resolves regardless
of whether the Supabase-dashboard-created table shipped with one.
2026-06-06 21:52:19 +00:00
tyler b63d0415ab fix(deploy): seed docker-compose.yml before any docker compose cmd
Deploy to route.crispygoat.com / deploy (push) Failing after 1m7s
The 'cp -f deploy/docker-compose.yml ...' was sitting AFTER
'docker compose down' in the step. 'docker compose down' reads
and validates the compose file on the server — so the old copy
(with the dead nextjs service and its env_file: ../.env.production
reference) was still being read, and docker compose bailed on the
missing .env.production file before the copy could overwrite it.

Moved the config-file seeding to the top of the step, right after
'mkdir -p $APP_DIR'. Now the new compose file is in place before
either 'docker compose down' or 'docker compose up' runs.
2026-06-06 21:29:21 +00:00
tyler e2e56252ec fix(deploy): always copy docker-compose.yml to server
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
The 'Start Docker stack' step used '[ -f ... ] || cp' for
docker-compose.yml, which only copied the file on the first
deploy. Subsequent deploys kept the stale copy on the server.

The stale copy still had the dead 'nextjs' service with
'env_file: ../.env.production', which docker compose validates
on every 'up' and bailed because .env.production is written
later by the 'Deploy' step.

Changed to unconditional 'cp -f' so the server always has the
latest compose file.
2026-06-06 20:48:43 +00:00
tyler 48ce5665b9 docs(memory): deploy fix notes
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 20:47:15 +00:00
tyler 2d55791458 fix(deploy): PostgREST env + remove dead nextjs service from compose
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
Build was failing on the 'Start Docker stack' step with two issues:

1. PGRST_DB_URI not set — the env var was only in the 'Deploy' step,
   which runs after PostgREST has already started. PostgREST booted
   with a blank DB URI and the step exited 1.

2. docker-compose.yml had a 'nextjs' service with
   env_file: ../.env.production, but .env.production is written
   later by the 'Deploy' step. docker compose validates the entire
   compose file on 'up' and bailed because the path didn't exist
   yet.

   The 'nextjs' service is dead code anyway: PM2 runs Next.js
   directly from $APP_DIR, never through docker. Removed it.

Also fixed: 'docker compose up -d db postgrest minio minio_init'
referenced services that don't exist in the compose file (Postgres
runs on the host, not in docker). Changed to just 'postgrest', and
the pg_isready check now uses host psql directly instead of
'docker compose exec -T db'.

Changes:
- deploy/docker-compose.yml: drop nextjs service, keep only postgrest
- .gitea/workflows/deploy.yml:
  - Add PGRST_DB_URI / PGRST_DB_ANON_ROLE / PGRST_SERVER_PORT to
    the 'Start Docker stack' step env
  - Write them to $APP_DIR/.env so docker compose picks them up
  - 'docker compose up -d postgrest' (was: db postgrest minio minio_init)
  - pg_isready check uses host psql (was: docker compose exec -T db)
2026-06-06 20:46:52 +00:00
tyler 6c5ca6829f docs(memory): Auth.js v5 wiring notes
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 20:31:13 +00:00
tyler 1e9f9c0414 feat(auth): wire getAdminUser() to Auth.js v5 Google sign-in
Deploy to route.crispygoat.com / deploy (push) Failing after 2s
After a user signs in with Google, they land on /admin but see
'Your account does not have admin access' because getAdminUser()
only checked the legacy dev_session and rc_auth_uid cookies.

This completes the Auth.js path:

- New src/lib/db.ts: shared pg.Pool singleton (extracted from
  src/lib/auth.ts). The single connection pool for the whole app
  — server actions, API routes, and Auth.js all import from here.

- src/lib/auth.ts: imports the shared pool, signIn event now calls
  the new upsert_admin_user_for_authjs RPC (idempotent) to
  auto-create a platform_admin row on first sign-in.

- New supabase/migrations/209_authjs_auto_create_admin.sql:
  - Defensive ALTER TABLE ADD COLUMN IF NOT EXISTS for
    can_manage_settings (was likely dashboard-added, not in any
    tracked migration)
  - SECURITY DEFINER RPC upsert_admin_user_for_authjs(p_user_id UUID)
    that inserts a platform_admin row with all permissions true
    and ON CONFLICT (user_id) DO NOTHING
  - NOTIFY pgrst to reload PostgREST schema cache

- src/lib/admin-permissions.ts: new Auth.js session check between
  dev_session and rc_auth_uid. Uses auth() from @/lib/auth to
  decrypt the JWT cookie server-side, then getAdminUserFromPool()
  queries admin_users + admin_user_brands via the shared pool.
  Legacy rc_auth_uid path unchanged (deferred).

- src/middleware.ts: recognizes Auth.js session cookies
  (authjs.session-token and __Secure-authjs.session-token) at the
  edge so signed-in users aren't bounced to /login.

Flow after this change:
  Dev/demo:  visit /admin → middleware auto-issues dev_session → in
  Prod:      click Google → Auth.js OAuth → signIn event creates
             admin_users row → redirect to /admin → getAdminUser()
             reads JWT, queries pool, returns platform_admin.
2026-06-06 20:30:11 +00:00
tyler 53d995fc99 docs(memory): login flow consolidation notes
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 20:14:44 +00:00
tyler e499139c74 fix(login): one-button Google sign-in + middleware auto-issues dev cookie
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
The dev login flow was redirecting back to /login because:
  - src/middleware.ts didn't exist, so the Auth.js authorized
    callback in auth.config.ts never ran
  - Even if it had, it only checked the Auth.js JWT, not dev_session
  - Clicking the demo buttons set the cookie via document.cookie,
    but the admin layout (via getAdminUser) was the only thing
    reading it — no edge gate

Fix:
  - New src/middleware.ts: gates /admin/* and /login at the edge.
    Auto-issues dev_session=platform_admin when ALLOW_DEV_LOGIN is
    enabled (default on, set to 'false' in prod). No buttons, no
    client-side cookie games.
  - LoginClient.tsx: stripped to a single Google OAuth button.
    Removed email/password form, dev credentials form, and the
    /login?demo=1 three-button picker.
  - Removed signInWithDev from auth-signin.ts (no longer called).
  - Removed dead /dev-login page and /api/dev-login route.

Net result: one sign-in path (Google), invisible dev auto-login
via middleware, no more three modes.
2026-06-06 20:14:08 +00:00
tyler 7489da3da0 docs(memory): record successful build + production prep checklist
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 19:58:26 +00:00
94 changed files with 4654 additions and 2946 deletions
+7
View File
@@ -40,6 +40,13 @@ GOOGLE_CLIENT_SECRET=
# development. Default: enabled in dev only.
ALLOW_DEV_LOGIN=true
# Comma-separated list of email addresses allowed to sign in via Google
# OAuth. If unset (or empty), any Google account can sign in and gets a
# `platform_admin` row auto-created — fine for demo/dev. Set this in
# production to lock sign-in down to a known set of admins.
# Example: ADMIN_ALLOWED_EMAILS=tyler@example.com,sarah@example.com
ADMIN_ALLOWED_EMAILS=
# ── Supabase (legacy, being removed) ────────────────────────────────────────
# Still used by the existing admin pages, server actions, and the
# `getAdminUser` flow. Once the auth migration is complete and the
+34 -7
View File
@@ -25,10 +25,30 @@ jobs:
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
# PostgREST — needs the DB URI at start time (it reads env
# from the container, not from .env.production which is
# written later by the Deploy step).
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
# Seed config files into APP_DIR FIRST, before any docker compose
# command. The `docker compose down` below validates the compose
# file (including `env_file` paths) — if the old copy is still
# on the server with a broken `env_file`, the step fails before
# we get a chance to overwrite it.
# - docker-compose.yml: copied UNCONDITIONALLY so deploys pick
# up compose changes. The previous `[ -f ... ] ||` guard
# kept stale copies on the server.
# - .env.example: copied on first deploy only (it's a template;
# the real `.env` is built from it below).
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml
# Free the dev-stack port (3001) and the port the previous deploy used
# (so a new deploy can pick it back up if it's the lowest free port)
PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "")
@@ -79,9 +99,6 @@ jobs:
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
export POSTGREST_HOST_PORT
# Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR)
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
[ -f $APP_DIR/docker-compose.yml ] || cp deploy/docker-compose.yml $APP_DIR/docker-compose.yml
cd $APP_DIR
[ -f .env ] || cp .env.example .env
# Append production secrets to .env (overriding .env.example defaults)
@@ -92,15 +109,22 @@ jobs:
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
echo "PGRST_DB_URI=${PGRST_DB_URI}"
echo "PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-anon}"
echo "PGRST_SERVER_PORT=${PGRST_SERVER_PORT:-3000}"
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
} >> .env
# Bring the stack up fresh — --force-recreate ensures no stale
# network/container references from prior failed attempts
docker compose up -d --force-recreate db postgrest minio minio_init
# Wait for Postgres healthcheck
# network/container references from prior failed attempts.
# Only `postgrest` lives in docker; Postgres itself runs on the
# host (see the migrations step below, which uses
# `psql -h 127.0.0.1`).
docker compose up -d --force-recreate postgrest
# Wait for Postgres to accept connections on the host.
# The DB is on 127.0.0.1, not in a docker service.
for i in $(seq 1 30); do
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT 1" > /dev/null 2>&1; then
echo "Postgres is ready"
break
fi
@@ -145,6 +169,7 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
# Supabase (legacy, still used by admin pages/server actions until
# the Auth.js migration is finished)
@@ -197,6 +222,7 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
# Storage
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
@@ -253,6 +279,7 @@ jobs:
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
printf "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS"
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
+21 -13
View File
@@ -46,28 +46,36 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp
### Authentication & Authorization
**Dev mode** bypasses Supabase auth entirely via `dev_session` cookie set by `/login`:
**Auth.js v5 (NextAuth)** is the active auth system. Config lives in `src/lib/auth.ts`, with the route handler at `src/app/api/auth/[...nextauth]/route.ts`. The login page (`src/app/login/LoginClient.tsx`) renders a "Continue with Google" button alongside the email/password form, both backed by Auth.js providers. Server-side code reads the session via `await auth()` from `@/lib/auth`; client code that needs to sign out can call the `signOutAction` server action from `@/actions/auth-actions`.
**Providers (see `src/lib/auth.ts`):**
- **Google OAuth** — primary sign-in. Active when `AUTH_GOOGLE_ID` + `AUTH_GOOGLE_SECRET` are set. Users auto-provision as `platform_admin` if their Google `sub` is UUID-shaped (rare) — for Google sign-ins, `admin_users` rows must be provisioned manually by an existing admin until the `email`-based provisioning flow lands.
- **Email/password (Supabase-backed)** — wraps the existing `auth/v1/token?grant_type=password` flow. This is transitional; once Supabase auth is fully removed, this provider goes away.
**Demo / dev mode** still works through a `dev_session` cookie:
- `dev_session=platform_admin` — full access, all brands
- `dev_session=brand_admin` — full access to assigned brand only
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
- The login page renders "Demo Mode" buttons that set this cookie client-side; the middleware also auto-issues `dev_session=platform_admin` for the `/admin` demo flow when Supabase isn't configured.
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and the legacy `rc_auth_uid` cookie in production (set by the pre-Auth.js `/api/login`) — never import this file directly into Client Components. Use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
**Single source of truth for the current admin user:** `getAdminUser()` in `src/lib/admin-permissions.ts`. It reads `dev_session` first, then the Auth.js session via `auth()` from `@/lib/auth`. The Supabase `admin_users` lookup still uses `rest/v1/admin_users?user_id=eq.<uid>` — when the `pg` pool at `src/lib/db.ts` lands, that lookup should switch to a direct `SELECT`. **Never import `admin-permissions.ts` into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
The middleware (`src/middleware.ts`) guards `/admin/:path*` and `/login`. It auto-issues a `dev_session=platform_admin` cookie for the demo flow when no auth is present. A `clerk-auth.ts` helper exists in `src/lib/` but is currently a stub — do not depend on it.
The middleware (`src/middleware.ts`) uses Auth.js v5's `auth()` wrapper. It guards `/admin/*` and `/login`, preserves the `dev_session` bypass, and adds baseline security headers.
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
#### Auth.js (NextAuth v5) migration — in progress
#### Migration status
The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight:
- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead.
- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list.
- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints.
- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos.
- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change.
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it.
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior.
- ✅ Auth.js v5 installed (`next-auth@beta`, currently `5.0.0-beta.31`)
-`src/lib/auth.ts` + `src/app/api/auth/[...nextauth]/route.ts` in place
- ✅ Google + Credentials (Supabase-backed) providers configured
- `getAdminUser()` reads from `auth()` session
- ✅ Middleware uses `auth()` wrapper
- ✅ Old `/api/login`, `/api/logout`, `/api/auth/uid`, `/api/set-auth-cookie` removed
- ⏳ Add `email` column to `admin_users` and provision Google users by email (TODO)
- ⏳ Switch the `admin_users` lookup in `getAdminUser()` to direct `pg` (TODO — needs `src/lib/db.ts`)
- ⏳ Remove the email/password (Supabase) provider when Supabase auth is fully cut over (TODO)
- ⏳ The `rc_auth_uid` cookie is no longer the source of truth, but `actions/admin/users.ts` still reads it for backward compat with pre-existing sessions — the `DEV_FORCE_UID` constant and its branches are now dead code (the `/api/force-admin` route that set it was deleted) and should be removed in a follow-up
### Server Actions Pattern
+182
View File
@@ -308,3 +308,185 @@ The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when
- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow.
- Push targets `tyler/main` to trigger the Gitea build.
## Build green — 2026-06-06
Push `32396af` to `origin/main` triggered a successful Gitea deploy. Fixes that landed:
- `force-dynamic` on `src/app/admin/layout.tsx` + `src/app/admin/settings/square-sync/page.tsx`
- try/catch around Supabase REST fetches in `src/actions/stops.ts` and `src/actions/brand-settings.ts`
- `.gitea/workflows/deploy.yml` paths updated to `deploy/docker-compose.yml`
## Production prep — next steps
1. **Verify the stack is actually running.** SSH to the deploy host, `docker compose -p prod-app ps` in `$APP_DIR` (`/home/tyler/route-commerce`). All services should be `healthy`.
2. **Test Postgres connectivity.** `docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c '\dt'` should list tables from migrations. `curl http://localhost:$POSTGREST_HOST_PORT/` should return PostgREST's OpenAPI spec.
3. **Test app → PostgREST.** Hit any public page that reads from PostgREST (e.g. `/indian-river-direct/stops` after the revalidate window). If it returns stops, the chain works.
4. **Replace dummy secrets** in Gitea:
- `NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321` + `NEXT_PUBLIC_SUPABASE_ANON_KEY=dummy-supabase-anon-ke` — either set real Supabase project values, or remove entirely once the Postgres-direct migration is complete (CLAUDE.md direction).
- `RESEND_API_KEY=re_REPLACE_ME`, `RESEND_WEBHOOK_SECRET=whsec_REPLACE_ME` — get real values from Resend dashboard.
- `STRIPE_SECRET_KEY=sk_test_REPLACE_ME`, `STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_ME`, `STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME` — real test-mode values from Stripe.
5. **Supabase → direct Postgres migration.** The codebase still imports `@supabase/ssr` and `@supabase/supabase-js` in `src/lib/supabase.ts`, `src/lib/supabase/server.ts`, `src/actions/login.ts`, `src/actions/admin/users.ts`, `src/actions/admin/force-login.ts`, `src/actions/wholesale-auth.ts`. CLAUDE.md says these should be purged. The deploy stack already has PostgREST, so the path is: replace `supabase.from(...)` calls with `fetch` to `NEXT_PUBLIC_API_URL/rest/v1/...` or direct `pg` queries, then drop the `@supabase/*` deps.
6. **Auth.js hardening.** `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` aren't in the secret list — the workflow falls back to `BETTER_AUTH_*` names which exist. Set the canonical `AUTH_*` names too so the fallback isn't load-bearing.
## Login flow consolidated — 2026-06-06
Push `e499139` fixes the "dev login redirects back to /login" bug and
removes the three-mode login page.
**Root cause:** `src/middleware.ts` didn't exist, so the `authorized`
callback in `auth.config.ts` never ran at the edge. The demo buttons at
`/login?demo=1` set `dev_session` via `document.cookie`, but nothing at
the edge recognized the cookie — the admin layout's `getAdminUser()` was
the only thing reading it, and if the layout's `force-dynamic` ever
stopped applying, the user would be bounced.
**Fix:**
- **New `src/middleware.ts`** — plain middleware (NOT the `auth()`
wrapper). Gates `/admin/*` and `/login`:
- If `dev_session`, `rc_auth_uid`, or `rc_uid` cookie is present →
`NextResponse.next()`.
- If no auth cookie, on `/admin/*`, and `ALLOW_DEV_LOGIN !== "false"`
(on by default) → set `dev_session=platform_admin` cookie and
`NextResponse.next()`. Invisible auto-login.
- If no auth and dev disabled → redirect to `/login`.
- If authenticated and on `/login` → redirect to `/admin`.
- **`src/app/login/LoginClient.tsx`** — stripped to a single Google
OAuth button. Removed:
- Email/password form (was hitting dummy Supabase and 500'ing).
- Dev credentials form (`signInWithDev`).
- `DemoMode` component with the three buttons (Platform Admin,
Brand Admin, Store Employee).
- `useState`/`useEffect`/`useCallback`/`useSearchParams`/`Suspense`
— none of that complexity is needed for a single button.
- **`src/actions/auth-signin.ts`** — removed `signInWithDev`. Kept
`signInWithGoogle` and `signOutAction`.
- **Deleted `src/app/dev-login/page.tsx`** and
**`src/app/api/dev-login/route.ts`** — dead routes, middleware
handles it.
**What "one way to log in" looks like now:**
- Dev/demo: visit `/admin` → middleware sets `dev_session` cookie →
`getAdminUser()` returns platform_admin → you're in.
- Production: visit `/admin` → no cookie, `ALLOW_DEV_LOGIN=false` →
redirect to `/login` → click Google → Auth.js OAuth flow.
**Note for Auth.js migration:** `getAdminUser()` still only checks
`dev_session` and `rc_auth_uid` — it doesn't read the Auth.js JWT.
After Google sign-in succeeds, the user has a valid Auth.js session
but `getAdminUser()` returns null. The middleware can't fix that
because it can't write to the JWT without going through the
credentials provider. This is the next piece of the Auth.js migration
(see CLAUDE.md "Auth.js migration — in progress"). The current fix
gets the dev/demo path working; the Google OAuth → admin path needs
the `getAdminUser()` Auth.js check wired up.
## Auth.js v5 wiring complete — 2026-06-06
Push `1e9f9c0` completes the Auth.js path so Google sign-in lands the
user on `/admin` as a real admin (not "Your account does not have
admin access").
**What landed:**
- **`src/lib/db.ts` (NEW)** — shared `pg.Pool` singleton. The single
connection pool for the whole app. Extracted from `src/lib/auth.ts`
(which had its own private pool). Connection string resolution:
`DATABASE_URL` → `SUPABASE_DB_URL` → `POSTGRES_URL`.
- **`src/lib/auth.ts`** — imports the shared pool. The `signIn` event
now calls the new `upsert_admin_user_for_authjs` RPC instead of
the no-op existence check it had before.
- **`supabase/migrations/209_authjs_auto_create_admin.sql` (NEW)** —
pushed automatically by the deploy workflow (line 130 of
`.gitea/workflows/deploy.yml` does `cat supabase/migrations/[0-9]*.sql
| $PG`). Contains:
- `ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS
can_manage_settings BOOLEAN NOT NULL DEFAULT false` — defensive,
since this column is in the TypeScript `AdminUser` type but not
in any tracked migration (was likely dashboard-added).
- SECURITY DEFINER RPC `upsert_admin_user_for_authjs(p_user_id UUID)`
that inserts a `platform_admin` row with all `can_manage_*` flags
true, `ON CONFLICT (user_id) DO NOTHING`.
- `NOTIFY pgrst, 'reload schema'` so PostgREST picks up the new RPC.
- **`src/lib/admin-permissions.ts`** — new Auth.js session check
between `dev_session` and `rc_auth_uid`. Uses `auth()` from
`@/lib/auth` to decrypt the JWT cookie server-side, then
`getAdminUserFromPool()` queries `admin_users` + `admin_user_brands`
via the shared pool. The legacy `rc_auth_uid` path is unchanged
(deferred — it still hits the dummy Supabase URL in prod).
- **`src/middleware.ts`** — recognizes `authjs.session-token` and
`__Secure-authjs.session-token` cookies at the edge so signed-in
users aren't bounced to `/login`.
**Key insight: same ID space.** Both `admin_users.user_id` (UUID, per
`028_fix_caller_uid_type.sql`) and Auth.js `users.id` (UUID, per
`204_authjs_tables.sql:18`) are in the same UUID space. The
`@auth/pg-adapter` auto-generates a fresh UUID per new user on first
sign-in; the Google `sub` claim is stored separately in
`accounts."providerAccountId"`. So no schema change was needed —
just a `user_id` lookup in `getAdminUserFromPool()`.
**Full sign-in flow now:**
1. Dev/demo: visit `/admin` → middleware auto-issues `dev_session`
cookie → `getAdminUser()` returns platform_admin. (No DB call.)
2. Production: click "Sign in with Google" → Auth.js OAuth →
`signIn` event fires → `upsert_admin_user_for_authjs` creates
the `admin_users` row → redirect to `/admin` → `getAdminUser()`
reads JWT, queries pool via `auth.js.user.id`, returns
platform_admin.
**What's still broken (out of scope for this push):**
- Legacy `rc_auth_uid` path in `getAdminUser()` still fetches from
`${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/...` which is a dummy
`http://localhost:54321` in prod. Any pre-existing user with a
`rc_auth_uid` cookie will get null. Defer until the Supabase →
direct Postgres migration of the REST calls.
- `getCurrentAdminUser` (client-side variant) still reads from
server-passed props — no change needed.
- The `signIn` event RPC call will fail silently if `DATABASE_URL`
is not set. The user would see "Your account does not have admin
access" and need to sign out and back in once the env is fixed.
## Deploy fix — PostgREST env + dead nextjs service — 2026-06-06
Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
1. **`PGRST_DB_URI` not set** — the env var was only in the "Deploy"
step's env, which runs after PostgREST has already started.
PostgREST booted with a blank DB URI. Now set in the "Start
Docker stack" step's env and written to `$APP_DIR/.env` (the
file docker compose auto-loads).
2. **`docker-compose.yml` had a dead `nextjs` service** with
`env_file: ../.env.production`. That file is written by the
"Deploy" step (later in the workflow), so at "Start Docker stack"
time the path doesn't exist. `docker compose up` validates the
whole compose file and bailed.
The `nextjs` service is dead code anyway — PM2 runs Next.js
directly from `$APP_DIR`, never through docker. Removed it.
**Other fixes in the same push:**
- `docker compose up -d db postgrest minio minio_init` referenced
services that don't exist in the compose file. Postgres runs on
the host (the migrations step uses `psql -h 127.0.0.1`), not in
docker. Changed to just `postgrest`.
- The `pg_isready` check was `docker compose exec -T db pg_isready`.
Since `db` is a host service, changed to
`PGPASSWORD=... psql -h 127.0.0.1 -U ... -d ... -c "SELECT 1"`.
**Architecture (now consistent):**
- Postgres: host (127.0.0.1:5432), migrations via `psql -h 127.0.0.1`
- PostgREST: docker, connects to host Postgres via `PGRST_DB_URI`
- Next.js: host, PM2 process, reads `DATABASE_URL` from `.env.production`
- MinIO: not yet wired up (the `MINIO_ROOT_USER`/`PASSWORD` env vars
are written to `.env` but no service consumes them yet — add a
`minio` service to docker-compose.yml when storage goes live)
+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;
+21
View File
@@ -0,0 +1,21 @@
-- 0002_admin_password.sql
--
-- Adds a `password_hash` column to `users` so the Auth.js Credentials
-- provider can verify email + password against the database.
--
-- Idempotent: uses `ADD COLUMN IF NOT EXISTS`. The column is nullable
-- because OAuth-only users (Google) never set a password.
--
-- The Credentials provider's `authorize` function is documented in
-- `src/lib/auth.ts` — it queries this column and runs `verifyPassword`
-- (see `src/lib/passwords.ts`) before returning the user.
BEGIN;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS password_hash TEXT;
-- Update the updated_at trigger tracking — no new triggers needed since
-- `users` already has `set_updated_at` from migration 0001.
COMMIT;
+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;
+85
View File
@@ -0,0 +1,85 @@
/**
* Tenancy + auth tables. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
timestamp,
index,
uniqueIndex,
primaryKey,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import {
tenantStatusEnum,
authProviderEnum,
roleEnum,
} from "./enums";
export const tenants = pgTable("tenants", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
status: text("status", { enum: tenantStatusEnum }).notNull().default("trial"),
trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }),
stripeCustomerId: text("stripe_customer_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").unique(),
name: text("name"),
image: text("image"),
/**
* Bcrypt / scrypt / argon2 hash, or null for OAuth-only users.
* Format is self-describing (algorithm$N$salt$hash) so we can
* migrate to a stronger KDF later without losing existing hashes.
* See `src/lib/passwords.ts` for the encoder/decoder.
*/
passwordHash: text("password_hash"),
authProvider: text("auth_provider", { enum: authProviderEnum })
.notNull()
.default("dev"),
authSubject: text("auth_subject"),
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
authSubjectIdx: uniqueIndex("users_auth_subject_idx")
.on(t.authProvider, t.authSubject)
.where(sql`${t.authSubject} IS NOT NULL`),
}),
);
export const tenantUsers = pgTable(
"tenant_users",
{
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
role: text("role", { enum: roleEnum }).notNull().default("brand_admin"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.tenantId, t.userId] }),
userIdx: index("tenant_users_user_idx").on(t.userId),
}),
);
export type Tenant = typeof tenants.$inferSelect;
export type NewTenant = typeof tenants.$inferInsert;
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type TenantUser = typeof tenantUsers.$inferSelect;
export type NewTenantUser = typeof tenantUsers.$inferInsert;
+314
View File
@@ -0,0 +1,314 @@
/**
* Seed script. Idempotent — safe to re-run. Uses `ON CONFLICT (col) DO UPDATE`
* (with a target) for tables that have a unique constraint; uses
* `WHERE NOT EXISTS` for tables that don't.
*
* npm run db:seed
*
* Populates:
* - 3 plans (Starter / Farm / Enterprise)
* - 6 add-ons
* - 2 tenants (Tuxedo, Indian River Direct)
* - 1 platform-admin user + 1 brand_admin per tenant
* - brand_settings per tenant
* - sample products, stops, customers per tenant
* - sample email templates + a draft campaign
*/
import "dotenv/config";
import { Pool } from "pg";
import { hashPassword } from "../src/lib/passwords";
// Seed needs elevated privileges (inserts into plans, add_ons, tenants —
// tables that are intentionally not RLS-scoped but the runtime app user
// can't create rows in without setting up GUCs we don't want to bother
// with here). Use DATABASE_ADMIN_URL (the superuser) for the seed.
const pool = new Pool({
connectionString:
process.env.DATABASE_ADMIN_URL ??
process.env.DATABASE_URL ??
"postgres://postgres:postgres@localhost:5432/route_commerce",
});
async function main() {
const client = await pool.connect();
try {
await client.query("BEGIN");
// ── Plans ────────────────────────────────────────────────────────────
const planRows = [
{ code: "starter", name: "Starter", price: 4900, maxUsers: 1, maxProducts: 25, maxStopsMonthly: 10, features: ["products", "stops", "orders", "basic_pickup"] },
{ code: "farm", name: "Farm", price: 14900, maxUsers: 5, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support"] },
{ code: "enterprise", name: "Enterprise", price: 39900, maxUsers: 9999, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support", "ai_intelligence", "sms_campaigns", "square_sync", "water_log", "custom_development", "dedicated_sla"] },
];
for (const p of planRows) {
await client.query(
`INSERT INTO plans (code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
monthly_price_cents = EXCLUDED.monthly_price_cents,
max_users = EXCLUDED.max_users,
max_products = EXCLUDED.max_products,
max_stops_monthly = EXCLUDED.max_stops_monthly,
features = EXCLUDED.features`,
[p.code, p.name, p.price, p.maxUsers, p.maxProducts, p.maxStopsMonthly, JSON.stringify(p.features)],
);
}
console.log(`Seeded ${planRows.length} plans`);
// ── Add-ons ──────────────────────────────────────────────────────────
const addOnRows = [
{ code: "wholesale_portal", name: "Wholesale Portal", price: 9900, description: "Self-serve wholesale storefront with order approval." },
{ code: "harvest_reach", name: "Harvest Reach", price: 7900, description: "Email + SMS campaigns and automations." },
{ code: "ai_tools", name: "AI Intelligence Pack", price: 5900, description: "Smart pricing, product descriptions, and demand forecasting." },
{ code: "water_log", name: "Water Log", price: 3900, description: "Irrigation tracking and reporting." },
{ code: "square_sync", name: "Square Sync", price: 3900, description: "Sync inventory and sales to Square POS." },
{ code: "sms_campaigns", name: "SMS Campaigns", price: 2900, description: "Send SMS blasts and automations." },
];
for (const a of addOnRows) {
await client.query(
`INSERT INTO add_ons (code, name, monthly_price_cents, description)
VALUES ($1, $2, $3, $4)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
monthly_price_cents = EXCLUDED.monthly_price_cents,
description = EXCLUDED.description`,
[a.code, a.name, a.price, a.description],
);
}
console.log(`Seeded ${addOnRows.length} add-ons`);
// ── Tenants + users + content ────────────────────────────────────────
const tenantsData = [
{
slug: "tuxedo",
name: "Tuxedo Citrus",
brandName: "Tuxedo Citrus Co.",
tagline: "Sun-ripened citrus, delivered.",
aboutHtml:
"<p>Family-run citrus grove in the Indian River region. We grow, pack, and ship premium grapefruit, oranges, and specialty varieties to wholesale buyers across the country.</p>",
primaryColor: "#F59E0B",
contactEmail: "hello@tuxedocitrus.example",
contactPhone: "(555) 010-2200",
planCode: "farm",
addOns: ["wholesale_portal", "harvest_reach"],
},
{
slug: "indian-river-direct",
name: "Indian River Direct",
brandName: "Indian River Direct",
tagline: "From our groves to your store.",
aboutHtml:
"<p>Direct-from-grove wholesale produce. Family farms, fair pricing, fast delivery.</p>",
primaryColor: "#0F766E",
contactEmail: "orders@indianriverdirect.example",
contactPhone: "(555) 010-3300",
planCode: "starter",
addOns: ["wholesale_portal"],
},
];
for (const t of tenantsData) {
// Upsert tenant
const tenantRes = await client.query<{ id: string }>(
`INSERT INTO tenants (name, slug, status, trial_ends_at)
VALUES ($1, $2, 'active', now() + interval '30 days')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
[t.name, t.slug],
);
const tenantId = tenantRes.rows[0].id;
// Plan + subscription
const planRes = await client.query<{ id: string }>(
`SELECT id FROM plans WHERE code = $1`,
[t.planCode],
);
const planId = planRes.rows[0].id;
await client.query(
`INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_end)
VALUES ($1, $2, 'active', now() + interval '30 days')
ON CONFLICT (tenant_id) DO UPDATE SET
plan_id = EXCLUDED.plan_id,
status = EXCLUDED.status,
current_period_end = EXCLUDED.current_period_end`,
[tenantId, planId],
);
// Add-ons (composite PK — ON CONFLICT has a target)
for (const code of t.addOns) {
const addOnRes = await client.query<{ id: string }>(
`SELECT id FROM add_ons WHERE code = $1`,
[code],
);
if (!addOnRes.rows[0]) continue;
const addOnId = addOnRes.rows[0].id;
await client.query(
`INSERT INTO tenant_add_ons (tenant_id, add_on_id, status)
VALUES ($1, $2, 'active')
ON CONFLICT (tenant_id, add_on_id) DO NOTHING`,
[tenantId, addOnId],
);
}
// Brand settings (PK is tenant_id)
await client.query(
`INSERT INTO brand_settings
(tenant_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (tenant_id) DO UPDATE SET
brand_name = EXCLUDED.brand_name,
tagline = EXCLUDED.tagline,
about_html = EXCLUDED.about_html,
primary_color = EXCLUDED.primary_color,
contact_email = EXCLUDED.contact_email,
contact_phone = EXCLUDED.contact_phone`,
[
tenantId, t.brandName, t.tagline, t.aboutHtml,
t.primaryColor, t.contactEmail, t.contactPhone,
],
);
// Brand admin user
const userRes = await client.query<{ id: string }>(
`INSERT INTO users (email, name, auth_provider, auth_subject)
VALUES ($1, $2, 'dev', $3)
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
[`admin@${t.slug}.example`, `${t.brandName} Admin`, `dev-${t.slug}-admin`],
);
const userId = userRes.rows[0].id;
await client.query(
`INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES ($1, $2, 'brand_admin')
ON CONFLICT (tenant_id, user_id) DO NOTHING`,
[tenantId, userId],
);
// Sample products — use WHERE NOT EXISTS (no good unique target other than id)
const products = [
{ name: "Ruby Red Grapefruit", price: 2400, unit: "40 lb case", desc: "Sweet, juicy, seedless." },
{ name: "Navel Oranges", price: 2200, unit: "40 lb case", desc: "Classic eating orange, easy-peel." },
{ name: "Honeybells", price: 3200, unit: "20 lb case", desc: "Limited season — bell-shaped, super sweet." },
{ name: "Tangelos", price: 2800, unit: "40 lb case", desc: "Tangerine-grapefruit hybrid." },
];
for (const p of products) {
await client.query(
`INSERT INTO products (tenant_id, name, description, price_cents, inventory, unit, active)
SELECT $1, $2, $3, $4, 100, $5, true
WHERE NOT EXISTS (
SELECT 1 FROM products WHERE tenant_id = $1 AND name = $2
)`,
[tenantId, p.name, p.desc, p.price, p.unit],
);
}
// Sample stops
const stops = [
{ name: "Downtown Farmers Market", address: "100 Main St", schedule: [{ day: "Saturday", time: "08:00" }] },
{ name: "Eastside Pickup Hub", address: "555 Oak Ave", schedule: [{ day: "Wednesday", time: "16:00" }] },
];
for (const s of stops) {
await client.query(
`INSERT INTO stops (tenant_id, name, address, schedule, status)
SELECT $1, $2, $3, $4::jsonb, 'active'
WHERE NOT EXISTS (
SELECT 1 FROM stops WHERE tenant_id = $1 AND name = $2
)`,
[tenantId, s.name, s.address, JSON.stringify(s.schedule)],
);
}
// Sample customers (email has a unique index per tenant)
const customers = [
{ name: "Green Grocer Co.", email: "buyer@greengrocer.example", phone: "555-010-0010" },
{ name: "Sunset Cafe", email: "orders@sunsetcafe.example", phone: "555-010-0020" },
{ name: "Northside Co-op", email: "produce@northsidecoop.example", phone: "555-010-0030" },
];
for (const c of customers) {
await client.query(
`INSERT INTO customers (tenant_id, name, email, phone, sms_opt_in, email_opt_in)
SELECT $1, $2, $3, $4, true, true
WHERE NOT EXISTS (
SELECT 1 FROM customers WHERE tenant_id = $1 AND email = $3
)`,
[tenantId, c.name, c.email, c.phone],
);
}
// Sample email template (id-based, use WHERE NOT EXISTS)
const tmplRes = await client.query<{ id: string }>(
`INSERT INTO email_templates (tenant_id, name, subject, body_html)
SELECT $1, 'Weekly Availability', 'This week at the grove', '<h1>Fresh this week</h1><p>Hi {{name}}, our harvest is in. Reply to reserve.</p>'
WHERE NOT EXISTS (
SELECT 1 FROM email_templates WHERE tenant_id = $1 AND name = 'Weekly Availability'
)
RETURNING id`,
[tenantId],
);
if (tmplRes.rows[0]) {
await client.query(
`INSERT INTO campaigns (tenant_id, template_id, name, status)
SELECT $1, $2, 'Welcome series — week 1', 'draft'
WHERE NOT EXISTS (
SELECT 1 FROM campaigns WHERE tenant_id = $1 AND name = 'Welcome series — week 1'
)`,
[tenantId, tmplRes.rows[0].id],
);
}
}
console.log(`Seeded ${tenantsData.length} tenants with sample data`);
// ── Platform admin (seeded for dev sign-in via Credentials provider) ─
// email: admin@route-commerce.local
// password: admin (override with SEED_ADMIN_PASSWORD)
//
// The user is attached to the Tuxedo tenant with the `platform_admin`
// role so `getAdminUser()` resolves it and grants cross-tenant
// visibility. To rotate the password, re-run `npm run db:seed`
// (the UPSERT updates `password_hash`).
const tuxedoRes = await client.query<{ id: string }>(
`SELECT id FROM tenants WHERE slug = 'tuxedo'`,
);
const tuxedoId = tuxedoRes.rows[0]?.id;
if (tuxedoId) {
const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "admin";
const passwordHash = hashPassword(adminPassword);
const adminRes = await client.query<{ id: string }>(
`INSERT INTO users (email, name, auth_provider, auth_subject, password_hash)
VALUES ($1, 'Platform Admin', 'dev', 'dev-platform-admin', $2)
ON CONFLICT (email) DO UPDATE SET
name = EXCLUDED.name,
password_hash = EXCLUDED.password_hash
RETURNING id`,
["admin@route-commerce.local", passwordHash],
);
const adminId = adminRes.rows[0].id;
await client.query(
`INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES ($1, $2, 'platform_admin')
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[tuxedoId, adminId],
);
console.log(
`Seeded platform admin: admin@route-commerce.local / ${adminPassword === "admin" ? "admin" : "(SEED_ADMIN_PASSWORD)"}`,
);
}
await client.query("COMMIT");
console.log("✅ Seed complete");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
main()
.then(() => pool.end())
.catch((err) => {
console.error("❌ Seed failed:", err);
pool.end();
process.exit(1);
});
-53
View File
@@ -1,53 +0,0 @@
import { chromium } from 'playwright';
async function debugAuth() {
console.log('Launching browser...');
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
// First, login
console.log('Navigating to login page...');
await page.goto('https://route-commerce-platform.vercel.app/login');
console.log('Filling login form...');
await page.fill('#email', 'kylemart@gmail.com');
await page.fill('#password', 'Test123456!');
console.log('Clicking sign in...');
const response = await page.click('button[type="submit"]');
// Wait for network to settle
await page.waitForLoadState('networkidle').catch(() => {});
console.log('Current URL after wait:', page.url());
// Get any error messages
const errorText = await page.$eval('[role="alert"]', el => el.textContent).catch(() => null);
if (errorText) console.log('Error message:', errorText);
const pageContent = await page.content();
if (pageContent.includes('Access Denied')) {
console.log('*** ACCESS DENIED PAGE DETECTED ***');
}
// Check cookies
const cookies = await context.cookies();
console.log('Cookies:', cookies.map(c => `${c.name}=${c.value.slice(0, 30)}...`));
// Try to visit debug-auth page
console.log('Navigating to /admin/debug-auth...');
try {
const response = await page.goto('https://route-commerce-platform.vercel.app/admin/debug-auth', { timeout: 10000 });
console.log('Response status:', response?.status());
console.log('Response URL:', page.url());
const content = await page.content();
console.log('Page content (first 2000 chars):', content.slice(0, 2000));
} catch (e) {
console.log('Error:', e);
}
await browser.close();
}
debugAuth().catch(console.error);
+7 -39
View File
@@ -2,17 +2,14 @@
# docker-compose.yml — production stack consumed by deploy.sh
# =============================================================================
#
# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by
# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011}
# so a manual `docker compose up` without the deploy script still works.
# Only `postgrest` lives in docker. Postgres itself runs on the host
# (the deploy workflow applies migrations via `psql -h 127.0.0.1`).
# Next.js runs under PM2 on the host — it is NOT a docker service.
#
# Note on networking: the Next.js container calls PostgREST on
# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined
# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves
# correctly. On Linux you may need to add
# extra_hosts:
# - "host.docker.internal:host-gateway"
# which is included below for that reason.
# The host-side port (POSTGREST_HOST_PORT) is written by the deploy
# workflow into $APP_DIR/.env. We interpolate from there with
# ${VAR:-3011} so a manual `docker compose up` without the deploy
# script still works.
# =============================================================================
name: prod-app # default project name; deploy.sh overrides with -p
@@ -39,32 +36,3 @@ services:
timeout: 3s
retries: 6
nextjs:
# Build context is the workspace root (one level up from this file).
build:
context: ..
dockerfile: deploy/Dockerfile.nextjs
container_name: prod-app-nextjs
restart: unless-stopped
ports:
- "${NEXTJS_HOST_PORT:-3012}:3000"
environment:
# Runtime vars — these can change without rebuilding. NEXT_PUBLIC_*
# is also exported here for completeness, but the BROWSER's view of
# NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile).
NODE_ENV: production
PORT: 3000
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
env_file:
- ../.env.production # server-side secrets read at runtime
extra_hosts:
# Lets the container reach the host on the dynamically allocated port.
- "host.docker.internal:host-gateway"
depends_on:
postgrest:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 3s
retries: 6
+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,
});
+11
View File
@@ -1,6 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Lock the file-tracing root to the project directory. Without this,
// Next.js 16 walks up from package.json looking for a lockfile, finds
// the homelab runner's stale `act` cache at
// /home/tyler/.cache/act/.../package-lock.json, and warns:
// "We detected multiple lockfiles and selected the directory of
// /home/tyler/package-lock.json as the root directory."
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
// resolving relative to the project root is correct both locally and
// in CI.
outputFileTracingRoot: ".",
// Enable strict mode
reactStrictMode: true,
+20 -8
View File
@@ -3,29 +3,35 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
"lint:fix": "eslint --fix",
"migrate": "node supabase/push-migrations.js",
"migrate:one": "node supabase/push-migrations.js",
"migrate": "node scripts/migrate.js",
"migrate:one": "node scripts/migrate.js",
"db:migrate": "node scripts/migrate.js",
"db:seed": "tsx db/seed.ts",
"db:reset": "node scripts/db-reset.js",
"db:studio": "drizzle-kit studio",
"type-check": "npx tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "playwright test --project=local",
"test:e2e:prod": "PLAYWRIGHT_PROD=1 playwright test",
"format": "prettier --write \"src/**/*.{ts,tsx}\""
},
"dependencies": {
"@anthropic-ai/sdk": "^0.96.0",
"@auth/pg-adapter": "^1.11.2",
"@clerk/nextjs": "^7.4.2",
"@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2",
"@sentry/nextjs": "^10.55.0",
"@stripe/react-stripe-js": "^3.10.0",
"@stripe/stripe-js": "^5.10.0",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
"drizzle-orm": "^0.36.4",
"exceljs": "^4.4.0",
"framer-motion": "^12.40.0",
"gsap": "^3.15.0",
@@ -57,13 +63,19 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^4.7.0",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.30.6",
"eslint": "^9",
"eslint-config-next": "16.2.5",
"jsdom": "^25.0.1",
"pg": "^8.20.0",
"playwright": "^1.59.1",
"tailwindcss": "^4",
"typescript": "^5.9.3"
"tsx": "^4.22.4",
"typescript": "^5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.9"
},
"overrides": "{}"
}
+11 -5
View File
@@ -1,6 +1,9 @@
import { defineConfig, devices } from "@playwright/test";
import path from "path";
const LOCAL_BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
const PROD_BASE = "https://route-commerce-platform.vercel.app";
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
@@ -9,16 +12,19 @@ export default defineConfig({
workers: 1,
reporter: "list",
use: {
baseURL: "https://route-commerce-platform.vercel.app",
baseURL: LOCAL_BASE,
trace: "on-first-retry",
},
projects: [
{
name: "local",
use: { ...devices["Desktop Chrome"], baseURL: LOCAL_BASE },
},
{
name: "production",
use: {
...devices["Desktop Chrome"],
baseURL: "https://route-commerce-platform.vercel.app",
},
// `PLAYWRIGHT_PROD=1 npx playwright test` to run against the live site.
testMatch: /.*\.prod\.spec\.ts$/,
use: { ...devices["Desktop Chrome"], baseURL: PROD_BASE },
},
],
});
+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);
});
-63
View File
@@ -1,63 +0,0 @@
"use server";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = NextResponse.next();
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return cookieStore.getAll(); },
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
},
},
});
// Upsert dev platform_admin record
const { data: existing } = await supabase
.from("admin_users")
.select("id, role")
.eq("user_id", DEV_ADMIN_UID)
.single();
if (!existing) {
const { error: insertError } = await supabase
.from("admin_users")
.insert({
user_id: DEV_ADMIN_UID,
brand_id: null,
role: "platform_admin",
active: true,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
must_change_password: false,
});
if (insertError) {
return { success: false, error: insertError.message };
}
}
return { success: true, uid: DEV_ADMIN_UID };
}
+38 -20
View File
@@ -1,30 +1,48 @@
"use server";
import { cookies } from "next/headers";
import { createClient as createServiceClient } from "@supabase/supabase-js";
import { auth } from "@/lib/auth";
import { query } from "@/lib/db";
/**
* Update the current user's Supabase auth password.
*
* Reads the Auth.js v5 session to identify the user. The session's
* `user.id` is either:
* - a Supabase auth user id (UUID) for email/password sign-ins
* - a Google `sub` (non-UUID) for Google sign-ins — these are not
* provisioned in Supabase auth, so the RPC will reject them. Google
* users must be provisioned in Supabase auth separately.
*
* The password update itself runs as a SECURITY DEFINER PL/pgSQL function
* (`update_user_password`) inside the database, called directly via the
* shared `pg` pool. No Supabase REST hop required.
*/
export async function updatePasswordAction(
newPassword: string
): Promise<{ error?: string }> {
const cookieStore = await cookies();
const uid =
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value;
): Promise<{ error?: string; userId?: string }> {
const session = await auth();
const uid = session?.user?.id;
if (!uid) {
return { error: "Not authenticated. Please log in again." };
}
const service = createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) {
return {
error:
"Password change is not available for social sign-in accounts. Please contact an admin.",
};
}
const { error } = await service.rpc("update_user_password", {
p_user_id: uid,
p_password: newPassword,
});
if (error) return { error: error.message };
return {};
}
try {
// The RPC is SECURITY DEFINER and returns a single row (or raises).
// We SELECT it (rather than SELECT update_user_password(...)) so the
// call stays a normal parameterized query and we can read the result.
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
return { userId: uid };
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to update password.";
return { error: message };
}
}
+243 -539
View File
@@ -1,18 +1,15 @@
"use server";
import { cookies, headers } from "next/headers";
import { createServerClient } from "@supabase/ssr";
import { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createClient as createServiceClient } from "@supabase/supabase-js";
import { supabase as publicSupabase } from "@/lib/supabase";
import "server-only";
import { cookies } from "next/headers";
import { pool, query } from "@/lib/db";
import { getMockTableData, mockBrands } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type AdminUserRow = {
id: string;
user_id: string;
user_id: string | null;
display_name: string | null;
email: string;
phone_number: string | null;
@@ -75,169 +72,17 @@ export type UpdateAdminUserInput = {
phone_number?: string | null;
};
// ─── SSR client for authenticated requests ─────────────────────────────────
async function getAuthClient() {
const cookieStore = await cookies();
const headerStore = await headers();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const request = new NextRequest("http://localhost/admin", { headers: new Headers() });
const response = NextResponse.next({ request });
// Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets
// cookies that arrive in the header but NOT in next/headers cookies()).
const cookieHeader = headerStore.get("cookie") || "";
const allCookies = cookieHeader.split(";").map(c => c.trim());
const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid="));
const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null;
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return cookieStore.getAll(); },
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
Object.entries(headers).forEach(([key, value]) => response.headers.set(key, value));
},
},
});
return { supabase, response, rcAuthUid };
}
async function callRpcWithAuth<T>(fn: string, params: Record<string, unknown>): Promise<{ data: T | null; error: string | null }> {
const { supabase, rcAuthUid } = await getAuthClient();
// Dev force-login UID bypasses Supabase auth entirely
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
if (rcAuthUid === DEV_FORCE_UID) {
return { data: null, error: null }; // let the action proceed without auth check
}
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError || !userData.user) {
return { data: null, error: "Not authenticated" };
}
const { data, error } = await supabase.rpc(fn, params as Record<string, unknown>);
if (error) { /* RPC error handled silently */ }
return { data: data as T, error: error ? error.message : null };
}
// ─── Service role client (server-only, never exposed to browser) ───────────
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set. Cannot use service role in dev path.");
}
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
// ─── Dev-only path — uses service role to create auth user + admin_users ──
async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
if (process.env.NODE_ENV === "production") {
return { user: null, error: "Dev path not available in production" };
}
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
if (!devSession || devSession !== "platform_admin") {
return { user: null, error: "Not authenticated" };
}
const service = getServiceClient();
// Create auth user with the provided password
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email,
password: input.password,
email_confirm: true,
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
},
});
if (authError || !authUser.user) {
return { user: null, error: authError?.message ?? "Failed to create auth user" };
}
// Insert into admin_users
const { data: inserted, error: insertError } = await service
.from("admin_users")
.insert({
user_id: authUser.user.id,
role: input.role,
brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
can_manage_products: input.flags.can_manage_products ?? false,
can_manage_stops: input.flags.can_manage_stops ?? false,
can_manage_orders: input.flags.can_manage_orders ?? false,
can_manage_pickup: input.flags.can_manage_pickup ?? false,
can_manage_messages: input.flags.can_manage_messages ?? false,
can_manage_refunds: input.flags.can_manage_refunds ?? false,
can_manage_users: input.flags.can_manage_users ?? false,
can_manage_water_log: input.flags.can_manage_water_log ?? false,
can_manage_reports: input.flags.can_manage_reports ?? false,
active: true,
must_change_password: input.mustChangePassword ?? true,
})
.select()
.single();
if (insertError) {
return { user: null, error: insertError.message };
}
// Send welcome email
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
await sendWelcomeEmail({
to: input.email,
name: input.display_name || input.email.split("@")[0],
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
brandName: "Tuxedo Corn",
tempPassword: input.password,
});
} catch (e) {
// welcome email failed silently
}
return {
user: {
id: inserted.id,
user_id: inserted.user_id,
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: inserted.phone_number ?? input.phone_number ?? null,
role: inserted.role,
brand_id: inserted.brand_id,
brand_name: null,
can_manage_products: inserted.can_manage_products,
can_manage_stops: inserted.can_manage_stops,
can_manage_orders: inserted.can_manage_orders,
can_manage_pickup: inserted.can_manage_pickup,
can_manage_messages: inserted.can_manage_messages,
can_manage_refunds: inserted.can_manage_refunds,
can_manage_users: inserted.can_manage_users,
can_manage_water_log: inserted.can_manage_water_log,
can_manage_reports: inserted.can_manage_reports,
active: inserted.active,
must_change_password: inserted.must_change_password ?? true,
created_at: inserted.created_at,
last_login: null,
},
error: null,
};
}
// ─── Row mapping ────────────────────────────────────────────────────────────
//
// `admin_users` schema (after migration 204 + 034 + 037):
// id, user_id, display_name, email, phone_number, role, brand_id,
// can_manage_<X> (BOOLEAN each), active, must_change_password,
// created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
return {
id: String(row.id ?? ""),
user_id: String(row.user_id ?? ""),
user_id: (row.user_id as string | null) ?? null,
display_name: (row.display_name as string | null) ?? null,
email: String(row.email ?? ""),
phone_number: (row.phone_number as string | null) ?? null,
@@ -260,413 +105,272 @@ function mapUserRow(row: Record<string, unknown>): AdminUserRow {
};
}
// ─── Dev path helpers (service role, local only) ───────────────────────────
// ─── Welcome email (best-effort) ────────────────────────────────────────────
async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
const service = getServiceClient();
// Ensure caller has an admin_users record
if (callerUid) {
const { data: existing } = await service
.from("admin_users")
.select("id")
.eq("user_id", callerUid)
.maybeSingle();
if (!existing) {
// auto-creating admin_users for uid
const { data: authData } = await service.auth.admin.listUsers();
const authUser = authData?.users?.find((u) => u.id === callerUid);
const meta = (authUser as { user_metadata?: Record<string, unknown> })?.user_metadata;
await service.from("admin_users").insert({
user_id: callerUid,
role: "platform_admin",
brand_id: null,
display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin",
phone_number: (meta?.phone_number as string | null) ?? null,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
active: true,
must_change_password: false,
});
}
async function sendWelcomeEmailSafe(input: {
to: string;
name: string;
role: "platform_admin" | "brand_admin" | "store_employee";
password: string;
}): Promise<void> {
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
await sendWelcomeEmail({
to: input.to,
name: input.name,
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
brandName: "Tuxedo Corn",
tempPassword: input.password,
});
} catch {
// welcome email is best-effort; never block user creation
}
// Fetch all admin_users rows (no RLS for service role)
const { data: adminRows, error: adminError } = await service
.from("admin_users")
.select(`
id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports,
brands (name)
`)
.order("created_at", { ascending: false });
if (adminError) return { users: [], error: adminError.message };
// Fetch auth user details via service role admin API
const { data: authData, error: authError } = await service.auth.admin.listUsers();
if (authError) return { users: [], error: authError.message };
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authData?.users ?? []).forEach((u) => {
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
authMap[user.id] = {
email: user.email ?? "",
display_name: (user.user_metadata?.display_name as string | null) ?? (user.user_metadata?.full_name as string | null) ?? null,
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
}
function buildUsersFromRows(adminRows: Record<string, unknown>[], authUsers: { id: string; email?: string; user_metadata?: Record<string, unknown> }[]): { users: AdminUserRow[]; error: string | null } {
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authUsers ?? []).forEach((u) => {
authMap[u.id] = {
email: u.email ?? "",
display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null,
phone_number: (u.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = adminRows.map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
}
// ─── Production admin actions (require real Supabase auth) ─────────────────
const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001";
// ─── Public actions ─────────────────────────────────────────────────────────
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
let filteredUsers = mockUsers;
if (brandId) {
filteredUsers = mockUsers.filter(u => u.brand_id === brandId);
}
return { users: filteredUsers, error: null };
}
const cookieStore = await cookies();
const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
// Read rc_auth_uid for force-login check
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// In development mode: ALL requests with a valid rc_auth_uid use the dev/service path.
// This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation.
if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) {
return devListAdminUsers(rcAuthUid);
}
// Dev session cookie (platform_admin/brand_admin) — always use service role path
const isDevAdmin = process.env.NODE_ENV !== "production" && (
devSession === "platform_admin" || devSession === "brand_admin" || rcAuthUid === DEV_FORCE_UID
);
if (isDevAdmin) {
return devListAdminUsers(rcAuthUid ?? undefined);
}
// Production path: try authenticated RPC first, fall back to service role if not authenticated
const result = await callRpcWithAuth<AdminUserRow[]>("get_admin_users", { p_brand_id: brandId ?? null });
if (result.error === "Not authenticated" && rcAuthUid) {
// No Supabase session token in browser — use service role with rc_auth_uid
const service = getServiceClient();
const { data: adminRows, error: adminError } = await service
.from("admin_users")
.select(`id, user_id, role, brand_id, active, must_change_password, created_at, last_login,
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports,
brands (name)`)
.order("created_at", { ascending: false });
if (adminError) return { users: [], error: adminError.message };
const { data: authData } = await service.auth.admin.listUsers();
const authMap: Record<string, { email: string; display_name: string | null; phone_number: string | null }> = {};
(authData?.users ?? []).forEach((u) => {
const user = u as { id: string; email?: string; user_metadata?: Record<string, unknown> };
authMap[user.id] = {
email: user.email ?? "",
display_name: (user.user_metadata?.display_name as string | null) ?? null,
phone_number: (user.user_metadata?.phone_number as string | null) ?? null,
};
});
const users: AdminUserRow[] = (adminRows ?? []).map((row) => {
const r = row as Record<string, unknown> & { brands?: { name?: string } };
const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null };
return {
...mapUserRow(r),
email: authInfo.email || "No Email",
display_name: authInfo.display_name ?? null,
phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null,
brand_name: r.brands?.name ?? null,
};
});
return { users, error: null };
}
return { users: result.data ?? [], error: result.error };
}
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
// Read auth context
const cookieStore = await cookies();
const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin
if (rcAuthUid === DEV_FORCE_UID) {
return devCreateAdminUser(input);
}
const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin";
// Dev path: use service role to create user without Supabase auth session
if (isDevAdmin) {
return devCreateAdminUser(input);
}
// Production path — use service role directly (bypasses Supabase JWT auth)
// rc_auth_uid cookie proves the admin is logged in; service role creates the account
if (rcAuthUid) {
const service = getServiceClient();
// Create auth user
const { data: authUser, error: authError } = await service.auth.admin.createUser({
email: input.email,
password: input.password,
email_confirm: true,
user_metadata: {
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
},
});
if (authError || !authUser.user) {
return { user: null, error: authError?.message ?? "Failed to create auth user" };
}
// Insert into admin_users
const { data: inserted, error: insertError } = await service
.from("admin_users")
.insert({
user_id: authUser.user.id,
role: input.role,
brand_id: input.brand_id,
display_name: input.display_name || input.email.split("@")[0],
phone_number: input.phone_number ?? null,
can_manage_products: input.flags.can_manage_products ?? false,
can_manage_stops: input.flags.can_manage_stops ?? false,
can_manage_orders: input.flags.can_manage_orders ?? false,
can_manage_pickup: input.flags.can_manage_pickup ?? false,
can_manage_messages: input.flags.can_manage_messages ?? false,
can_manage_refunds: input.flags.can_manage_refunds ?? false,
can_manage_users: input.flags.can_manage_users ?? false,
can_manage_water_log: input.flags.can_manage_water_log ?? false,
can_manage_reports: input.flags.can_manage_reports ?? false,
active: true,
must_change_password: input.mustChangePassword ?? true,
})
.select()
.single();
if (insertError) return { user: null, error: insertError.message };
// Send welcome email
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
await sendWelcomeEmail({
to: input.email,
name: input.display_name || input.email.split("@")[0],
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
brandName: "Tuxedo Corn",
tempPassword: input.password,
});
} catch (e) {
// welcome email failed silently
}
return {
user: {
id: inserted.id,
user_id: inserted.user_id,
display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: inserted.phone_number ?? input.phone_number ?? null,
role: inserted.role,
brand_id: inserted.brand_id,
brand_name: null,
can_manage_products: inserted.can_manage_products,
can_manage_stops: inserted.can_manage_stops,
can_manage_orders: inserted.can_manage_orders,
can_manage_pickup: inserted.can_manage_pickup,
can_manage_messages: inserted.can_manage_messages,
can_manage_refunds: inserted.can_manage_refunds,
can_manage_users: inserted.can_manage_users,
can_manage_water_log: inserted.can_manage_water_log,
can_manage_reports: inserted.can_manage_reports,
active: inserted.active,
must_change_password: inserted.must_change_password ?? true,
created_at: inserted.created_at,
last_login: null,
},
users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers,
error: null,
};
}
return { user: null, error: "Not authenticated" };
try {
const sql = brandId
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
au.role, au.brand_id, b.name AS brand_name,
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
au.active, au.must_change_password, au.created_at, au.last_login
FROM admin_users au
LEFT JOIN brands b ON b.id = au.brand_id
WHERE au.brand_id = $1
ORDER BY au.created_at DESC`
: `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
au.role, au.brand_id, b.name AS brand_name,
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
au.active, au.must_change_password, au.created_at, au.last_login
FROM admin_users au
LEFT JOIN brands b ON b.id = au.brand_id
ORDER BY au.created_at DESC`;
const { rows } = await query<Record<string, unknown>>(sql, brandId ? [brandId] : []);
return { users: rows.map(mapUserRow), error: null };
} catch (err) {
return { users: [], error: err instanceof Error ? err.message : String(err) };
}
}
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const newRow: AdminUserRow = {
id: `mock-${Date.now()}`,
user_id: null,
display_name: input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: input.phone_number ?? null,
role: input.role,
brand_id: input.brand_id,
brand_name: null,
can_manage_products: input.flags.can_manage_products ?? false,
can_manage_stops: input.flags.can_manage_stops ?? false,
can_manage_orders: input.flags.can_manage_orders ?? false,
can_manage_pickup: input.flags.can_manage_pickup ?? false,
can_manage_messages: input.flags.can_manage_messages ?? false,
can_manage_refunds: input.flags.can_manage_refunds ?? false,
can_manage_users: input.flags.can_manage_users ?? false,
can_manage_water_log: input.flags.can_manage_water_log ?? false,
can_manage_reports: input.flags.can_manage_reports ?? false,
active: true,
must_change_password: input.mustChangePassword ?? true,
created_at: new Date().toISOString(),
last_login: null,
};
mockUsers.push(newRow);
return { user: newRow, error: null };
}
try {
// No Supabase Auth — `user_id` stays NULL until the user signs in
// via Auth.js and `get_admin_user_for_session` matches them by
// `auth_subject` / `email`. We just insert the row.
const f = input.flags;
const { rows } = await query<Record<string, unknown>>(
`INSERT INTO admin_users
(user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, auth_provider, auth_subject)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'pending',$17)
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, created_at, last_login`,
[
null,
input.display_name ?? input.email.split("@")[0],
input.email.toLowerCase(),
input.phone_number ?? null,
input.role,
input.brand_id,
f.can_manage_products ?? false,
f.can_manage_stops ?? false,
f.can_manage_orders ?? false,
f.can_manage_pickup ?? false,
f.can_manage_messages ?? false,
f.can_manage_refunds ?? false,
f.can_manage_users ?? false,
f.can_manage_water_log ?? false,
f.can_manage_reports ?? false,
input.mustChangePassword ?? true,
input.email.toLowerCase(),
],
);
if (!rows[0]) return { user: null, error: "Insert returned no row" };
await sendWelcomeEmailSafe({
to: input.email,
name: input.display_name ?? input.email.split("@")[0],
role: input.role,
password: input.password,
});
return { user: mapUserRow(rows[0]), error: null };
} catch (err) {
return { user: null, error: err instanceof Error ? err.message : String(err) };
}
}
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
// Dev bypass check
const cookieStore = await cookies();
const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
if (rcAuthUid === DEV_FORCE_UID) {
const service = getServiceClient();
const { data, error } = await service
.from("admin_users")
.update({
role: input.role ?? undefined,
brand_id: input.brand_id ?? undefined,
can_manage_products: input.flags?.can_manage_products ?? undefined,
can_manage_stops: input.flags?.can_manage_stops ?? undefined,
can_manage_orders: input.flags?.can_manage_orders ?? undefined,
can_manage_pickup: input.flags?.can_manage_pickup ?? undefined,
can_manage_messages: input.flags?.can_manage_messages ?? undefined,
can_manage_refunds: input.flags?.can_manage_refunds ?? undefined,
can_manage_users: input.flags?.can_manage_users ?? undefined,
can_manage_water_log: input.flags?.can_manage_water_log ?? undefined,
can_manage_reports: input.flags?.can_manage_reports ?? undefined,
active: input.active ?? undefined,
display_name: input.display_name ?? null,
phone_number: input.phone_number ?? null,
})
.eq("id", input.id)
.select()
.single();
if (error) return { user: null, error: error.message };
return { user: mapUserRow(data), error: null };
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const idx = mockUsers.findIndex((u) => u.id === input.id);
if (idx === -1) return { user: null, error: "User not found" };
const merged: AdminUserRow = { ...mockUsers[idx] };
if (input.role !== undefined) merged.role = input.role;
if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
if (input.active !== undefined) merged.active = input.active;
if (input.display_name !== undefined) merged.display_name = input.display_name;
if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
if (input.flags) {
for (const [k, v] of Object.entries(input.flags)) {
if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
}
}
mockUsers[idx] = merged;
return { user: merged, error: null };
}
const result = await callRpcWithAuth<AdminUserRow[]>("update_admin_user", {
p_id: input.id,
p_role: input.role ?? null,
p_brand_id: input.brand_id ?? null,
p_flags: input.flags ?? null,
p_active: input.active ?? null,
p_display_name: input.display_name ?? null,
p_phone_number: input.phone_number ?? null,
});
const rows = result.data as AdminUserRow[] | null;
return { user: rows?.[0] ?? null, error: result.error };
try {
// Build a partial SET clause. Each `can_manage_*` column is set
// individually — the input's `flags` partial is spread across them.
const sets: string[] = [];
const params: unknown[] = [];
const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); };
if (input.role !== undefined) push("role", input.role);
if (input.brand_id !== undefined) push("brand_id", input.brand_id);
if (input.active !== undefined) push("active", input.active);
if (input.display_name !== undefined) push("display_name", input.display_name);
if (input.phone_number !== undefined) push("phone_number", input.phone_number);
if (input.flags) {
for (const [key, val] of Object.entries(input.flags)) {
if (val !== undefined) push(key, val);
}
}
if (sets.length === 0) return { user: null, error: "Nothing to update" };
params.push(input.id);
const sql = `UPDATE admin_users SET ${sets.join(", ")}
WHERE id = $${params.length}
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, created_at, last_login`;
const { rows } = await query<Record<string, unknown>>(sql, params);
if (!rows[0]) return { user: null, error: "User not found" };
return { user: mapUserRow(rows[0]), error: null };
} catch (err) {
return { user: null, error: err instanceof Error ? err.message : String(err) };
}
}
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
// Dev bypass check
const cookieStore = await cookies();
const headerStore = await headers();
const devSession = cookieStore.get("dev_session")?.value;
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
// DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly
if (rcAuthUid === DEV_FORCE_UID) {
const service = getServiceClient();
// Get user_id first
const { data: adminRow, error: fetchError } = await service
.from("admin_users")
.select("user_id")
.eq("id", id)
.single();
if (fetchError) return { success: false, error: fetchError.message };
// Delete from admin_users
const { error: deleteError } = await service.from("admin_users").delete().eq("id", id);
if (deleteError) return { success: false, error: deleteError.message };
// Delete auth user
if (adminRow?.user_id) {
await service.auth.admin.deleteUser(adminRow.user_id);
}
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const idx = mockUsers.findIndex((u) => u.id === id);
if (idx === -1) return { success: false, error: "User not found" };
mockUsers.splice(idx, 1);
return { success: true, error: null };
}
const result = await callRpcWithAuth<boolean>("delete_admin_user", { p_id: id });
return { success: result.data ?? false, error: result.error };
try {
// No Supabase Auth — nothing to delete from the auth service.
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
return { success: (rowCount ?? 0) > 0, error: null };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
const cookieStore = await cookies();
const headerStore = await headers();
const cookieHeader = headerStore.get("cookie") || "";
const rcAuthUid = cookieHeader.split(";").map(c => c.trim())
.find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null;
if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") {
const service = getServiceClient();
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null };
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const u = mockUsers.find((m) => m.id === userId);
if (!u) return { success: false, error: "User not found" };
u.must_change_password = true;
return { success: true, error: null };
}
// Production path — use service role via direct update
const service = getServiceClient();
const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId);
return { success: !error, error: error?.message ?? null };
try {
const { rowCount } = await query(
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
[userId],
);
return { success: (rowCount ?? 0) > 0, error: null };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> {
const { error } = await publicSupabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`,
});
return { success: !error, error: error?.message ?? null };
/**
* No auth service anymore (no Supabase, no Auth.js password-reset
* endpoint). A platform admin can reset access by deleting +
* re-creating the user, or by toggling `must_change_password` via the
* UI — the function is preserved as a no-op so call sites keep
* compiling.
*/
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
return {
success: false,
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
};
}
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
if (useMockData) {
const brands = mockBrands.map(b => ({ id: b.id, name: b.name }));
return { brands, error: null };
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
}
try {
const { rows } = await query<{ id: string; name: string }>(
`SELECT id, name FROM brands ORDER BY name`,
);
return { brands: rows, error: null };
} catch (err) {
return { brands: [], error: err instanceof Error ? err.message : String(err) };
}
}
const { data, error } = await publicSupabase.from("brands").select("id, name").order("name");
return { brands: data ?? [], error: error?.message ?? null };
}
// Keep `pool` reachable so bundlers don't tree-shake the import — the
// import is for the `server-only` side effect.
void pool;
+1 -3
View File
@@ -21,9 +21,7 @@ type AuditResult =
/**
* Logs an audit event to the audit_logs table.
*
* In dev mode (dev_session cookie), uses the dev user identity.
* In production (Supabase auth), resolves the admin user from admin_users.
*
* Resolves the admin user from the Auth.js session via getAdminUser().
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
*/
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
+44
View File
@@ -0,0 +1,44 @@
"use server";
import "server-only";
import { signIn, signOut } from "@/lib/auth";
import { redirect } from "next/navigation";
/**
* Kick off the Google OAuth flow. Auth.js will redirect to Google's
* consent screen and then back to /api/auth/callback/google, which sets
* the session cookie and redirects to /admin.
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
/**
* Sign in with email + password. The `credentials` provider is enabled
* in dev (see `isDevLoginEnabled()` in `src/auth.config.ts`); in
* production it is omitted entirely and this action returns an
* `AuthError` (Auth.js surfaces `?error=Configuration` on /login).
*
* On a failed credential check Auth.js redirects back to
* /login?error=CredentialsSignin, which the LoginClient renders as
* "Invalid email or password."
*/
export async function signInWithCredentials(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const password = String(formData.get("password") ?? "");
if (!email || !password) {
redirect("/login?error=MissingCredentials");
}
await signIn("credentials", {
email,
password,
redirectTo: "/admin",
});
}
/**
* Sign out and clear the Auth.js session cookie.
*/
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
+3 -27
View File
@@ -1,7 +1,6 @@
"use server";
import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
/**
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
@@ -19,38 +18,15 @@ import { AuthError } from "next-auth";
* <button type="submit">Sign in with Google</button>
* </form>
*
* Usage for the dev credentials provider (dev only):
* <form action={signInWithDev}>
* <input name="username" />
* <input name="password" type="password" />
* <button type="submit">Dev login</button>
* </form>
* Note: dev/demo authentication is no longer a button on the login page.
* `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
* when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
export async function signInWithDev(formData: FormData): Promise<void> {
const username = String(formData.get("username") ?? "admin");
const password = String(formData.get("password") ?? "dev");
try {
await signIn("dev-login", {
username,
password,
redirectTo: "/admin",
});
} catch (e) {
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
// so the redirect actually happens. Re-throw any other error so the
// caller can render a meaningful message.
if (e instanceof AuthError) {
throw new Error(`Dev sign-in failed: ${e.type}`);
}
throw e;
}
}
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
+1 -2
View File
@@ -273,8 +273,7 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Return
// a not-configured result; the page falls back to slug-based defaults.
if (!supabaseUrl || !supabaseKey) {
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
}
-56
View File
@@ -1,56 +0,0 @@
"use server";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
export type LoginWithPasswordResult =
| { success: true; redirect: true }
| { success: false; error: string };
export async function loginWithPassword(
email: string,
password: string
): Promise<LoginWithPasswordResult> {
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return { success: false, error: "Server misconfiguration." };
}
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
});
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error || !data.user) {
return { success: false, error: error?.message || "Invalid credentials" };
}
// Set the rc_auth_uid cookie that getAdminUser() reads
const isProd = process.env.NODE_ENV === "production";
cookieStore.set("rc_auth_uid", data.user.id, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: isProd,
});
return { success: true, redirect: true };
}
+4 -1
View File
@@ -5,7 +5,7 @@ import { logAuditEvent } from "@/actions/audit";
import { svcHeaders } from "@/lib/svc-headers";
type MarkPickupResult =
| { success: true; pickup_completed_at: string; pickup_completed_by: string }
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
| { success: false; error: string };
export async function markPickupComplete(
@@ -23,6 +23,9 @@ export async function markPickupComplete(
}
const now = new Date().toISOString();
// `user_id` is null for Google-authenticated admins who haven't been
// linked to a Supabase auth user yet. Pass null through; downstream
// audit/assignment RPCs will surface a clearer error.
const performedBy = adminUser.user_id;
// brand_admin: verify the order belongs to their brand
+5 -8
View File
@@ -112,13 +112,12 @@ export type StopForSitemap = {
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the sitemap render with only the static URLs.
if (!supabaseUrl || !supabaseKey) return [];
// Get all active stops with their brand slug. Wrapped in try/catch so a
// build-time outage (ECONNREFUSED) doesn't crash the prerender — the
// sitemap just renders without stop URLs.
// Get all active stops with their brand slug.
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
// crash the prerender — the sitemap just renders without stop URLs.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
@@ -166,9 +165,7 @@ export async function getPublicStopsForBrand(
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the page render with zero stops; at runtime the
// fetch path is unchanged.
if (!supabaseUrl || !supabaseKey) return [];
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
-60
View File
@@ -1,60 +0,0 @@
import { cookies } from "next/headers";
export default async function DebugAuthPage() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
let adminUsersStatus = "not_tried";
let adminUsersResult: string | null = null;
if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && serviceKey) {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
adminUsersStatus = String(res.status);
const data = await res.json().catch(() => null);
adminUsersResult = JSON.stringify(data);
} catch (e) {
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
}
} else {
adminUsersStatus = "missing_env_vars";
}
} else {
adminUsersStatus = "no_rc_auth_uid_cookie";
}
return (
<div className="min-h-screen bg-zinc-950 p-8 text-white">
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
</div>
</div>
);
}
+18 -6
View File
@@ -64,13 +64,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN
redirect("/change-password");
}
// Resolve the active brand (URL > cookie > legacy > first of brand_ids)
const activeBrandId = await getActiveBrandId(adminUser);
// Resolve the active brand (URL > cookie > legacy > first of brand_ids).
// Wrapped in try/catch so a transient brand-resolution failure can't
// crash the whole admin shell — we fall back to null (no active brand).
let activeBrandId: string | null = null;
try {
activeBrandId = await getActiveBrandId(adminUser);
} catch (err) {
console.error("[admin/layout] getActiveBrandId failed:", err);
}
// Fetch accessible brands for the sidebar BrandSelector. We do this
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar
// decides whether to show the dropdown.
const brands = await listBrandsForAdmin();
// Fetch accessible brands for the sidebar BrandSelector. Wrapped in
// try/catch so the sidebar renders empty rather than crashing the page
// if the brands query fails.
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
try {
brands = await listBrandsForAdmin();
} catch (err) {
console.error("[admin/layout] listBrandsForAdmin failed:", err);
}
return (
<ToastProviderWrapper>
+10 -41
View File
@@ -2,9 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { AdminUserRow } from "@/actions/admin/users";
import { logUserActivity } from "@/actions/admin/audit";
type ProfilePageProps = {
currentUser: AdminUserRow;
@@ -21,53 +19,24 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
const [newEmail, setNewEmail] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
// Profile / email mutations used to call Supabase directly. With the
// platform moved off Supabase entirely, those handlers are stubbed out
// — the page remains read-only until a server-action equivalent ships.
// See the final YOLO report for the broader Supabase → pg data-fetch
// migration that covers the rest of the admin pages.
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const { error: rpcError } = await supabase.rpc("update_admin_user", {
p_id: currentUser.id,
p_display_name: displayName || null,
p_phone_number: phoneNumber || null,
});
if (rpcError) {
setError(rpcError.message);
return;
}
await logUserActivity({
user_id: currentUser.user_id,
activity_type: "profile_update",
details: { fields: ["display_name", "phone_number"] },
});
setEditing(false);
} finally {
setSaving(false);
}
setError("Profile editing is temporarily unavailable. Contact a platform admin.");
setSaving(false);
}
async function handleEmailChange(e: React.FormEvent) {
e.preventDefault();
setChangingEmail(true);
setEmailError(null);
try {
const { error: updateError } = await supabase.auth.updateUser({
email: newEmail,
});
if (updateError) {
setEmailError(updateError.message);
return;
}
await logUserActivity({
user_id: currentUser.user_id,
activity_type: "email_change",
details: { new_email: newEmail },
});
setEmailChangeSent(true);
setChangingEmail(false);
} finally {
setChangingEmail(false);
}
setEmailError("Email changes are temporarily unavailable. Contact a platform admin.");
setChangingEmail(false);
}
return (
+13 -8
View File
@@ -27,16 +27,21 @@ export default async function AdminPage() {
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
// > first of brand_ids). For platform_admin in dev mode this is null, so we
// fall back to the first brand in the brands table to keep the dashboard's
// "Active Products" stat in sync with the billing page.
// "Active Products" stat in sync with the billing page. Wrapped in try/catch
// so a transient DB/network failure can't crash the whole admin page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
const { data: firstBrand } = await supabase
.from("brands")
.select("id")
.limit(1)
.single();
if (firstBrand?.id) {
dashboardBrandId = firstBrand.id;
try {
const { data: firstBrand } = await supabase
.from("brands")
.select("id")
.limit(1)
.single();
if (firstBrand?.id) {
dashboardBrandId = firstBrand.id;
}
} catch (err) {
console.error("[admin/page] supabase brands lookup failed:", err);
}
}
-90
View File
@@ -1,90 +0,0 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export default async function TestAuthPage() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
let adminUser = null;
let error: string | null = null;
try {
adminUser = await getAdminUser();
} catch (e: unknown) {
error = e instanceof Error ? e.message : String(e);
}
return (
<div className="min-h-screen bg-zinc-950 p-8 text-white">
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
<p className="text-lg font-mono">
{allCookies.some(c => c.name === "rc_auth_uid")
? <span className="text-emerald-400">SET {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
: <span className="text-red-400">NOT SET</span>
}
</p>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
<p className="text-lg font-mono">
{allCookies.some(c => c.name === "rc_access_token")
? <span className="text-yellow-400">SET (not needed)</span>
: <span className="text-zinc-500">NOT SET (OK)</span>
}
</p>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
{error ? (
<div>
<p className="text-red-400 font-bold">ERROR</p>
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
</div>
) : adminUser ? (
<div>
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
{JSON.stringify({
id: adminUser.id,
user_id: adminUser.user_id,
role: adminUser.role,
brand_id: adminUser.brand_id,
active: adminUser.active,
}, null, 2)}
</pre>
</div>
) : (
<p className="text-red-400">NOT AUTHENTICATED null returned</p>
)}
</div>
<div className="mt-8 pt-4 border-t border-zinc-800">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
<div className="flex gap-4">
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
Go to Admin
</a>
<form action="/api/logout" method="POST">
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
Logout
</button>
</form>
</div>
</div>
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export default async function TestPage() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
let adminUser = null;
let adminUserError: string | null = null;
try {
adminUser = await getAdminUser();
} catch (e: any) {
adminUserError = e?.message ?? String(e);
}
return (
<div className="min-h-screen bg-zinc-950 p-8 text-white">
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Server cookies ({allCookies.length})</h2>
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}...`).join("\n") || "(none)"}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token present?</h2>
<p className="text-lg font-mono">
{allCookies.some(c => c.name === "rc_access_token")
? <span className="text-emerald-400">YES {allCookies.find(c => c.name === "rc_access_token")?.value.length} chars</span>
: <span className="text-red-400">NO</span>
}
</p>
</div>
<div>
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
{adminUserError ? (
<p className="text-red-400">ERROR: {adminUserError}</p>
) : (
<pre className="bg-black/50 p-4 rounded-xl text-sm overflow-auto">
{JSON.stringify(adminUser, null, 2)}
</pre>
)}
</div>
</div>
);
}
+2 -14
View File
@@ -1,16 +1,4 @@
// Auth.js v5 route handler — re-exports the GET/POST handlers from src/lib/auth.ts
// Mounted at /api/auth/* (signin, signout, callback, session, csrf, providers, etc.)
import { handlers } from "@/lib/auth";
/**
* Auth.js v5 catch-all route handler. Exposes:
* GET /api/auth/signin
* GET /api/auth/signout
* GET /api/auth/session
* GET /api/auth/csrf
* GET /api/auth/providers
* POST /api/auth/callback/:provider
* POST /api/auth/signin/:provider
* POST /api/auth/signout
*
* The actual OAuth + session logic is in `src/lib/auth.ts`.
*/
export const { GET, POST } = handlers;
-11
View File
@@ -1,11 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const uid =
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value ??
null;
return NextResponse.json({ uid });
}
-45
View File
@@ -1,45 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
if (!serviceKey || !supabaseUrl) {
return NextResponse.json({ error: "Missing env vars", serviceKey: !!serviceKey, supabaseUrl: !!supabaseUrl }, { status: 500 });
}
// Test 1: just a simple health endpoint that doesn't require the key
let healthResult = null;
try {
const res = await fetch(`${supabaseUrl}/rest/v1/`, {
headers: { apikey: serviceKey },
});
healthResult = { status: res.status, ok: res.ok };
} catch (e: any) {
healthResult = { error: e?.message };
}
// Test 2: try admin_users with POST (bypasses RLS select policies)
let adminResult = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?select=id,user_id,role,email,display_name&limit=5`,
{
method: "GET",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
}
);
const body = await res.text();
let parsed = null;
try { parsed = JSON.parse(body); } catch { parsed = body; }
adminResult = { status: res.status, body: parsed };
} catch (e: any) {
adminResult = { error: e?.message };
}
return NextResponse.json({ healthResult, adminResult, serviceKeyLen: serviceKey.length });
}
-48
View File
@@ -1,48 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
let adminUsersStatus = "not_tried";
let adminUsersResult: string | null = null;
if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && serviceKey) {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
adminUsersStatus = String(res.status);
const data = await res.json().catch(() => null);
adminUsersResult = JSON.stringify(data);
} catch (e) {
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
}
} else {
adminUsersStatus = "missing_env_vars";
}
} else {
adminUsersStatus = "no_rc_auth_uid_cookie";
}
return NextResponse.json({
cookies: {
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
rc_access_token: rcAccessToken ? "present" : null,
all_cookie_names: allCookies.map(c => c.name),
},
admin_users: {
status: adminUsersStatus,
result: adminUsersResult,
},
});
}
-18
View File
@@ -1,18 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
return NextResponse.json({
received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })),
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING",
rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING",
raw_rc_auth_uid: rcAuthUid ?? null,
});
}
-31
View File
@@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const nodeEnv = process.env.NODE_ENV;
const result = {
NODE_ENV: nodeEnv,
NEXT_PUBLIC_SUPABASE_URL: url ? `${url.substring(0, 40)}... (SET)` : "MISSING",
NEXT_PUBLIC_SUPABASE_ANON_KEY: key ? `${key.substring(0, 20)}... (SET)` : "MISSING",
SUPABASE_SERVICE_ROLE_KEY: serviceKey ? `${serviceKey.substring(0, 20)}... (SET)` : "MISSING",
supabaseClientCanCreate: false as boolean,
error: null as string | null,
};
if (url && key) {
try {
const { createClient } = await import("@supabase/supabase-js");
const client = createClient(url, key);
result.supabaseClientCanCreate = true;
} catch (e: any) {
result.error = e?.message ?? String(e);
}
} else {
result.error = "Missing env vars";
}
return NextResponse.json(result, { status: 200 });
}
@@ -1,34 +0,0 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
export async function GET() {
// Test the REST call directly from this endpoint
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uid = "c023efb3-e3ef-4156-bed6-d17b92ea8aca";
let restResult = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
const data = await res.json().catch(() => null);
restResult = { status: res.status, data, keyLen: serviceKey.length };
} catch (e: any) {
restResult = { error: e?.message };
}
const adminUser = await getAdminUser();
return NextResponse.json({
adminUser: adminUser ? {
id: adminUser.id,
user_id: adminUser.user_id,
role: adminUser.role,
brand_id: adminUser.brand_id,
active: adminUser.active,
} : null,
restResult,
supabaseUrl: supabaseUrl ? "SET" : "MISSING",
});
}
-9
View File
@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
ts: new Date().toISOString(),
deployment: "debug-hello",
msg: "hello from latest build"
});
}
-80
View File
@@ -1,80 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
if (!uid) {
return NextResponse.json({ error: "No uid cookie found" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) {
return NextResponse.json({
uid,
error: "Missing env vars",
supabaseUrl: supabaseUrl ?? "MISSING",
serviceKeyPresent: !!serviceKey,
});
}
// Exact same lookup as getAdminUser
const lookupRes = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
let adminUsers: unknown[] = [];
let lookupOk = lookupRes.ok;
let lookupStatus = lookupRes.status;
let lookupData: unknown = null;
if (lookupRes.ok) {
lookupData = await lookupRes.json().catch(() => []);
adminUsers = Array.isArray(lookupData) ? lookupData : [];
} else {
lookupData = await lookupRes.text().catch(() => "unknown error");
}
if (adminUsers.length > 0) {
return NextResponse.json({
uid,
result: "found",
adminUser: adminUsers[0],
});
}
// Try auto-create
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) {
return NextResponse.json({ uid, result: "invalid_uuid" });
}
const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({
user_id: uid, role: "platform_admin", brand_id: null, active: true,
can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true,
can_manage_settings: true, must_change_password: false
}),
});
return NextResponse.json({
uid,
result: "auto_created",
lookupOk,
lookupStatus,
adminUsersFound: adminUsers.length,
postStatus: postRes.status,
postOk: postRes.ok,
postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null),
});
}
-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;
}
-31
View File
@@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"];
export async function GET(request: Request) {
const url = new URL(request.url);
const role = url.searchParams.get("role") ?? "platform_admin";
const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin";
const origin = url.origin;
const response = NextResponse.redirect(new URL("/admin", origin));
const cookieOptions = {
path: "/",
maxAge: 60 * 60 * 24 * 30,
sameSite: "lax" as const,
};
response.cookies.set("dev_session", safeRole, {
...cookieOptions,
httpOnly: false,
});
response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, {
...cookieOptions,
httpOnly: false,
});
return response;
}
-47
View File
@@ -1,47 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const { email, password } = await request.json().catch(() => ({}));
if (!email || !password) {
return NextResponse.json({ error: "Email and password are required." }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
}
const authRes = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { "Content-Type": "application/json", apikey: supabaseAnonKey },
body: JSON.stringify({ email, password }),
});
const authData = await authRes.json().catch(() => null);
if (!authRes.ok || !authData?.access_token) {
const msg = authData?.error_description ?? authData?.error ?? "Invalid credentials.";
return NextResponse.json({ ok: false, error: msg }, { status: 401 });
}
const userId = authData.user?.id ?? authData.user_id;
if (!userId) {
return NextResponse.json({ ok: false, error: "Auth succeeded but user ID missing." }, { status: 500 });
}
// Set cookie + return JSON — client reads this and navigates
const isProd = process.env.NODE_ENV === "production";
const response = NextResponse.json({ ok: true });
response.cookies.set("rc_auth_uid", userId, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: isProd,
});
return response;
}
-12
View File
@@ -1,12 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST() {
const cookieStore = await cookies();
// Clear all auth cookies (new + legacy)
cookieStore.delete("rc_access_token");
cookieStore.delete("rc_uid");
cookieStore.delete("rc_auth_uid");
cookieStore.delete("rc_auth_token");
return NextResponse.json({ success: true });
}
-22
View File
@@ -1,22 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const { userId } = await request.json().catch(() => ({}));
if (!userId) {
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
}
const cookieStore = await cookies();
const isProd = process.env.NODE_ENV === "production";
cookieStore.set("rc_auth_uid", userId, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: isProd ? "strict" : "lax",
secure: isProd,
});
return NextResponse.json({ ok: true });
}
-43
View File
@@ -1,43 +0,0 @@
import { createClient } from "@supabase/supabase-js";
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
export async function GET() {
// Try creating supabase client — if it throws, capture the exact error
let status: string;
let canCreate = false;
let errMessage = "";
if (!url || !key) {
status = "MISSING_ENV_VARS";
errMessage = `url=${url ? "SET" : "MISSING"}, key=${key ? "SET" : "MISSING"}`;
} else {
try {
createClient(url, key);
canCreate = true;
status = "OK";
} catch (e: any) {
errMessage = e?.message ?? String(e);
status = "CREATE_CLIENT_FAILED";
}
}
const body = JSON.stringify({
status,
canCreate,
errMessage,
envVars: {
NODE_ENV: process.env.NODE_ENV,
urlSet: !!url,
urlPrefix: url ? url.substring(0, 30) : null,
keySet: !!key,
keyPrefix: key ? key.substring(0, 10) : null,
},
}, null, 2);
return new Response(body, {
status: status === "OK" ? 200 : 500,
headers: { "Content-Type": "application/json" },
});
}
-54
View File
@@ -1,54 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
export default function AuthCallback() {
const router = useRouter();
useEffect(() => {
const url = new URL(window.location.href);
// Supabase sends token via query params (not hash) on redirect
const accessToken = url.searchParams.get("token") || url.searchParams.get("access_token");
const type = url.searchParams.get("type");
const error = url.searchParams.get("error");
if (error) {
router.replace(`/login?error=${error}`);
return;
}
if (!accessToken) {
router.replace("/login?error=no_token");
return;
}
// Validate token by fetching user info from Supabase
fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/user`, {
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
Authorization: `Bearer ${accessToken}`,
},
})
.then(r => r.json())
.then(data => {
if (data?.id) {
// Set rc_auth_uid cookie via API route
return fetch("/api/set-auth-cookie", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: data.id }),
});
}
throw new Error("No user ID in response");
})
.then(() => router.replace("/admin"))
.catch(() => router.replace("/login?error=token_invalid"));
}, [router]);
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="text-zinc-400">Verifying reset link...</div>
</div>
);
}
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updatePasswordAction } from "@/actions/admin/password";
import { logUserActivity } from "@/actions/admin/audit";
export default function ChangePasswordForm({ userId }: { userId: string }) {
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setLoading(true);
const result = await updatePasswordAction(password);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
logUserActivity({
user_id: userId,
activity_type: "password_change",
details: {},
}).catch(() => {});
router.push("/admin");
router.refresh();
}
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
<svg
className="h-6 w-6 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
<p className="mt-1 text-sm text-zinc-500">
You must set a new password before continuing.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
New Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
placeholder="Min. 8 characters"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Confirm Password
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
placeholder="Repeat password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
>
{loading ? "Updating..." : "Update Password"}
</button>
</form>
</div>
</div>
</main>
);
}
+8 -37
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { updatePasswordAction } from "@/actions/admin/password";
@@ -12,22 +12,6 @@ export default function ChangePasswordPage() {
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [checkingSession, setCheckingSession] = useState(true);
const [userId, setUserId] = useState<string | null>(null);
useEffect(() => {
fetch("/api/auth/uid")
.then((r) => r.json())
.then((data) => {
if (!data.uid) {
router.push("/login");
} else {
setUserId(data.uid);
setCheckingSession(false);
}
})
.catch(() => router.push("/login"));
}, [router]);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -51,30 +35,17 @@ export default function ChangePasswordPage() {
return;
}
if (userId) {
logUserActivity({
user_id: userId,
activity_type: "password_change",
details: {},
});
}
// Audit log (best-effort — the password change itself was the source of truth)
logUserActivity({
user_id: result.userId ?? "unknown",
activity_type: "password_change",
details: {},
}).catch(() => {});
router.push("/admin");
router.refresh();
}
if (checkingSession) {
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 text-center">
<p className="text-zinc-500 text-sm">Checking session...</p>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
<div className="w-full max-w-md">
@@ -147,4 +118,4 @@ export default function ChangePasswordPage() {
</div>
</main>
);
}
}
-20
View File
@@ -1,20 +0,0 @@
"use client";
export default function DevLoginPage() {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 shadow-xl">
<h1 className="text-2xl font-bold text-zinc-100 mb-4">Dev Login</h1>
<p className="text-zinc-400 mb-6">Click below to login as platform admin:</p>
<form action="/api/dev-login" method="POST">
<button
type="submit"
className="w-full rounded-xl bg-emerald-600 px-6 py-4 text-base font-bold text-white hover:bg-emerald-500 transition-colors"
>
Login as Platform Admin
</button>
</form>
</div>
</div>
);
}
+168 -506
View File
@@ -1,546 +1,208 @@
"use client";
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
import { useState, useTransition } from "react";
import { signInWithGoogle, signInWithCredentials } from "@/actions/auth-actions";
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [globalError, setGlobalError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [forgotPassword, setForgotPassword] = useState(false);
const [forgotEmail, setForgotEmail] = useState("");
const [forgotSent, setForgotSent] = useState(false);
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
type LoginClientProps = {
hasGoogle: boolean;
hasCredentials: boolean;
/** Server-rendered error message, if any (from ?error=...) */
error: string | null;
/** Pre-fill the email in dev (for the seeded admin). */
seededEmail?: string;
/** Where to send the user after a successful sign-in. */
redirectTo?: string;
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setGlobalError(null);
if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
if (!password.trim()) { setGlobalError("Please enter your password."); return; }
setLoading(true);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), password }),
});
const data = await res.json().catch(() => ({ error: "Login failed" }));
if (res.ok && data?.ok) {
window.location.replace("/admin");
} else {
setGlobalError(data?.error || `Login failed (${res.status})`);
}
} catch {
setGlobalError("Network error. Please try again.");
} finally {
setLoading(false);
}
}, [email, password]);
const handleForgotPassword = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!forgotEmail.trim()) return;
setForgotLoading(true);
setForgotError(null);
const fd = new FormData();
fd.set("email", forgotEmail.trim());
const result = await fetch("/api/forgot-password", {
method: "POST",
body: fd,
}).then(r => r.json()).catch(() => ({ error: "Network error" }));
setForgotLoading(false);
if (result.error) {
setForgotError(result.error);
} else {
setForgotSent(true);
}
}, [forgotEmail]);
function GoogleSignIn({ hasGoogle }: { hasGoogle: boolean }) {
if (!hasGoogle) {
return (
<div className="rounded-xl border border-stone-200/80 bg-stone-50 p-4 text-sm text-stone-700">
<p className="font-medium">Google sign-in is not configured.</p>
<p className="mt-1 text-stone-600">
Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
<code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
environment to enable it.
</p>
</div>
);
}
return (
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
{/* Google Fonts */}
<form action={signInWithGoogle}>
<button
type="submit"
className="w-full flex items-center justify-center gap-3 rounded-xl bg-white border border-stone-200/80 px-6 py-3.5 text-sm font-semibold text-stone-800 hover:bg-stone-50 active:scale-[0.98] transition-all shadow-sm"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0012 23z" />
<path fill="#FBBC05" d="M5.84 14.1A6.6 6.6 0 015.5 12c0-.73.13-1.44.34-2.1V7.07H2.18A10.99 10.99 0 001 12c0 1.77.43 3.45 1.18 4.93l3.66-2.83z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.83C6.71 7.31 9.14 5.38 12 5.38z" />
</svg>
Continue with Google
</button>
</form>
);
}
function CredentialsForm({
seededEmail,
error,
}: {
seededEmail?: string;
error: string | null;
}) {
const [isPending, startTransition] = useTransition();
const [localError, setLocalError] = useState<string | null>(null);
return (
<form
action={(formData) => {
setLocalError(null);
startTransition(async () => {
try {
await signInWithCredentials(formData);
} catch (err) {
// Auth.js throws NEXT_REDIRECT on success — that's the normal
// flow. We only care about non-redirect errors here.
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes("NEXT_REDIRECT")) {
setLocalError("Sign-in failed. Please try again.");
}
}
});
}}
className="space-y-3"
>
<label className="block">
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Email
</span>
<input
name="email"
type="email"
required
autoComplete="username"
defaultValue={seededEmail ?? ""}
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
placeholder="you@example.com"
/>
</label>
<label className="block">
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Password
</span>
<input
name="password"
type="password"
required
autoComplete="current-password"
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
placeholder="••••••••"
/>
</label>
{(error || localError) && (
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
{error ?? localError}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
background: isPending
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
}}
>
{isPending ? "Signing in…" : "Sign in with email"}
</button>
</form>
);
}
function Divider() {
return (
<div className="flex items-center gap-3 my-5" aria-hidden="true">
<div className="flex-1 h-px bg-stone-200/80" />
<span className="text-xs uppercase tracking-wider text-stone-400" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
or
</span>
<div className="flex-1 h-px bg-stone-200/80" />
</div>
);
}
export default function LoginClient({
hasGoogle,
hasCredentials,
error,
seededEmail,
}: LoginClientProps) {
// Render the Google button first (or a setup message), then the divider,
// then the credentials form if dev login is enabled. The order is the
// most-common-first progression: prod users see Google; dev users
// see Google at top, email/password below as the fast path.
return (
<main
className="min-h-screen flex flex-col relative overflow-hidden"
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
>
<style jsx global>{`
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
html, body { overflow: hidden; }
`}</style>
{/* Organic background elements */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} />
</div>
{/* Header */}
<header className="w-full py-6 px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
Route Commerce
</span>
</Link>
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
Back to home
</Link>
</div>
</header>
{/* Login Card */}
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
<div className={`w-full max-w-sm transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"}`}>
{/* Card */}
<div className="w-full max-w-sm">
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
{/* Subtle top accent */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
<div className="p-8 sm:p-10">
{/* Logo & Title */}
<div className="text-center mb-8">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
<div
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
style={{
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
}}
>
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
<h1
className="text-3xl font-semibold text-stone-900"
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
>
Welcome back
</h1>
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
<p
className="mt-2 text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
>
Sign in to your account
</p>
</div>
{/* Auth.js v5 — primary sign-in: Google OAuth */}
<form action={signInWithGoogle} className="space-y-3">
<button
type="submit"
className="w-full inline-flex items-center justify-center gap-3 rounded-xl border border-stone-200/80 bg-white px-6 py-3.5 text-sm font-semibold text-stone-900 shadow-sm transition-all hover:bg-stone-50 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
aria-label="Sign in with Google"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"
/>
</svg>
<span>Sign in with Google</span>
</button>
</form>
{/* Dev login (only visible in development) */}
{process.env.NODE_ENV !== "production" && (
<form action={signInWithDev} className="space-y-3">
<div className="rounded-xl bg-amber-50/70 border border-amber-200/60 px-3 py-2 text-xs text-amber-900">
<strong>Dev login</strong> only available in development.
Set <code>ALLOW_DEV_LOGIN=false</code> in <code>.env.local</code> to hide.
</div>
<div className="grid grid-cols-2 gap-2">
<input
name="username"
type="text"
defaultValue="admin"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Username"
aria-label="Dev username"
/>
<input
name="password"
type="password"
defaultValue="dev"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Password"
aria-label="Dev password"
/>
</div>
<button
type="submit"
className="w-full rounded-xl bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
>
Dev sign in (no Google required)
</button>
</form>
)}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-stone-200/70" />
</div>
<div className="relative flex justify-center text-xs uppercase tracking-wider">
<span className="bg-white/0 px-2 text-stone-400" style={{ background: "linear-gradient(to right, transparent, white 30%, white 70%, transparent)" }}>
or sign in with email
</span>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
{globalError && (
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{globalError}
</div>
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
disabled={loading}
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
placeholder="you@company.com"
aria-required="true"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Password</label>
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 pr-12 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
placeholder="••••••••"
aria-required="true"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600 p-1 transition-colors"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
>
{loading ? (
<>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</>
) : "Sign in"}
</button>
{!forgotPassword && !forgotSent && (
<button
type="button"
onClick={() => { setForgotPassword(true); setGlobalError(null); }}
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
>
Forgot password?
</button>
)}
</form>
{/* Password Reset Form */}
{forgotPassword && !forgotSent && (
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4 border-t border-stone-200/50 pt-6" aria-label="Password reset form">
<p className="text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b6b6b" }}>Enter your email and we&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>
)}
<GoogleSignIn hasGoogle={hasGoogle} />
{hasCredentials && hasGoogle && <Divider />}
{hasCredentials && <CredentialsForm seededEmail={seededEmail} error={error} />}
</div>
{/* Security Trust Badges */}
<div className="border-t border-stone-100/50 px-8 py-5" style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-center gap-4 text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>256-bit SSL</span>
</div>
<span className="text-stone-300"></span>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span>SOC 2</span>
</div>
<span className="text-stone-300"></span>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#6b8f71"/>
</svg>
<span>Powered by Supabase</span>
</div>
</div>
</div>
</div>
</div>
{/* Back link */}
<div className="text-center mt-6">
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
View Farms
</Link>
</div>
</div>
</div>
{/* Footer */}
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
© {new Date().getFullYear()} Route Commerce
</span>
</div>
<nav className="flex items-center gap-6">
<Link href="/privacy-policy" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
Privacy
</Link>
<Link href="/terms-and-conditions" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
Terms
</Link>
</nav>
</div>
</footer>
</main>
);
}
// Demo mode wrapper
function DemoMode() {
return (
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
{/* Organic background elements */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
</div>
{/* Header */}
<header className="w-full py-6 px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
Route Commerce
</span>
</Link>
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
Back to home
</Link>
</div>
</header>
{/* Demo Card */}
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
<div className="w-full max-w-sm">
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden p-10">
<div className="text-center mb-8">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
Demo Mode
</h1>
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
Select a role to explore the platform
</p>
</div>
<div className="flex flex-col gap-3">
<button
onClick={() => { document.cookie = "dev_session=platform_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
>
Platform Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=brand_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#c97a3e" }}
>
Brand Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=store_employee; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#6b8f71" }}
>
Store Employee
</button>
</div>
</div>
{/* Back link */}
<div className="text-center mt-6">
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
View Farms
</Link>
</div>
</div>
</div>
{/* Footer */}
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
© {new Date().getFullYear()} Route Commerce
</span>
</div>
</div>
</footer>
</main>
);
}
// Inner component that uses useSearchParams - must be wrapped in Suspense
function LoginPageInner() {
const searchParams = useSearchParams();
const isDemo = searchParams.get("demo") === "1";
if (isDemo) return <DemoMode />;
return <LoginForm />;
}
export default function LoginClient() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-emerald-600 to-emerald-500 animate-pulse" />
<p className="text-stone-500">Loading...</p>
</div>
</div>
}>
<LoginPageInner />
</Suspense>
);
}
+30 -3
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import LoginClient from "./LoginClient";
import { isDevLoginEnabled } from "@/auth.config";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
@@ -21,6 +22,32 @@ export const metadata: Metadata = {
},
};
export default function LoginPage() {
return <LoginClient />;
}
type SearchParams = { error?: string; redirect?: string };
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
// The Google provider is only added to the Auth.js config when these
// two env vars are set. Pass the flag down so the client can hide the
// button (and surface a helpful message) when Google is unavailable.
const hasGoogle = !!(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET);
const hasCredentials = isDevLoginEnabled();
const params = await searchParams;
const error =
params?.error === "CredentialsSignin" || params?.error === "MissingCredentials"
? "Invalid email or password."
: params?.error
? "Sign-in failed. Please try again."
: null;
return (
<LoginClient
hasGoogle={hasGoogle}
hasCredentials={hasCredentials}
error={error}
seededEmail={hasCredentials ? "admin@route-commerce.local" : undefined}
redirectTo={params?.redirect ?? "/admin"}
/>
);
}
-105
View File
@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function LoginPage2() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (!email.trim()) { setError("Email is required."); return; }
if (!password.trim()) { setError("Password is required."); return; }
setLoading(true);
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), password }),
});
setLoading(false);
if (res.status === 303) {
window.location.href = "/admin";
} else {
const data = await res.json().catch(() => ({ error: "Login failed" }));
setError(data.error || "Login failed.");
}
}
return (
<main className="min-h-screen bg-slate-100 px-6 py-12">
<div className="mx-auto max-w-md">
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200">
<h1 className="text-2xl font-bold text-slate-900">Admin Login</h1>
<p className="mt-1 text-sm text-slate-500">Sign in to your account.</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div role="alert" className="rounded-xl bg-red-50 p-4 text-sm text-red-700 border border-red-100">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
disabled={loading}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
placeholder="admin@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50 flex items-center justify-center gap-3"
>
{loading ? (
<>
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</>
) : "Sign In"}
</button>
</form>
</div>
<Link href="/" className="mt-4 block text-sm text-slate-500 hover:text-slate-700">
Back to storefront
</Link>
</div>
</main>
);
}
+7 -39
View File
@@ -1,41 +1,9 @@
"use client";
// Server-side logout. Signs the user out of the Auth.js v5 session and
// redirects to /login. The previous client-side implementation (which
// called Supabase auth.signOut) was replaced with this so logout goes
// through the same auth path the rest of the app uses.
import { signOut } from "@/lib/auth";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
export default function LogoutPage() {
const router = useRouter();
useEffect(() => {
// Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
document.cookie = "dev_session=;path=/;max-age=0";
document.cookie = "rc_auth_uid=;path=/;max-age=0";
document.cookie = "rc_auth_token=;path=/;max-age=0";
// Clear shopping cart on logout
localStorage.removeItem("route_commerce_cart");
localStorage.removeItem("route_commerce_stop");
// Sign out from Supabase and clear server cart
supabase.auth.getUser().then(async ({ data }) => {
if (data.user?.id) {
const { clearServerCart } = await import("@/actions/checkout");
clearServerCart(data.user.id).catch(() => {});
}
supabase.auth.signOut().then(() => {
router.push("/login");
router.refresh();
});
});
}, [router]);
return (
<main className="min-h-screen bg-slate-100 px-6 py-12">
<div className="mx-auto max-w-md">
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center">
<p className="text-slate-500">Signing out...</p>
</div>
</div>
</main>
);
export default async function LogoutPage() {
await signOut({ redirectTo: "/login" });
}
+67 -64
View File
@@ -1,105 +1,108 @@
import type { NextAuthConfig } from "next-auth";
import type { NextAuthConfig, DefaultSession } from "next-auth";
import Google from "next-auth/providers/google";
/**
* Edge-compatible Auth.js v5 configuration.
* Edge-safe Auth.js v5 configuration.
*
* This file is imported by `src/middleware.ts`, which runs in the Edge runtime.
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
* This file is imported by `src/middleware.ts`, which runs in the Edge
* runtime. It must NOT import:
* - `pg` / `@auth/pg-adapter` (Node-only)
* - `next-auth/providers/credentials` (uses Node `crypto` internally)
* - The Drizzle client
* - Anything else that touches the database or Node-only APIs
*
* If you need to add a provider that uses Node-only APIs (e.g. an adapter
* implementation), define it in `src/lib/auth.ts` instead and add a thin
* placeholder here so the middleware can still reference it.
* Provider definitions, callbacks, and pages all live here. The full
* server-side handler in `src/lib/auth.ts` extends this with the
* Credentials provider (Node-only) for email + password sign-in.
*
* Both instances share the same session cookie, so the middleware can
* read JWTs minted by the server-side handler.
*/
const isDev = process.env.NODE_ENV !== "production";
const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}
const hasGoogleCreds = !!(
process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
);
const googleProvider = hasGoogleCreds
? [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
]
: [];
export const authConfig = {
// Custom sign-in page (must exist at /login)
pages: {
signIn: "/login",
},
// Trust the host header in dev for callback URLs
trustHost: true,
// Providers — referenced from middleware edge runtime.
// The Google provider only needs the env vars at runtime; it does not pull
// in any Node-only code. The dev Credentials provider is added in
// `src/lib/auth.ts` (server-side only) — it's not safe to import
// `next-auth/providers/credentials` from the edge runtime.
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
clientSecret:
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
// No `authorization` override — we want the default scopes (openid email profile)
}),
],
// New users are persisted in the database (handled in src/lib/auth.ts)
// Default to JWT here so middleware can run in edge runtime; the full
// server-side handler in src/lib/auth.ts switches this to "database".
session: { strategy: "jwt" },
pages: { signIn: "/login" },
session: { strategy: "jwt" as const },
providers: googleProvider,
callbacks: {
/**
* Gate /admin routes. Anything not on the public list and not signed in
* gets redirected to /login. This mirrors what the page-level checks do,
* but runs first at the edge so unauthorized requests never hit the
* server component tree.
* Gate /admin, /wholesale, and /protected-example. Anything on those
* paths and not signed in is redirected to /login (the default
* pages.signIn). Public storefronts and the homepage pass through.
*
* The actual role-based gating still happens in `getAdminUser()` —
* this callback only ensures the user is *signed in*.
*/
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnWholesale = nextUrl.pathname.startsWith("/wholesale");
const isOnProtectedExample = nextUrl.pathname.startsWith(
"/protected-example"
);
if (isOnAdmin) {
if (isLoggedIn) return true;
return false; // Redirect to /login
if (isOnAdmin || isOnWholesale || isOnProtectedExample) {
return isLoggedIn;
}
if (isOnProtectedExample) {
if (isLoggedIn) return true;
return false;
}
return true;
},
/**
* Forward the user id from the database user record into the JWT on
* initial sign-in. With database sessions this is what populates
* `session.user.id` for downstream server actions.
* Forward the user id into the JWT on initial sign-in. With
* `session.strategy: "jwt"` this is the field downstream code reads
* via `session.user.id`.
*/
async jwt({ token, user }) {
if (user) {
token.id = (user as { id?: string }).id ?? token.sub;
if (user.id) token.id = user.id;
if (user.email) token.email = user.email;
}
return token;
},
async session({ session, token }) {
if (session.user && token?.sub) {
(session.user as { id?: string }).id = token.sub;
if (session.user) {
session.user.id =
(typeof token.id === "string" && token.id) ||
(typeof token.sub === "string" && token.sub) ||
"";
}
return session;
},
},
// Cookie config — keep default names so legacy `rc_auth_uid` consumers
// continue to work until they're migrated. New Auth.js cookies default to
// `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod).
} satisfies NextAuthConfig;
/**
* Helper: are we in development AND allowed to use the dev credentials
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
* include the provider in its provider list.
* Is the dev Credentials provider enabled?
* - Disabled in production (NODE_ENV === "production")
* - Can be force-disabled by setting `ALLOW_DEV_LOGIN=false`
*
* Surfaced so `src/lib/auth.ts` can decide whether to include the
* Credentials provider in its providers list.
*/
export function isDevLoginEnabled(): boolean {
return isDev && allowDevLogin;
return (
process.env.NODE_ENV !== "production" &&
process.env.ALLOW_DEV_LOGIN !== "false"
);
}
+2 -5
View File
@@ -9,7 +9,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { supabase } from "@/lib/supabase";
import { signOutAction } from "@/actions/auth-actions";
type AdminHeaderProps = {
userRole?: string | null;
@@ -89,10 +89,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
router.push("/login");
router.refresh();
await signOutAction();
}
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
+9 -32
View File
@@ -4,8 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import BrandSelector from "./BrandSelector";
import { signOutAction } from "@/actions/auth-actions";
// Elegant warm sidebar design
// Colors: parchment 100 bg, soft linen text, powder petal accent
@@ -220,10 +219,15 @@ type SidebarProps = {
userRole?: string | null;
brandIds?: string[];
activeBrandId?: string | null;
brands?: Array<{ id: string; name: string; slug: string; logo_url: string | null }>;
brands?: { id: string; name: string; slug: string; logo_url: string | null }[];
};
export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands }: SidebarProps) {
export default function AdminSidebar({
userRole,
brandIds,
activeBrandId,
brands,
}: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
@@ -234,15 +238,9 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "multi_brand_admin" ? "Multi-Brand Manager"
: userRole === "store_employee" ? "Store Employee"
: null;
const isMultiBrandAdmin = userRole === "multi_brand_admin";
const showAllBrandsOption = userRole === "platform_admin";
const showBrandSelector =
brands && (showAllBrandsOption || (brandIds && brandIds.length >= 2));
const isActive = useCallback((href: string) => {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
@@ -306,10 +304,7 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
}, [router, mobileOpen, closeMobileMenu]);
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
router.push("/login");
router.refresh();
await signOutAction();
}
return (
@@ -404,24 +399,6 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
</Link>
</div>
{/* Brand selector (multi-brand admins + platform_admin) */}
{showBrandSelector && (
<div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
<p
className="text-[10px] font-semibold uppercase tracking-widest mb-1.5 px-1"
style={{ color: "rgba(195, 195, 193, 0.6)" }}
>
Active Brand
</p>
<BrandSelector
brands={brands!}
activeBrandId={activeBrandId ?? null}
showAllBrandsOption={showAllBrandsOption}
isMultiBrandAdmin={isMultiBrandAdmin}
/>
</div>
)}
{/* Nav links with keyboard navigation */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
<ul className="space-y-1" role="list">
@@ -22,7 +22,7 @@ type StopProductAssignmentProps = {
stopId: string;
allProducts: Product[];
assignedProducts: AssignedProduct[];
callerUid: string;
callerUid: string | null;
};
type Filter = "all" | "available" | "assigned";
-10
View File
@@ -1,10 +0,0 @@
// Auth Components for Clerk
import { UserButton } from "@clerk/nextjs";
export default function ClerkComponents() {
return (
<div className="flex items-center gap-4">
<UserButton />
</div>
);
}
@@ -1,10 +0,0 @@
// Clerk Authentication Provider
import { ClerkProvider } from "@clerk/nextjs";
export default function ClerkAuthProvider({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
{children}
</ClerkProvider>
);
}
+56 -11
View File
@@ -1,19 +1,48 @@
// Shared AdminUser type — safe to import from both server and client components
//
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
// `brand_ids` is the full list of brands the admin can act in.
// - platform_admin: `brand_id = null`, `brand_ids = []` (in dev) or all brands
// (resolved by `listBrandsForAdmin`).
// - multi_brand_admin: `brand_id` = selected/cookie brand, `brand_ids` = 2+.
// - brand_admin / store_employee / staff: `brand_id` = their single brand,
// `brand_ids = [that one]`.
// Shared AdminUser type — safe to import from both server and client
// components. The shape mirrors what `getAdminUser()` returns and
// includes both the user's role and the tenant they belong to.
export type AdminRole = "platform_admin" | "brand_admin" | "store_employee";
export type AdminUser = {
id?: string;
/** user.id from the `users` table — or "dev" for dev_session cookies */
id: string;
/** user_id (same as id) — kept for legacy callers */
user_id: string;
/** email from the `users` table, or null for dev shims */
email: string | null;
/** display name */
display_name: string | null;
/** tenant id from `tenant_users`, or null for platform_admin */
tenant_id: string | null;
/**
* @deprecated Use `tenant_id` instead. Kept for backward compat with
* call sites that haven't been migrated yet. Always mirrors
* `tenant_id`; will be removed in a later cleanup pass.
*/
brand_id: string | null;
/**
* @deprecated Use `tenant_id` instead. Kept for backward compat with
* multi-brand admin code (`AdminSidebar`, `setActiveBrand`, etc.).
* In our schema an admin belongs to at most one tenant, so this is
* always `[]` for platform_admin or `[tenant_id]` for everyone else.
* Will be removed when the multi-brand admin UI is re-thought.
*/
brand_ids: string[];
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
/** tenant slug (for storefronts) */
tenant_slug: string | null;
/** role within the tenant (or platform-wide for platform_admin) */
role: AdminRole;
/** is the user active? */
active: boolean;
/** auth provider */
auth_provider: "dev" | "google" | "email" | null;
// ── Permission flags ────────────────────────────────────────────
// Derived from the role, but exposed as individual booleans so
// existing consumer code (forms, sidebar, etc.) can read them
// directly without doing role math. See `permissionsForRole()` in
// admin-permissions.ts for the source of truth.
can_manage_products: boolean;
can_manage_stops: boolean;
can_manage_orders: boolean;
@@ -24,5 +53,21 @@ export type AdminUser = {
can_manage_water_log: boolean;
can_manage_reports: boolean;
can_manage_settings: boolean;
can_manage_billing: boolean;
can_manage_branding: boolean;
can_manage_marketing: boolean;
can_manage_team: boolean;
/** must the user change their password? (legacy; unused) */
must_change_password?: boolean;
};
export type TenantContext = {
tenant: {
id: string;
name: string;
slug: string;
status: string;
};
user: AdminUser;
};
+197 -157
View File
@@ -1,185 +1,225 @@
import { cookies } from "next/headers";
import type { AdminUser } from "./admin-permissions-types";
export type { AdminUser } from "./admin-permissions-types";
import "server-only";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { withPlatformAdmin } from "@/db/client";
import { users, tenants, tenantUsers } from "@/db/schema";
import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permissions-types";
/**
* Returns the current admin user, or `null` if not authenticated.
* Source of truth for the current admin user.
*
* Resolution order:
* 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev.
* 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee).
* 3. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids.
* Looks up the Auth.js v5 session, then resolves the user + tenant
* from the `users` and `tenant_users` tables.
*
* `brand_id` is the active brand; `brand_ids` is the full membership list.
* For dev sessions without a real DB, `brand_ids` is populated by:
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
* - store_employee: `[<first real brand>]` if a brand exists, else `[]`
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev)
* Returns `null` if:
* - No Auth.js session (caller not signed in)
* - The session email doesn't match any `users.email`
* - The user has no `tenant_users` row (not provisioned yet)
*
* Provisioning: an admin must run
* INSERT INTO users (email, ...) VALUES (...)
* INSERT INTO tenant_users (tenant_id, user_id, role) VALUES (...)
* to grant a Google-sign-in user admin access. Until provisioned, the
* layout shows "Access Denied" — correct behavior.
*
* The previous `dev_session` cookie bypass has been removed. The only
* way into the admin is through real Auth.js (Google in production;
* for local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
*/
export async function getAdminUser(): Promise<AdminUser | null> {
const cookieStore = await cookies();
// ── Mock data mode for UI review ─────────────────────────────────
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
return buildDevAdmin("platform_admin");
}
// ── Dev session bypass (enabled for testing on all envs) ──────────────
const dev = cookieStore.get("dev_session")?.value;
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") {
return buildDevAdmin(dev);
}
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
if (!uid) return null;
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
return null;
}
// Lookup admin_users by Supabase auth user id
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let adminUsers: unknown[] = [];
let sessionEmail: string | null = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
if (res.ok) {
const data = await res.json().catch(() => []);
adminUsers = Array.isArray(data) ? data : [];
}
} catch (e) {
// fetch failed silently
}
// First login — auto-create platform_admin via SECURITY DEFINER RPC
if (adminUsers.length === 0) {
// Check if uid is a valid UUID before trying to insert
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) return null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
{
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({ p_user_id: uid }),
}
);
if (res.ok) {
const inserted = await res.json().catch(() => null);
if (inserted && inserted.length > 0) {
return buildAdminUser(inserted[0] as Record<string, unknown>, []);
}
}
} catch (e) {
// RPC failed silently
}
const session = await auth();
sessionEmail = session?.user?.email ?? null;
} catch (err) {
console.error("[admin-permissions] auth() failed:", err);
return null;
}
const admin = adminUsers[0] as Record<string, unknown>;
if (!admin.active) return null;
if (!sessionEmail) return null;
// Load brand_ids from the admin_user_brands junction
const brandIds = await fetchAdminUserBrandIds(supabaseUrl, serviceKey, admin.id as string);
return await withPlatformAdmin(async (db) => {
const userRows = await db
.select()
.from(users)
.where(eq(users.email, sessionEmail))
.limit(1);
const user = userRows[0];
if (!user) return null;
return buildAdminUser(admin, brandIds);
const membershipRows = await db
.select({
tenantId: tenants.id,
tenantName: tenants.name,
tenantSlug: tenants.slug,
tenantStatus: tenants.status,
role: tenantUsers.role,
})
.from(tenantUsers)
.innerJoin(tenants, eq(tenants.id, tenantUsers.tenantId))
.where(eq(tenantUsers.userId, user.id))
.limit(1);
if (membershipRows.length === 0) {
// Signed in but not provisioned for any tenant.
return null;
}
const m = membershipRows[0];
const role = m.role as AdminRole;
return buildAdminUser({
id: user.id,
email: user.email,
displayName: user.name,
authProvider: user.authProvider,
tenantId: m.tenantId,
tenantSlug: m.tenantSlug,
tenantName: m.tenantName,
role,
active: true,
});
});
}
/**
* Load `brand_ids` from the admin_user_brands junction for the given admin row.
* Returns an empty array on any failure (e.g. before migration 207 is applied).
* Resolves the current admin user AND their tenant. Returns `null` if
* the user is not signed in or has no tenant. For platform_admin (no
* tenant), `tenant` is `null` and callers should use `withPlatformAdmin`
* to query across all tenants.
*/
async function fetchAdminUserBrandIds(
supabaseUrl: string,
serviceKey: string,
adminRowId: string
): Promise<string[]> {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_user_brands?admin_user_id=eq.${adminRowId}&select=brand_id`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
if (!res.ok) return [];
const data = await res.json().catch(() => []);
if (!Array.isArray(data)) return [];
return data
.map((row: Record<string, unknown>) => row.brand_id as string)
.filter((id): id is string => typeof id === "string");
} catch {
return [];
export async function getCurrentTenant(): Promise<TenantContext | null> {
const user = await getAdminUser();
if (!user) return null;
if (!user.tenant_id) {
// platform_admin — no specific tenant
return null;
}
return {
user,
tenant: {
id: user.tenant_id,
name: user.display_name ?? user.tenant_slug ?? "Unknown",
slug: user.tenant_slug ?? "unknown",
status: "active",
},
};
}
function buildDevAdmin(role: string): AdminUser {
// For dev sessions we don't have an admin_user_brands junction row to load.
// - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands).
// - store_employee: `brand_ids = []` (dev AdminAccessDenied is acceptable;
// this is the documented limitation — re-read spec section on getAdminUser
// step 1. We skip the spec's "fetch first real brand" complexity here in
// favour of keeping dev session cheap and DB-independent).
// - brand_admin: `brand_ids = []` (same rationale).
// `role` is narrowed to the strict union — we know the dev callers pass
// only valid values.
const base = {
// ────────────────────────────────────────────────────────────────────────
// Re-exports for backward compat
// ────────────────────────────────────────────────────────────────────────
export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permissions-types";
/**
* @deprecated Kept for unit tests that exercise the dev shim path.
* Production code should never call this — `getAdminUser()` only reads
* the Auth.js session now.
*/
export function buildDevAdmin(role: AdminRole): AdminUser {
const isPlatform = role === "platform_admin";
const tenantId = isPlatform ? null : "dev-tenant";
return {
id: "dev",
user_id: "dev",
brand_id: null,
brand_ids: [] as string[],
role: role as AdminUser["role"],
email: null,
display_name: "Demo Admin",
tenant_id: tenantId,
brand_id: tenantId, // legacy alias
brand_ids: tenantId ? [tenantId] : [], // legacy array alias
tenant_slug: isPlatform ? null : "tuxedo",
role,
active: true,
auth_provider: "dev",
...permissionsForRole(role),
must_change_password: false,
};
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
}
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
}
function buildAdminUser(r: Record<string, unknown>, brandIds: string[]): AdminUser {
// The DB column is TEXT (per CLAUDE.md) so the runtime value is a string.
// We narrow it to the known union here. If the DB has an unknown role
// (e.g. a future role), the migration's CHECK constraint will reject it
// before it ever reaches this function.
const role = r.role as AdminUser["role"];
// `brand_id` is the *legacy* single-brand column — preserved here as-is.
// The canonical "active brand" is resolved by `getActiveBrandId` on each
// page/action, which considers URL params, the active_brand_id cookie,
// and this legacy fallback. Setting `brand_id` here to a sensible default
// (legacy → first of brand_ids) keeps the AdminUser shape useful even
// for callers that haven't migrated to `getActiveBrandId` yet.
const legacyBrandId = (r.brand_id as string | null) ?? null;
const base = {
id: r.id as string,
user_id: r.user_id as string,
brand_id: legacyBrandId ?? brandIds[0] ?? null,
brand_ids: brandIds,
role,
active: r.active as boolean,
must_change_password: Boolean(r.must_change_password),
function buildAdminUser(input: {
id: string;
email: string | null;
displayName: string | null;
authProvider: "dev" | "google" | "email" | null;
tenantId: string;
tenantSlug: string;
tenantName: string;
role: AdminRole;
active: boolean;
}): AdminUser {
return {
id: input.id,
user_id: input.id,
email: input.email,
display_name: input.displayName,
tenant_id: input.tenantId,
brand_id: input.tenantId, // legacy alias
brand_ids: [input.tenantId], // legacy array alias
tenant_slug: input.tenantSlug,
role: input.role,
active: input.active,
auth_provider: input.authProvider,
...permissionsForRole(input.role),
};
}
/**
* Single source of truth for "what can a role do". Used by both the
* dev shim and the real user lookup so the demo and the real thing
* behave identically.
*/
export function permissionsForRole(role: AdminRole) {
if (role === "platform_admin") {
return {
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
can_manage_billing: true,
can_manage_branding: true,
can_manage_marketing: true,
can_manage_team: true,
};
}
if (role === "brand_admin") {
return {
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: false,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
can_manage_billing: true,
can_manage_branding: true,
can_manage_marketing: true,
can_manage_team: true,
};
}
// store_employee
return {
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
can_manage_settings: false,
can_manage_billing: false,
can_manage_branding: false,
can_manage_marketing: false,
can_manage_team: false,
};
if (role === "platform_admin") {
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
}
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
}
return { ...base, can_manage_products: Boolean(r.can_manage_products), can_manage_stops: Boolean(r.can_manage_stops),
can_manage_orders: Boolean(r.can_manage_orders), can_manage_pickup: Boolean(r.can_manage_pickup),
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
}
+73 -120
View File
@@ -1,136 +1,89 @@
import NextAuth from "next-auth";
import PostgresAdapter from "@auth/pg-adapter";
import { Pool } from "pg";
import Credentials from "next-auth/providers/credentials";
import {
authConfig,
isDevLoginEnabled,
} from "@/auth.config";
import "server-only";
/**
* Build the dev Credentials provider. Lives here (Node-only) because
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
* that the middleware uses.
* Auth.js (NextAuth v5) — server-side configuration.
*
* This file is Node-only. It is imported by:
* - `src/app/api/auth/[...nextauth]/route.ts` (the OAuth + credentials handlers)
* - Server actions that call `signIn` / `signOut`
* - `src/lib/admin-permissions.ts` (reads `auth()` for the current user)
*
* The middleware imports a separate, edge-safe instance built from
* `src/auth.config.ts`. Both instances share the same JWT cookie, so the
* middleware can read sessions minted here.
*
* Providers:
* - Google OAuth — active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set.
* - Email + password (Credentials) — active in dev only; backed by the
* `users.password_hash` column. In production, set ALLOW_DEV_LOGIN=false
* (the default) and the provider is omitted entirely.
*
* For local dev, run `npm run db:seed` to create the seeded admin user
* (`admin@route-commerce.local` / `admin`). The `authorize` function
* looks up the user by email, verifies the password against the stored
* hash, and returns the real user record. No `dev_session` cookie
* bypass; this is real Auth.js sign-in.
*/
function buildDevCredentialsProvider() {
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { eq } from "drizzle-orm";
import { authConfig, isDevLoginEnabled } from "@/auth.config";
import { withDb } from "@/db/client";
import { users } from "@/db/schema";
import { verifyPassword } from "@/lib/passwords";
function buildCredentialsProvider() {
return Credentials({
id: "dev-login",
name: "Dev login",
id: "credentials",
name: "Email + password",
credentials: {
username: { label: "Username", type: "text" },
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
/**
* Returns the user on success, or `null` on any failure. Auth.js
* never throws from `authorize` — a throw is treated as a 500.
*/
async authorize(creds) {
if (!isDevLoginEnabled()) return null;
// Any non-empty username/password combo is accepted; this is purely a
// local convenience for smoke testing without Google OAuth.
const username = String(creds?.username ?? "").trim();
const email = String(creds?.email ?? "").trim().toLowerCase();
const password = String(creds?.password ?? "");
if (!username || !password) return null;
if (!email || !password) return null;
return {
id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
name: username,
email: `${username}@dev.local`,
// Custom field surfaced via `jwt` callback if needed
devRole: "platform_admin",
} as unknown as { id: string; name: string; email: string };
},
});
}
/**
* Shared Postgres pool for Auth.js. Reuses the same database the rest of
* the app talks to (via `pg`). Lives behind a module-level singleton so
* Next.js hot reload doesn't open a new pool on every request.
*
* Note: in production, `DATABASE_URL` should be the only DB env var. The
* Supabase project URL / service role key are no longer required for auth
* (they are still used elsewhere until the rest of the app is migrated off
* the @supabase client — see CLAUDE.md).
*/
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
function getPool(): Pool {
if (globalForPool.__pgPool) return globalForPool.__pgPool;
const connectionString =
process.env.DATABASE_URL ??
process.env.SUPABASE_DB_URL ??
process.env.POSTGRES_URL;
if (!connectionString) {
// Don't throw at module load — let route handlers return a clean 500
// if env is missing. The smoke test instructions tell the user to
// set DATABASE_URL.
// eslint-disable-next-line no-console
console.warn(
"[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up."
);
}
const pool = new Pool({
connectionString,
// Reasonable defaults; override via connection string if you need more
max: 10,
idleTimeoutMillis: 30_000,
});
globalForPool.__pgPool = pool;
return pool;
}
/**
* Final server-side Auth.js config.
*
* Builds on `authConfig` (edge-safe) and layers on:
* 1. The Postgres database adapter
* 2. The dev Credentials provider (only in development)
*
* Note: when using a database adapter the session strategy is fixed to
* "database" — Auth.js will persist sessions in the `sessions` table.
*/
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
// Use JWT sessions to match the edge-friendly config in `authConfig`.
// The middleware (running on the edge) cannot reach the database, so it
// must use JWT. The Postgres adapter is still wired up so that user
// records are created/updated when a new OAuth sign-in happens — but
// the session itself is stored in the cookie as an encrypted JWT.
adapter: PostgresAdapter(getPool()),
// `session.strategy` is inherited from `authConfig` ("jwt")
providers: [
// Re-declare the providers from authConfig and append the dev
// credentials provider if dev login is enabled. (NextAuth merges by
// provider id, so this overrides the edge stubs.)
...authConfig.providers,
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
],
events: {
/**
* First-time sign-in: auto-create a `platform_admin` row in
* `admin_users` keyed to this auth.js user id, mirroring the legacy
* `rc_auth_uid` flow. This is the seam between the new auth layer
* and the existing admin authorization model.
*/
async signIn({ user }) {
try {
const pool = getPool();
const userId = user.id;
if (!userId) return;
// Fire and forget — don't block sign-in on a missing admin_users row.
await pool.query(
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
[userId]
);
// Note: we don't auto-create here; the existing `getAdminUser()`
// in `src/lib/admin-permissions.ts` is the source of truth for
// role lookups and is unchanged. After this migration the user
// is authenticated; the existing `dev_session` demo path still
// works for the smoke test.
} catch (e) {
// The `users` table is global (not tenant-scoped), so we use
// `withDb` rather than `withTenant` — no GUC to set.
const u = await withDb(async (db) => {
const rows = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
return rows[0] ?? null;
});
if (!u || !u.passwordHash) return null;
if (!verifyPassword(password, u.passwordHash)) return null;
return {
id: u.id,
name: u.name ?? undefined,
email: u.email,
};
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[auth] signIn event error (non-fatal):", e);
console.error("[auth] credentials authorize failed:", err);
return null;
}
},
},
});
}
const providers = [
...authConfig.providers,
...(isDevLoginEnabled() ? [buildCredentialsProvider()] : []),
];
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers,
});
-18
View File
@@ -1,18 +0,0 @@
// Clerk Auth Helper Functions - Stub implementation
// Replace with actual Clerk auth implementation when Clerk is set up
export async function getClerkAuth() {
return { userId: null, sessionId: null };
}
export async function requireAuth() {
throw new Error("Unauthorized");
}
export function getUserId(): string | null {
return null;
}
export async function getSession() {
return { userId: null, sessionId: null };
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Shared Postgres connection pool.
*
* The app connects to Postgres directly via the `pg` driver — no Supabase
* platform, JS client, or REST gateway. Server actions and API routes
* import `pool` (or the typed `query` helper below) and call SECURITY
* DEFINER PL/pgSQL functions.
*
* Usage:
* import { pool, query } from "@/lib/db";
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
*
* Configuration:
* - DATABASE_URL (required) — full Postgres connection string. Same env var
* is used by `supabase/push-migrations.js` and any external migration
* tooling. Format: `postgres://user:pass@host:port/dbname`.
*
* Notes:
* - This module is server-only. It must never be imported from a Client
* Component. The `import "server-only"` line below makes Next.js fail
* the build if a client import is attempted.
* - The pool is created lazily on first use. If `DATABASE_URL` is missing
* at import time, the first query throws a clear error pointing at the
* missing env var. This keeps local builds (e.g. `next build` static
* analysis, lint) from failing just because the DB isn't configured.
* - SSL is enabled for non-localhost connections; `pg` reads `?sslmode=`
* from the URL automatically.
*/
import "server-only";
import { Pool, type PoolConfig, type QueryResult, type QueryResultRow } from "pg";
let _pool: Pool | null = null;
let _poolError: Error | null = null;
function buildPool(): Pool {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
);
}
const config: PoolConfig = {
connectionString,
// Conservative defaults for a serverless environment (Vercel, Lambda).
// Adjust via env vars if you need more headroom:
// PG_POOL_MAX (default 10)
// PG_POOL_IDLE_MS (default 30s)
// PG_POOL_CONN_TIMEOUT_MS (default 10s)
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
connectionTimeoutMillis: parseInt(
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
10,
),
// Vercel/serverless recycling: keep the pool hot for warm invocations.
allowExitOnIdle: false,
};
const pool = new Pool(config);
// Surface connection errors loudly. Without these handlers, `pg` swallows
// backend disconnects (e.g. idle TCP RSTs from Vercel's network) and the
// pool goes silently dead.
pool.on("error", (err) => {
console.error("[db] idle client error", err);
});
return pool;
}
/**
* The shared connection pool. Lazy-initialized; throws a clear error on
* first use if `DATABASE_URL` is not set.
*/
export function getPool(): Pool {
if (_pool) return _pool;
if (_poolError) throw _poolError;
try {
_pool = buildPool();
return _pool;
} catch (err) {
_poolError = err instanceof Error ? err : new Error(String(err));
throw _poolError;
}
}
/**
* Convenience alias matching the previous Supabase client shape so call
* sites read naturally: `pool.query(...)`. Lazy.
*/
export const pool = new Proxy({} as Pool, {
get(_target, prop, receiver) {
return Reflect.get(getPool(), prop, receiver);
},
});
/**
* Typed query helper. Use this everywhere a `SELECT` / simple `INSERT/UPDATE`
* is enough. For transactions or `LISTEN/NOTIFY`, use `getPool()` directly.
*
* Example:
* const { rows } = await query<AdminUserRow>(
* "SELECT * FROM admin_users WHERE user_id = $1 LIMIT 1",
* [uid]
* );
*/
export async function query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: ReadonlyArray<unknown>,
): Promise<QueryResult<T>> {
return getPool().query<T>(text, params as unknown[] | undefined);
}
/**
* Run `fn` inside a single transaction. Commits on success, rolls back on
* any thrown error. The provided client must be used for all queries inside
* `fn` to keep them on the same connection.
*
* Example:
* const result = await withTx(async (client) => {
* await client.query("INSERT INTO foo ...", [...]);
* const { rows } = await client.query<Bar>("SELECT ...", [...]);
* return rows[0];
* });
*/
export async function withTx<T>(
fn: (client: import("pg").PoolClient) => Promise<T>,
): Promise<T> {
const client = await getPool().connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
try {
await client.query("ROLLBACK");
} catch {
// ignore secondary rollback failure
}
throw err;
} finally {
client.release();
}
}
+67
View File
@@ -0,0 +1,67 @@
import "server-only";
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
/**
* Password hashing + verification.
*
* Format: `scrypt$N$salt$hash` (hex-encoded).
*
* - scrypt: algorithm identifier (future-proof — easy to migrate to
* argon2id by adding a new branch in `verifyPassword`)
* - N: scrypt cost parameter (CPU/memory). 2^14 (16384) is a
* reasonable default for an interactive login on modern
* hardware (~50ms per hash on a typical server)
* - salt: 16 random bytes, hex-encoded
* - hash: 64-byte derived key, hex-encoded
*
* Why scrypt and not bcrypt / argon2?
* - No extra dependency. Node's `crypto` module has it built in.
* - Argon2id is the modern recommendation but requires a native module
* (`argon2` or `@node-rs/argon2`) which complicates the install.
* When we add a native build step, we should switch to argon2id.
* - bcrypt is fine but not better than scrypt for our use case, and
* also requires a native module.
*
* All comparisons are constant-time (`timingSafeEqual`) to defeat
* timing-based side-channel attacks.
*/
const KEY_LEN = 64;
const SALT_LEN = 16;
const DEFAULT_N = 16384; // 2^14
const ALGO = "scrypt";
export function hashPassword(plain: string): string {
if (typeof plain !== "string" || plain.length === 0) {
throw new Error("hashPassword: password must be a non-empty string");
}
const salt = randomBytes(SALT_LEN).toString("hex");
const hash = scryptSync(plain, salt, KEY_LEN, { N: DEFAULT_N }).toString("hex");
return `${ALGO}$${DEFAULT_N}$${salt}$${hash}`;
}
export function verifyPassword(plain: string, stored: string): boolean {
if (typeof plain !== "string" || typeof stored !== "string") return false;
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== ALGO) return false;
const n = Number.parseInt(parts[1], 10);
if (!Number.isFinite(n) || n < 1024 || n > 1_000_000) return false;
const salt = parts[2];
const expectedHex = parts[3];
if (!salt || !expectedHex) return false;
let actual: Buffer;
let expected: Buffer;
try {
actual = scryptSync(plain, salt, KEY_LEN, { N: n });
expected = Buffer.from(expectedHex, "hex");
} catch {
return false;
}
if (actual.length !== expected.length) return false;
try {
return timingSafeEqual(actual, expected);
} catch {
return false;
}
}
+61
View File
@@ -0,0 +1,61 @@
// NextAuth v5 middleware
//
// Runs on every non-static request in the Edge runtime. It uses a
// lightweight NextAuth instance built from the edge-safe `authConfig` —
// NOT the full `src/lib/auth.ts` (which uses `pg` and is Node-only).
//
// Responsibilities:
// 1. Allow Auth.js to read/write its own session cookie
// 2. Protect /admin/*, /wholesale/*, /protected-example — redirect to
// /login if not authenticated
// 3. Redirect away from /login when the user already has a session
// 4. Add a handful of baseline security headers
//
// The legacy `dev_session` cookie bypass has been removed. The only way
// into the admin is through real Auth.js — Google in production, or the
// seeded Credentials provider in dev (see `src/lib/auth.ts`).
import NextAuth from "next-auth";
import { authConfig } from "@/auth.config";
import { NextResponse } from "next/server";
const { auth } = NextAuth(authConfig);
export default auth((req) => {
const { pathname } = req.nextUrl;
const isAuthed = !!req.auth;
const isAdmin = pathname.startsWith("/admin");
const isLogin = pathname === "/login";
if (isAdmin && !isAuthed) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("redirect", pathname);
return addSecurityHeaders(NextResponse.redirect(url));
}
if (isLogin && isAuthed) {
const url = req.nextUrl.clone();
url.pathname = "/admin";
return addSecurityHeaders(NextResponse.redirect(url));
}
return addSecurityHeaders(NextResponse.next());
});
function addSecurityHeaders(res: NextResponse): NextResponse {
res.headers.set("X-Content-Type-Options", "nosniff");
res.headers.set("X-Frame-Options", "DENY");
res.headers.set("X-XSS-Protection", "1; mode=block");
res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
return res;
}
export const config = {
matcher: [
// Skip Next.js internals and static files
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
],
};
+72 -15
View File
@@ -1,26 +1,83 @@
import NextAuth from "next-auth";
import { authConfig } from "@/auth.config";
import { NextResponse, type NextRequest } from "next/server";
/**
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16).
* This is the single source of truth for route protection. The legacy
* `src/middleware.ts` has been deleted (Next.js only runs one).
*
* Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`?
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that
* knows which routes need a session. We reuse it here at the edge.
* 2. It auto-populates `request.auth` with the session (JWT-decoded)
* for any server component/page that reads `auth()` later.
* Routing policy:
* 1. `/admin/*` with no auth cookie + `ALLOW_DEV_LOGIN !== "false"`
* → set `dev_session=platform_admin` and let the request through.
* This makes `/admin` "just work" in dev/demo without any login
* UI gymnastics.
* 2. `/login` with an auth cookie (any flavour)
* → redirect to `/admin` so an authenticated user never sees the
* login form.
* 3. `/admin/*` (or `/protected-example`) with no auth cookie
* → redirect to `/login`.
* 4. Everything else → continue.
*
* Public routes, admin gating, and the `auth` cookie are all configured
* in `src/auth.config.ts`.
* Auth-cookie flavours recognised:
* - `dev_session` (dev auto-login, see above)
* - `rc_auth_uid` / `rc_uid` (legacy /api/login flow)
* - `authjs.session-token` / `__Secure-authjs.session-token` (Auth.js v5)
*
* The proxy only checks cookie *presence*. The real auth check (JWT
* signature decryption, admin role lookup) happens in
* `getAdminUser()` server-side. The proxy is just routing.
*/
const { auth } = NextAuth(authConfig);
export default auth;
function isAuthenticated(request: NextRequest): boolean {
const dev = request.cookies.get("dev_session")?.value;
if (
dev === "platform_admin" ||
dev === "brand_admin" ||
dev === "store_employee"
) {
return true;
}
if (request.cookies.get("rc_auth_uid")?.value) return true;
if (request.cookies.get("rc_uid")?.value) return true;
if (request.cookies.get("authjs.session-token")?.value) return true;
if (request.cookies.get("__Secure-authjs.session-token")?.value) return true;
return false;
}
export default function proxy(request: NextRequest) {
const { nextUrl } = request;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnProtectedExample = nextUrl.pathname.startsWith(
"/protected-example"
);
const isOnLogin = nextUrl.pathname === "/login";
const authenticated = isAuthenticated(request);
// ── 1. Dev auto-login for /admin/* ───────────────────────────────
if (isOnAdmin && !authenticated) {
const allowDev = process.env.ALLOW_DEV_LOGIN !== "false";
if (allowDev) {
const response = NextResponse.next();
response.cookies.set("dev_session", "platform_admin", {
path: "/",
maxAge: 60 * 60 * 24, // 1 day
sameSite: "lax",
});
return response;
}
}
// ── 2. Bounce authenticated users away from /login ───────────────
if (isOnLogin && isAuthenticated(request)) {
return NextResponse.redirect(new URL("/admin", nextUrl));
}
// ── 3. Gate protected routes ─────────────────────────────────────
if ((isOnAdmin || isOnProtectedExample) && !authenticated) {
return NextResponse.redirect(new URL("/login", nextUrl));
}
// ── 4. Everything else: continue ─────────────────────────────────
return NextResponse.next();
}
export const config = {
// Run on /admin and the protected example, plus /login so the
// `authorized` callback can bounce already-signed-in users away from it.
matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
};
@@ -0,0 +1,214 @@
-- 204_admin_users_email_and_auth_subject.sql
--
-- Prepare `admin_users` for Auth.js v5 multi-provider sign-in.
--
-- Context:
-- The platform is migrating from a Supabase-only auth flow to Auth.js v5
-- with multiple providers (Google, Supabase password). With Auth.js, a
-- sign-in "subject" is the provider's stable user identifier:
-- - Supabase password: a UUID (matches `auth.users.id`).
-- - Google OAuth: an opaque string (the Google `sub` claim).
-- The existing `admin_users.user_id UUID` column can only hold Supabase
-- UUIDs, so Google sign-ins have nowhere to land. We also need the
-- `admin_users` row to carry an `email` directly so server-side lookups
-- no longer have to JOIN `auth.users` (which won't exist for Google users
-- anyway).
--
-- This migration:
-- 1. Adds `email`, `auth_provider`, `auth_subject` columns.
-- 2. Backfills `email` + `auth_provider` for existing Supabase rows.
-- 3. Adds a unique index on `(auth_provider, auth_subject)` so the same
-- Google subject can't be provisioned twice.
-- 4. Replaces `upsert_admin_user` with a version that accepts email +
-- provider + subject and can mint first-time Google sign-ins.
-- 5. Adds `get_admin_user_for_session` so the application layer can
-- resolve an Auth.js `session.user.id` to an admin row in one call
-- (no `auth.users` join required).
--
-- This is idempotent — re-running is safe.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Schema additions
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE public.admin_users
ADD COLUMN IF NOT EXISTS email TEXT,
ADD COLUMN IF NOT EXISTS auth_provider TEXT,
ADD COLUMN IF NOT EXISTS auth_subject TEXT;
-- Backfill: pull email + mark existing rows as Supabase-originated.
UPDATE public.admin_users au
SET
email = COALESCE(au.email, u.email),
auth_provider = COALESCE(au.auth_provider, 'supabase')
FROM auth.users u
WHERE au.user_id = u.id
AND (au.email IS NULL OR au.auth_provider IS NULL);
-- Backfill the remaining rows (no auth.users match) so the NOT NULL
-- constraint below doesn't reject them. These are likely orphaned test
-- rows; flag them with provider='supabase-orphan' for visibility.
UPDATE public.admin_users
SET
email = COALESCE(email, 'unknown-' || id::TEXT || '@orphan.local'),
auth_provider = COALESCE(auth_provider, 'supabase-orphan')
WHERE email IS NULL OR auth_provider IS NULL;
-- Enforce that every row carries an email and a provider. The lookup
-- RPCs rely on `email` being NOT NULL.
ALTER TABLE public.admin_users
ALTER COLUMN email SET NOT NULL,
ALTER COLUMN auth_provider SET NOT NULL;
-- CHECK: a row must have either a Supabase `user_id` (UUID) or an
-- `auth_subject` (Google etc.). This catches the case where a row is
-- accidentally inserted with neither.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'admin_users_subject_check'
) THEN
ALTER TABLE public.admin_users
ADD CONSTRAINT admin_users_subject_check
CHECK (user_id IS NOT NULL OR auth_subject IS NOT NULL);
END IF;
END $$;
-- Index: fast lookup by Auth.js session id (which is `auth_subject` for
-- Google, `user_id` for Supabase).
CREATE INDEX IF NOT EXISTS admin_users_user_id_idx
ON public.admin_users (user_id)
WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS admin_users_auth_subject_idx
ON public.admin_users (auth_provider, auth_subject)
WHERE auth_subject IS NOT NULL;
-- Unique: prevent the same Google subject from being provisioned twice.
CREATE UNIQUE INDEX IF NOT EXISTS admin_users_auth_subject_unique
ON public.admin_users (auth_provider, auth_subject)
WHERE auth_subject IS NOT NULL;
-- Index: fast lookup by email (used for admin invites + future "sign in
-- with email" flows).
CREATE INDEX IF NOT EXISTS admin_users_email_idx
ON public.admin_users (email);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. upsert_admin_user — replace with multi-provider version
-- ═══════════════════════════════════════════════════════════════════════════
-- Drop the old single-arg version if it exists (Supabase may have
-- auto-generated it). Idempotent.
DROP FUNCTION IF EXISTS public.upsert_admin_user(UUID);
CREATE OR REPLACE FUNCTION public.upsert_admin_user(
p_user_id UUID DEFAULT NULL,
p_email TEXT DEFAULT NULL,
p_auth_provider TEXT DEFAULT 'supabase',
p_auth_subject TEXT DEFAULT NULL
)
RETURNS public.admin_users
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_existing public.admin_users;
v_inserted public.admin_users;
BEGIN
-- 1. Find an existing row by the strongest signal we have.
IF p_auth_subject IS NOT NULL AND p_auth_provider <> 'supabase' THEN
SELECT * INTO v_existing
FROM public.admin_users
WHERE auth_provider = p_auth_provider
AND auth_subject = p_auth_subject
LIMIT 1;
ELSIF p_user_id IS NOT NULL THEN
SELECT * INTO v_existing
FROM public.admin_users
WHERE user_id = p_user_id
LIMIT 1;
ELSIF p_email IS NOT NULL THEN
SELECT * INTO v_existing
FROM public.admin_users
WHERE lower(email) = lower(p_email)
LIMIT 1;
END IF;
IF v_existing.id IS NOT NULL THEN
-- 2a. Update missing fields in place (e.g. fill in `email` if the
-- row was orphaned at provision time).
UPDATE public.admin_users
SET
email = COALESCE(email, p_email),
auth_provider = COALESCE(NULLIF(auth_provider, 'supabase-orphan'), p_auth_provider),
auth_subject = COALESCE(auth_subject, p_auth_subject),
user_id = COALESCE(user_id, p_user_id)
WHERE id = v_existing.id
RETURNING * INTO v_existing;
RETURN v_existing;
END IF;
-- 2b. First-time sign-in. Insert a platform_admin row. Active is
-- true so the user can sign in immediately; brand assignment is
-- a manual step the platform admin takes later.
INSERT INTO public.admin_users (
user_id, email, auth_provider, auth_subject, role, active,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
must_change_password
) VALUES (
p_user_id, p_email, p_auth_provider, p_auth_subject,
'platform_admin', true,
true, true, true, true, true, true, true, true, true,
false
)
RETURNING * INTO v_inserted;
RETURN v_inserted;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. get_admin_user_for_session — Auth.js session resolver
-- ═══════════════════════════════════════════════════════════════════════════
-- Returns the admin row matching an Auth.js `session.user.id`, looking
-- up by `user_id` (Supabase UUID) OR `auth_subject` (Google etc.).
-- Returns NULL on miss — the application layer decides whether to
-- auto-provision a new row.
DROP FUNCTION IF EXISTS public.get_admin_user_for_session(TEXT);
CREATE OR REPLACE FUNCTION public.get_admin_user_for_session(p_session_id TEXT)
RETURNS public.admin_users
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row public.admin_users;
BEGIN
-- Try Supabase path first (UUID match on user_id).
IF p_session_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
SELECT * INTO v_row
FROM public.admin_users
WHERE user_id = p_session_id::UUID
LIMIT 1;
IF v_row.id IS NOT NULL THEN
RETURN v_row;
END IF;
END IF;
-- Fallback: treat the session id as a provider `auth_subject`. The
-- provider is unknown at this point, so match on the subject alone
-- (still unique thanks to the partial unique index).
SELECT * INTO v_row
FROM public.admin_users
WHERE auth_subject = p_session_id
LIMIT 1;
RETURN v_row;
END;
$$;
@@ -0,0 +1,95 @@
-- 209_authjs_auto_create_admin.sql
-- Auto-create a platform_admin row when a new user signs in via Auth.js.
--
-- Called from the `signIn` event in `src/lib/auth.ts`. The RPC is
-- idempotent (ON CONFLICT DO NOTHING) so repeat sign-ins are no-ops.
-- Defensive: ensure can_manage_settings column exists. It was likely
-- added via the Supabase dashboard (it's referenced in the TypeScript
-- `AdminUser` type at `src/lib/admin-permissions-types.ts` but not in
-- any tracked migration). ADD COLUMN IF NOT EXISTS is safe to re-run.
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS can_manage_settings BOOLEAN NOT NULL DEFAULT false;
-- Defensive: ensure admin_users.user_id has a unique constraint so the
-- `ON CONFLICT (user_id)` below resolves. The table was created via the
-- Supabase dashboard — we can't be sure the dashboard created a UNIQUE
-- index on user_id. If the constraint is missing, the ON CONFLICT
-- clause will fail the whole "Apply migrations" step on the deploy
-- runner. Skip silently if a matching unique/primary constraint already
-- exists, otherwise add one (cleaning up any duplicate rows first so
-- the ADD CONSTRAINT doesn't fail).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conrelid = 'public.admin_users'::regclass
AND contype IN ('u', 'p') -- unique or primary key
AND pg_get_constraintdef(oid) ILIKE '%(user_id)%'
) THEN
-- Shouldn't happen in practice (this RPC is the only writer for new
-- rows), but guard against duplicate user_id values that would
-- block the unique constraint from being created.
DELETE FROM admin_users a
USING admin_users b
WHERE a.user_id = b.user_id
AND a.ctid > b.ctid;
ALTER TABLE admin_users
ADD CONSTRAINT admin_users_user_id_key UNIQUE (user_id);
END IF;
END $$;
-- SECURITY DEFINER RPC: upsert a platform_admin row for the given
-- Auth.js user id.
--
-- Bypasses RLS on admin_users (which is enabled — see
-- 109_enable_rls_critical.sql:21). Runs with the function owner's
-- privileges so the auto-create on first sign-in can always succeed.
CREATE OR REPLACE FUNCTION upsert_admin_user_for_authjs(p_user_id UUID)
RETURNS SETOF admin_users
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
INSERT INTO admin_users (
user_id,
role,
active,
must_change_password,
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,
can_manage_settings
)
VALUES (
p_user_id,
'platform_admin',
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true
)
ON CONFLICT (user_id) DO NOTHING
RETURNING *;
END;
$$;
-- Reload PostgREST schema cache so the new RPC is immediately callable.
NOTIFY pgrst, 'reload schema';
+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;
$$;
-56
View File
@@ -1,56 +0,0 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
// Intercept ALL responses and log set-cookie headers
page.on('response', async (response) => {
const url = response.url();
if (url.includes('login') || url.includes('api/')) {
const headers = response.headers();
const setCookie = headers['set-cookie'];
console.log(`\n[${response.status()}] ${url}`);
if (setCookie) {
console.log(' Set-Cookie:', setCookie);
} else {
console.log(' Set-Cookie: (none)');
}
}
});
// Also intercept requests to add logging
page.on('request', (request) => {
const url = request.url();
if (url.includes('login') || url.includes('api/')) {
console.log(`[REQUEST] ${request.method()} ${url}`);
}
});
console.log('=== Starting login flow test ===');
await page.goto('https://route-commerce-platform.vercel.app/login', { waitUntil: 'domcontentloaded' });
console.log('Page loaded:', page.url());
await page.waitForTimeout(1000);
await page.fill('#email', 'kylemart@gmail.com');
await page.fill('#password', 'Test123456!');
console.log('Form filled, submitting...');
await page.click('button[type="submit"]');
// Wait for response
await page.waitForTimeout(5000);
console.log('\n=== Final State ===');
console.log('URL:', page.url());
const cookies = await context.cookies();
console.log('Cookies:', cookies.length);
cookies.forEach(c => {
console.log(` ${c.name}=${c.value.substring(0, 30)}... (domain=${c.domain}, path=${c.path})`);
});
await browser.close();
})().catch(console.error);
+61
View File
@@ -0,0 +1,61 @@
import { test, expect, request } from "@playwright/test";
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
// ─────────────────────────────────────────────────────────────────────
// Auth.js v5 API endpoints
// ─────────────────────────────────────────────────────────────────────
test.describe("Auth.js v5 endpoints", () => {
test("/api/auth/providers returns the configured providers", async () => {
const ctx = await request.newContext({ baseURL: BASE });
const res = await ctx.get("/api/auth/providers");
expect(res.status()).toBe(200);
const providers = (await res.json()) as Record<string, unknown>;
// The Google provider is only present when AUTH_GOOGLE_ID +
// AUTH_GOOGLE_SECRET are set.
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
expect(providers).toHaveProperty("google");
}
await ctx.dispose();
});
test("/api/auth/csrf returns a valid CSRF token", async () => {
const ctx = await request.newContext({ baseURL: BASE });
const res = await ctx.get("/api/auth/csrf");
expect(res.status()).toBe(200);
const body = (await res.json()) as { csrfToken?: string };
expect(typeof body.csrfToken).toBe("string");
expect(body.csrfToken!.length).toBeGreaterThan(20);
await ctx.dispose();
});
test("/api/auth/session returns null when there is no session", async () => {
const ctx = await request.newContext({ baseURL: BASE });
const res = await ctx.get("/api/auth/session");
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toBeNull();
await ctx.dispose();
});
});
// ─────────────────────────────────────────────────────────────────────
// Middleware — protected route gating
// ─────────────────────────────────────────────────────────────────────
test.describe("Middleware — protected route gating", () => {
test("unauthed /login renders the login form (200)", async ({ page }) => {
const res = await page.goto(`${BASE}/login`);
expect(res?.status()).toBe(200);
await expect(page.locator("body")).toBeVisible();
});
test("unauthed /admin redirects to /login", async ({ page }) => {
await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
expect(page.url()).toMatch(/\/login/);
});
test("unauthed /wholesale redirects to /login", async ({ page }) => {
await page.goto(`${BASE}/wholesale`, { waitUntil: "domcontentloaded" });
expect(page.url()).toMatch(/\/login/);
});
});
+30 -73
View File
@@ -1,79 +1,36 @@
import { test, expect } from "@playwright/test";
// ─────────────────────────────────────────────────────────────
// Login flow: credentials → /api/login JSON → redirect to /admin
// ─────────────────────────────────────────────────────────────
test("login with valid credentials redirects to /admin and shows admin dashboard", async ({
page,
}) => {
// Navigate to login page
await page.goto("/login");
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
// Fill credentials
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
await page.fill("#password", process.env.TEST_ADMIN_PASSWORD!);
// ─────────────────────────────────────────────────────────────────────
// Login page — Google OAuth is the only login path
// ─────────────────────────────────────────────────────────────────────
test.describe("Login page", () => {
test("renders the Google sign-in button", async ({ page }) => {
await page.goto(`${BASE}/login`);
await expect(page.getByRole("button", { name: /continue with google/i })).toBeVisible();
});
// Submit form
await page.click('button[type="submit"]');
test("shows a friendly 'not configured' message when Google OAuth env is missing", async ({ page }) => {
// The page either shows the Google button (when AUTH_GOOGLE_ID/SECRET
// are set) or a setup message (when they aren't). We accept both as
// correct renderings.
await page.goto(`${BASE}/login`);
const hasGoogle = await page
.getByRole("button", { name: /continue with google/i })
.isVisible()
.catch(() => false);
const hasSetup = await page
.getByText(/Google sign-in is not configured/i)
.isVisible()
.catch(() => false);
expect(hasGoogle || hasSetup).toBe(true);
});
// Wait for navigation to /admin — the JSON response + client nav must happen
await page.waitForURL("**/admin", { timeout: 10000 });
// Admin dashboard must be rendered (Control Center heading or admin layout)
await expect(page.locator("body")).not.toContainText("Access Denied");
await expect(page.locator("body")).not.toContainText("Login failed");
});
// ─────────────────────────────────────────────────────────────
// Login failure: bad password shows error, stays on /login
// ─────────────────────────────────────────────────────────────
test("login with wrong password shows error and stays on /login", async ({
page,
}) => {
await page.goto("/login");
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
await page.fill("#password", "wrongpassword123!");
await page.click('button[type="submit"]');
// Error message should appear
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 5000 });
// Should NOT navigate away from login
await expect(page).toHaveURL(/\/login/);
});
// ─────────────────────────────────────────────────────────────
// Login with missing fields shows validation error
// ─────────────────────────────────────────────────────────────
test("login with missing email shows validation error", async ({ page }) => {
await page.goto("/login");
// Don't fill email, only password
await page.fill("#password", "something");
await page.click('button[type="submit"]');
// Browser validation should fire (email required)
await expect(page.locator("#email")).toHaveAttribute("required", "");
});
// ─────────────────────────────────────────────────────────────
// Session persistence: after login, navigating to /admin
// should load without re-authenticating
// ─────────────────────────────────────────────────────────────
test("admin session persists across page reloads", async ({ page }) => {
// Login first
await page.goto("/login");
await page.fill("#email", process.env.TEST_ADMIN_EMAIL!);
await page.fill("#password", process.env.TEST_ADMIN_PASSWORD!);
await page.click('button[type="submit"]');
await page.waitForURL("**/admin", { timeout: 10000 });
// Reload the page
await page.reload();
// Should still be on /admin (session cookie keeps user logged in)
await expect(page).toHaveURL(/\/admin/);
await expect(page.locator("body")).not.toContainText("Access Denied");
test("/admin redirects to /login when not authenticated", async ({ page }) => {
const res = await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
// After redirect, the URL should be /login (with ?redirect=/admin).
expect(page.url()).toMatch(/\/login(\?|$)/);
expect(res?.status()).toBeLessThan(500);
});
});
+30 -43
View File
@@ -3,39 +3,9 @@ import { test, expect } from "@playwright/test";
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
test.describe("Smoke Tests", () => {
test("Login — dev_session platform_admin lands on admin dashboard", async ({ page }) => {
await page.context().addCookies([
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
]);
await page.goto(`${BASE}/admin`);
await expect(page).toHaveURL(/\/admin/, { timeout: 5000 });
// Admin sidebar nav should be loaded (sidebar is rendered by layout)
await expect(page.locator("aside").first()).toBeVisible({ timeout: 5000 });
});
test("Admin Settings — page loads", async ({ page }) => {
await page.context().addCookies([
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
]);
await page.goto(`${BASE}/admin/settings`);
// Wait for redirect to complete (may go to /login first)
await page.waitForURL(/\/admin\/settings/, { timeout: 10000 });
// Page renders without crash — main content area should exist
await expect(page.locator("main").first()).toBeVisible({ timeout: 8000 });
});
test("Time Tracking — page loads", async ({ page }) => {
await page.context().addCookies([
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
]);
await page.goto(`${BASE}/admin/time-tracking`);
await expect(page).toHaveURL(/\/admin\/time-tracking/, { timeout: 5000 });
test("Homepage loads", async ({ page }) => {
const res = await page.goto(`${BASE}/`);
expect(res?.status()).toBeLessThan(500);
await expect(page.locator("body")).toBeVisible();
});
@@ -46,17 +16,34 @@ test.describe("Smoke Tests", () => {
});
await page.goto(`${BASE}/tuxedo`);
// Hero brand name — use a more stable anchor
await expect(page.locator("h1").first()).toBeVisible({ timeout: 10000 });
// "Why Choose" section heading is a strong signal the page rendered correctly
await expect(page.getByRole("heading", { name: /why choose/i })).toBeVisible({ timeout: 5000 });
// No console errors (filter known non-critical)
await expect(page.locator("h1").first()).toBeVisible({ timeout: 10_000 });
const criticalErrors = errors.filter(
(e) => !e.includes("favicon") && !e.includes("Warning:")
(e) => !e.includes("favicon") && !e.includes("Warning:"),
);
expect(criticalErrors).toHaveLength(0);
});
});
test("Indian River Direct storefront — homepage loads", async ({ page }) => {
const res = await page.goto(`${BASE}/indian-river-direct`);
expect(res?.status()).toBeLessThan(500);
await expect(page.locator("body")).toBeVisible();
});
test("/admin redirects to /login when unauthenticated", async ({ page }) => {
await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
expect(page.url()).toMatch(/\/login(\?|$)/);
});
test("Login page renders the Google CTA (or 'not configured' notice)", async ({ page }) => {
await page.goto(`${BASE}/login`);
const hasGoogle = await page
.getByRole("button", { name: /continue with google/i })
.isVisible()
.catch(() => false);
const hasSetup = await page
.getByText(/Google sign-in is not configured/i)
.isVisible()
.catch(() => false);
expect(hasGoogle || hasSetup).toBe(true);
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Unit tests for the auth server actions in src/actions/auth-actions.ts.
*
* Mocks `@/lib/auth` to test the action wrappers in isolation from the
* network and the Auth.js runtime.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const signInMock = vi.fn();
const signOutMock = vi.fn();
vi.mock("@/lib/auth", () => ({
signIn: signInMock,
signOut: signOutMock,
}));
// `server-only` is a runtime guard that throws if imported outside a
// server context. Vitest is a Node env, so the guard fires — stub it.
vi.mock("server-only", () => ({}));
// Import after mocks.
const { signInWithGoogle, signOutAction } = await import(
"@/actions/auth-actions"
);
beforeEach(() => {
signInMock.mockReset();
signOutMock.mockReset();
});
describe("signInWithGoogle", () => {
it("calls signIn with the google provider and /admin redirect", async () => {
signInMock.mockResolvedValue(undefined);
await signInWithGoogle();
expect(signInMock).toHaveBeenCalledWith("google", { redirectTo: "/admin" });
});
});
describe("signOutAction", () => {
it("calls signOut with the login redirect", async () => {
signOutMock.mockResolvedValue(undefined);
await signOutAction();
expect(signOutMock).toHaveBeenCalledWith({ redirectTo: "/login" });
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* Unit tests for `getAdminUser()`. The dev_session cookie bypass has been
* removed; the only path into the admin is through a real Auth.js
* session. These tests mock the `auth()` function and the DB layer
* to exercise the lookup.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// Stub the server-only guard so the module can be imported under vitest.
vi.mock("server-only", () => ({}));
// Mock the Drizzle client wrapper so we don't need a real DB.
const mockSelect = vi.fn();
const mockWithPlatformAdmin = vi.fn(async (fn: any) => fn({ select: mockSelect }));
vi.mock("@/db/client", () => ({
withPlatformAdmin: (fn: any) => mockWithPlatformAdmin(fn),
}));
// Mock the auth() function. The default mock returns null (no session).
const authMock = vi.fn();
vi.mock("@/lib/auth", () => ({
auth: () => authMock(),
}));
// Mock cookies() so we don't read a real cookie store.
const cookieStoreGet = vi.fn();
vi.mock("next/headers", () => ({
cookies: () =>
Promise.resolve({
get: (name: string) => cookieStoreGet(name),
}),
}));
import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-permissions";
beforeEach(() => {
mockSelect.mockReset();
authMock.mockReset();
cookieStoreGet.mockReset();
});
describe("getAdminUser()", () => {
it("returns null when there is no Auth.js session", async () => {
authMock.mockResolvedValue(null);
cookieStoreGet.mockReturnValue(undefined);
const u = await getAdminUser();
expect(u).toBeNull();
});
it("returns null when the session has no email", async () => {
authMock.mockResolvedValue({ user: { name: "no-email" } });
const u = await getAdminUser();
expect(u).toBeNull();
});
it("returns null when the email is not in the users table", async () => {
authMock.mockResolvedValue({ user: { email: "unknown@example.com" } });
// First select: users. Returns empty.
mockSelect.mockReturnValueOnce({
from: () => ({
where: () => ({
limit: async () => [],
}),
}),
});
const u = await getAdminUser();
expect(u).toBeNull();
});
it("returns null when the user exists but has no tenant_users row", async () => {
authMock.mockResolvedValue({ user: { email: "no-tenant@example.com" } });
// First select: users — returns the user
mockSelect
.mockReturnValueOnce({
from: () => ({
where: () => ({
limit: async () => [
{
id: "user-1",
email: "no-tenant@example.com",
name: "No Tenant",
authProvider: "google",
authSubject: "google-sub",
},
],
}),
}),
})
// Second select: tenantUsers — returns empty
.mockReturnValueOnce({
from: () => ({
innerJoin: () => ({
where: () => ({
limit: async () => [],
}),
}),
}),
});
const u = await getAdminUser();
expect(u).toBeNull();
});
it("returns a fully-populated AdminUser for a provisioned brand_admin", async () => {
authMock.mockResolvedValue({ user: { email: "admin@tuxedo.example" } });
mockSelect
.mockReturnValueOnce({
from: () => ({
where: () => ({
limit: async () => [
{
id: "user-tux",
email: "admin@tuxedo.example",
name: "Tux Admin",
authProvider: "google",
authSubject: "google-tux",
},
],
}),
}),
})
.mockReturnValueOnce({
from: () => ({
innerJoin: () => ({
where: () => ({
limit: async () => [
{
tenantId: "tenant-tux",
tenantName: "Tuxedo Citrus",
tenantSlug: "tuxedo",
tenantStatus: "active",
role: "brand_admin",
},
],
}),
}),
}),
});
const u = await getAdminUser();
expect(u).not.toBeNull();
expect(u?.email).toBe("admin@tuxedo.example");
expect(u?.tenant_id).toBe("tenant-tux");
expect(u?.tenant_slug).toBe("tuxedo");
expect(u?.role).toBe("brand_admin");
expect(u?.can_manage_products).toBe(true);
expect(u?.can_manage_team).toBe(true);
});
});
describe("buildDevAdmin()", () => {
it("returns a platform_admin with no tenant", () => {
const u = buildDevAdmin("platform_admin");
expect(u.role).toBe("platform_admin");
expect(u.tenant_id).toBeNull();
expect(u.tenant_slug).toBeNull();
expect(u.can_manage_billing).toBe(true);
});
it("returns a brand_admin tied to the tuxedo tenant", () => {
const u = buildDevAdmin("brand_admin");
expect(u.role).toBe("brand_admin");
expect(u.tenant_slug).toBe("tuxedo");
expect(u.can_manage_users).toBe(false);
});
it("returns a store_employee with limited permissions", () => {
const u = buildDevAdmin("store_employee");
expect(u.role).toBe("store_employee");
expect(u.can_manage_products).toBe(false);
expect(u.can_manage_orders).toBe(true);
expect(u.can_manage_billing).toBe(false);
});
});
describe("permissionsForRole()", () => {
it("platform_admin can do everything", () => {
const p = permissionsForRole("platform_admin");
expect(Object.values(p).every((v) => v === true)).toBe(true);
});
it("brand_admin can manage most things but not users", () => {
const p = permissionsForRole("brand_admin");
expect(p.can_manage_users).toBe(false);
expect(p.can_manage_billing).toBe(true);
});
it("store_employee is restricted to orders + pickup", () => {
const p = permissionsForRole("store_employee");
expect(p.can_manage_orders).toBe(true);
expect(p.can_manage_pickup).toBe(true);
expect(p.can_manage_products).toBe(false);
expect(p.can_manage_billing).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Tests for the password helper. Exercises:
* - hash round-trip (verify(hashed) returns true for the same plaintext)
* - wrong password returns false
* - tampered/malformed stored values return false (no exception)
* - distinct passwords produce distinct hashes (salting works)
* - format is `scrypt$N$salt$hash` and self-describing
*/
import { describe, it, expect } from "vitest";
import { hashPassword, verifyPassword } from "@/lib/passwords";
// `src/lib/passwords.ts` uses `import "server-only"`. Stub it so the test
// runner can import the module.
import { vi } from "vitest";
vi.mock("server-only", () => ({}));
describe("hashPassword / verifyPassword", () => {
it("round-trips: verify(hash(p)) is true for the same plaintext", () => {
const h = hashPassword("admin");
expect(verifyPassword("admin", h)).toBe(true);
});
it("returns false for the wrong password", () => {
const h = hashPassword("admin");
expect(verifyPassword("wrong", h)).toBe(false);
});
it("returns false for an empty password (without throwing)", () => {
const h = hashPassword("admin");
expect(verifyPassword("", h)).toBe(false);
});
it("returns false for a tampered stored value (no exception)", () => {
expect(verifyPassword("admin", "not-a-real-hash")).toBe(false);
expect(verifyPassword("admin", "scrypt$999$abc$def")).toBe(false);
expect(verifyPassword("admin", "")).toBe(false);
});
it("uses a random salt — same password produces different hashes", () => {
const a = hashPassword("admin");
const b = hashPassword("admin");
expect(a).not.toBe(b);
expect(verifyPassword("admin", a)).toBe(true);
expect(verifyPassword("admin", b)).toBe(true);
});
it("hashes in the self-describing `scrypt$N$salt$hash` format", () => {
const h = hashPassword("admin");
const parts = h.split("$");
expect(parts).toHaveLength(4);
expect(parts[0]).toBe("scrypt");
expect(Number.parseInt(parts[1], 10)).toBeGreaterThan(0);
expect(parts[2]).toMatch(/^[0-9a-f]+$/);
expect(parts[3]).toMatch(/^[0-9a-f]+$/);
expect(parts[3].length).toBe(128); // 64 bytes hex = 128 chars
});
it("handles long passwords", () => {
const long = "x".repeat(1024);
const h = hashPassword(long);
expect(verifyPassword(long, h)).toBe(true);
expect(verifyPassword(long + "x", h)).toBe(false);
});
it("handles unicode passwords", () => {
const h = hashPassword("пароль密碼🔐");
expect(verifyPassword("пароль密碼🔐", h)).toBe(true);
expect(verifyPassword("пароль密碼", h)).toBe(false);
});
it("throws on empty input to hashPassword (caller bug, fail fast)", () => {
expect(() => hashPassword("")).toThrow();
});
});
+3 -1
View File
@@ -19,7 +19,9 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@/db": ["./db"],
"@/db/*": ["./db/*"]
}
},
"include": [
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: [
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
{ find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") },
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
{ find: "@/db", replacement: path.resolve(__dirname, "db") },
],
},
test: {
environment: "node",
include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"],
exclude: ["node_modules", ".next", "tests/e2e/**", "tests/login/**", "tests/smoke.spec.ts"],
// Supabase REST, Auth.js v5, and Next.js `cookies()` / `headers()` are
// stubbed in each test — keep the timeout generous.
testTimeout: 15_000,
},
});