12 Commits

Author SHA1 Message Date
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
34 changed files with 938 additions and 1568 deletions
+7
View File
@@ -40,6 +40,13 @@ GOOGLE_CLIENT_SECRET=
# development. Default: enabled in dev only. # development. Default: enabled in dev only.
ALLOW_DEV_LOGIN=true 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) ──────────────────────────────────────── # ── Supabase (legacy, being removed) ────────────────────────────────────────
# Still used by the existing admin pages, server actions, and the # Still used by the existing admin pages, server actions, and the
# `getAdminUser` flow. Once the auth migration is complete 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_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} 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: | run: |
APP_DIR=/home/tyler/route-commerce APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR 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 # 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) # (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 "") 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 NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
export 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 cd $APP_DIR
[ -f .env ] || cp .env.example .env [ -f .env ] || cp .env.example .env
# Append production secrets to .env (overriding .env.example defaults) # Append production secrets to .env (overriding .env.example defaults)
@@ -92,15 +109,22 @@ jobs:
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}" echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}" 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 "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
} >> .env } >> .env
# Bring the stack up fresh — --force-recreate ensures no stale # Bring the stack up fresh — --force-recreate ensures no stale
# network/container references from prior failed attempts # network/container references from prior failed attempts.
docker compose up -d --force-recreate db postgrest minio minio_init # Only `postgrest` lives in docker; Postgres itself runs on the
# Wait for Postgres healthcheck # 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 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" echo "Postgres is ready"
break break
fi fi
@@ -145,6 +169,7 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
# Supabase (legacy, still used by admin pages/server actions until # Supabase (legacy, still used by admin pages/server actions until
# the Auth.js migration is finished) # the Auth.js migration is finished)
@@ -197,6 +222,7 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
# Storage # Storage
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
@@ -253,6 +279,7 @@ jobs:
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID" printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET" printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN" 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_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION" printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY" printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
+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. - 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. - 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)
+7 -39
View File
@@ -2,17 +2,14 @@
# docker-compose.yml — production stack consumed by deploy.sh # docker-compose.yml — production stack consumed by deploy.sh
# ============================================================================= # =============================================================================
# #
# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by # Only `postgrest` lives in docker. Postgres itself runs on the host
# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011} # (the deploy workflow applies migrations via `psql -h 127.0.0.1`).
# so a manual `docker compose up` without the deploy script still works. # Next.js runs under PM2 on the host — it is NOT a docker service.
# #
# Note on networking: the Next.js container calls PostgREST on # The host-side port (POSTGREST_HOST_PORT) is written by the deploy
# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined # workflow into $APP_DIR/.env. We interpolate from there with
# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves # ${VAR:-3011} so a manual `docker compose up` without the deploy
# correctly. On Linux you may need to add # script still works.
# extra_hosts:
# - "host.docker.internal:host-gateway"
# which is included below for that reason.
# ============================================================================= # =============================================================================
name: prod-app # default project name; deploy.sh overrides with -p name: prod-app # default project name; deploy.sh overrides with -p
@@ -39,32 +36,3 @@ services:
timeout: 3s timeout: 3s
retries: 6 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
+11
View File
@@ -1,6 +1,17 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { 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 // Enable strict mode
reactStrictMode: true, reactStrictMode: true,
+14 -8
View File
@@ -1,18 +1,24 @@
"use server"; "use server";
import { cookies } from "next/headers"; import { auth } from "@/lib/auth";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import { createClient as createServiceClient } from "@supabase/supabase-js";
/**
* Update the password for the currently signed-in admin.
*
* Identity comes from the Auth.js session (`auth().user.id`), which is
* the same UUID space as `admin_users.user_id` and `auth.users.id` in
* Postgres. The legacy `rc_auth_uid` / `rc_uid` cookie fallback has
* been removed — the Auth.js JWT is the single source of truth.
*/
export async function updatePasswordAction( export async function updatePasswordAction(
newPassword: string newPassword: string
): Promise<{ error?: string }> { ): Promise<{ error?: string }> {
const cookieStore = await cookies(); const session = await auth();
const uid = const userId = session?.user?.id;
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value;
if (!uid) { if (!userId) {
return { error: "Not authenticated. Please log in again." }; return { error: "Not authenticated. Please sign in again." };
} }
const service = createServiceClient( const service = createServiceClient(
@@ -21,7 +27,7 @@ export async function updatePasswordAction(
); );
const { error } = await service.rpc("update_user_password", { const { error } = await service.rpc("update_user_password", {
p_user_id: uid, p_user_id: userId,
p_password: newPassword, p_password: newPassword,
}); });
+3 -27
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { signIn, signOut } from "@/lib/auth"; import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
/** /**
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for * 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> * <button type="submit">Sign in with Google</button>
* </form> * </form>
* *
* Usage for the dev credentials provider (dev only): * Note: dev/demo authentication is no longer a button on the login page.
* <form action={signInWithDev}> * `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
* <input name="username" /> * when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
* <input name="password" type="password" />
* <button type="submit">Dev login</button>
* </form>
*/ */
export async function signInWithGoogle(): Promise<void> { export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" }); 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> { export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" }); await signOut({ redirectTo: "/login" });
} }
-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 };
}
-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"); redirect("/change-password");
} }
// Resolve the active brand (URL > cookie > legacy > first of brand_ids) // Resolve the active brand (URL > cookie > legacy > first of brand_ids).
const activeBrandId = await getActiveBrandId(adminUser); // 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 // Fetch accessible brands for the sidebar BrandSelector. Wrapped in
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar // try/catch so the sidebar renders empty rather than crashing the page
// decides whether to show the dropdown. // if the brands query fails.
const brands = await listBrandsForAdmin(); let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
try {
brands = await listBrandsForAdmin();
} catch (err) {
console.error("[admin/layout] listBrandsForAdmin failed:", err);
}
return ( return (
<ToastProviderWrapper> <ToastProviderWrapper>
+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 // 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 // > 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 // 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; let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") { if (!dashboardBrandId && adminUser?.role === "platform_admin") {
const { data: firstBrand } = await supabase try {
.from("brands") const { data: firstBrand } = await supabase
.select("id") .from("brands")
.limit(1) .select("id")
.single(); .limit(1)
if (firstBrand?.id) { .single();
dashboardBrandId = firstBrand.id; 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>
);
}
-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 });
}
-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,
});
}
-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 });
}
-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>
);
}
+22 -144
View File
@@ -1,150 +1,28 @@
"use client"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import ChangePasswordForm from "./ChangePasswordForm";
import { useEffect, useState } from "react"; export const dynamic = "force-dynamic";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { updatePasswordAction } from "@/actions/admin/password";
import { logUserActivity } from "@/actions/admin/audit";
export default function ChangePasswordPage() { /**
const router = useRouter(); * Forced password-change page.
const [password, setPassword] = useState(""); *
const [confirm, setConfirm] = useState(""); * Linked from `src/app/admin/layout.tsx:64` when the admin user's row
const [error, setError] = useState<string | null>(null); * has `must_change_password = true`. Verifies the Auth.js session
const [loading, setLoading] = useState(false); * server-side, then renders the form with the user id passed as a prop.
const [checkingSession, setCheckingSession] = useState(true); *
const [userId, setUserId] = useState<string | null>(null); * Previously this page fetched `/api/auth/uid` from a `useEffect` to
* resolve the current user. That endpoint (and the underlying
* `rc_auth_uid` cookie) has been removed — the Auth.js JWT is the
* single source of truth for identity now.
*/
export default async function ChangePasswordPage() {
const session = await auth();
const userId = session?.user?.id;
useEffect(() => { if (!userId) {
fetch("/api/auth/uid") redirect("/login");
.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();
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;
}
if (userId) {
logUserActivity({
user_id: userId,
activity_type: "password_change",
details: {},
});
}
router.push("/admin");
router.refresh();
} }
if (checkingSession) { return <ChangePasswordForm userId={userId} />;
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">
<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 className="mt-4 border-t border-zinc-800 pt-4">
<Link
href="/logout"
className="block text-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
Sign out instead
</Link>
</div>
</div>
</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>
);
}
+179 -434
View File
@@ -1,101 +1,82 @@
"use client"; "use client";
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { signInWithGoogle } from "@/actions/auth-signin";
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
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);
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]);
/**
* The login page is a single Google OAuth button.
*
* The three "modes" that used to live here are gone:
* • Email/password — removed. Hit a dummy Supabase and 500'd.
* • Dev credentials form — removed. The dev cookie is now issued by
* `src/middleware.ts` when ALLOW_DEV_LOGIN is enabled.
* • /login?demo=1 three-button picker — removed. Same reason.
*
* Flow:
* • In dev / demo: visiting /admin auto-logs you in via the middleware.
* • In production: click "Sign in with Google" → Auth.js handles OAuth.
*/
export default function LoginClient() {
return ( return (
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}> <main
{/* Google Fonts */} className="min-h-screen flex flex-col relative overflow-hidden"
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
>
<style jsx global>{` <style jsx global>{`
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
html, body { overflow: hidden; } html,
body {
overflow: hidden;
}
`}</style> `}</style>
{/* Organic background elements */} {/* Organic background elements */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true"> <div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} /> <div
<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)" }} /> className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20"
<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)" }} /> 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> </div>
{/* Header */} {/* Header */}
<header className="w-full py-6 px-6 lg:px-8"> <header className="w-full py-6 px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between"> <div className="max-w-7xl mx-auto flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}> <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" }}> <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"> <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" /> <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> </svg>
</div> </div>
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}> <span
className="text-xl font-semibold tracking-tight"
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}
>
Route Commerce Route Commerce
</span> </span>
</Link> </Link>
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}> <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 Back to home
</Link> </Link>
</div> </div>
@@ -103,29 +84,42 @@ function LoginForm() {
{/* Login Card */} {/* Login Card */}
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10"> <div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
<div className={`w-full max-w-sm transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"}`}> <div className="w-full max-w-sm">
{/* Card */}
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden"> <div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
{/* Subtle top accent */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
<div className="p-8 sm:p-10"> <div className="p-8 sm:p-10">
{/* Logo & Title */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}> <div
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
style={{
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
}}
>
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg> </svg>
</div> </div>
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}> <h1
className="text-3xl font-semibold text-stone-900"
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
>
Welcome back Welcome back
</h1> </h1>
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}> <p
className="mt-2 text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
>
Sign in to your account Sign in to your account
</p> </p>
</div> </div>
{/* Auth.js v5 — primary sign-in: Google OAuth */} {/* Single sign-in method: Google OAuth via Auth.js */}
<form action={signInWithGoogle} className="space-y-3"> <form action={signInWithGoogle} className="space-y-3">
<button <button
type="submit" type="submit"
@@ -155,226 +149,63 @@ function LoginForm() {
</button> </button>
</form> </form>
{/* Dev login (only visible in development) */} <p
{process.env.NODE_ENV !== "production" && ( className="mt-6 text-center text-xs"
<form action={signInWithDev} className="space-y-3"> style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}
<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. By signing in you agree to our{" "}
Set <code>ALLOW_DEV_LOGIN=false</code> in <code>.env.local</code> to hide. <Link href="/terms-and-conditions" className="underline hover:text-stone-700">
</div> Terms
<div className="grid grid-cols-2 gap-2"> </Link>{" "}
<input and{" "}
name="username" <Link href="/privacy-policy" className="underline hover:text-stone-700">
type="text" Privacy Policy
defaultValue="admin" </Link>
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" </p>
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>
)}
</div> </div>
{/* Security Trust Badges */} {/* Security Trust Badges */}
<div className="border-t border-stone-100/50 px-8 py-5" style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}> <div
<div className="flex flex-col gap-3"> className="border-t border-stone-100/50 px-8 py-5"
<div className="flex items-center justify-center gap-4 text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}> style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}
<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}> <div
<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" /> className="flex items-center justify-center gap-4 text-xs"
</svg> style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}
<span>256-bit SSL</span> >
</div> <div className="flex items-center gap-1.5">
<span className="text-stone-300"></span> <svg
<div className="flex items-center gap-1.5"> className="w-4 h-4 text-[#6b8f71]"
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> fill="none"
<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" /> viewBox="0 0 24 24"
</svg> stroke="currentColor"
<span>SOC 2</span> strokeWidth={2}
</div> >
<span className="text-stone-300"></span> <path
<div className="flex items-center gap-1.5"> strokeLinecap="round"
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none"> strokeLinejoin="round"
<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"/> 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>Powered by Supabase</span> </svg>
</div> <span>256-bit SSL</span>
</div>
<span className="text-stone-300"></span>
<div className="flex items-center gap-1.5">
<svg
className="w-4 h-4 text-[#6b8f71]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
<span>SOC 2</span>
</div> </div>
</div> </div>
</div> </div>
@@ -382,8 +213,19 @@ function LoginForm() {
{/* Back link */} {/* Back link */}
<div className="text-center mt-6"> <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" }}> <Link
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"> 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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg> </svg>
View Farms View Farms
@@ -396,20 +238,49 @@ function LoginForm() {
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10"> <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="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="flex items-center gap-2">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}> <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"> <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" /> <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> </svg>
</div> </div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}> <span
className="text-xs"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}
>
© {new Date().getFullYear()} Route Commerce © {new Date().getFullYear()} Route Commerce
</span> </span>
</div> </div>
<nav className="flex items-center gap-6"> <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" }}> <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 Privacy
</Link> </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" }}> <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 Terms
</Link> </Link>
</nav> </nav>
@@ -418,129 +289,3 @@ function LoginForm() {
</main> </main>
); );
} }
// Demo mode wrapper
function DemoMode() {
return (
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
{/* Organic background elements */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
</div>
{/* Header */}
<header className="w-full py-6 px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
Route Commerce
</span>
</Link>
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
Back to home
</Link>
</div>
</header>
{/* Demo Card */}
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
<div className="w-full max-w-sm">
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden p-10">
<div className="text-center mb-8">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
Demo Mode
</h1>
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
Select a role to explore the platform
</p>
</div>
<div className="flex flex-col gap-3">
<button
onClick={() => { document.cookie = "dev_session=platform_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
>
Platform Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=brand_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#c97a3e" }}
>
Brand Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=store_employee; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#6b8f71" }}
>
Store Employee
</button>
</div>
</div>
{/* Back link */}
<div className="text-center mt-6">
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
View Farms
</Link>
</div>
</div>
</div>
{/* Footer */}
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
© {new Date().getFullYear()} Route Commerce
</span>
</div>
</div>
</footer>
</main>
);
}
// Inner component that uses useSearchParams - must be wrapped in Suspense
function LoginPageInner() {
const searchParams = useSearchParams();
const isDemo = searchParams.get("demo") === "1";
if (isDemo) return <DemoMode />;
return <LoginForm />;
}
export default function LoginClient() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-emerald-600 to-emerald-500 animate-pulse" />
<p className="text-stone-500">Loading...</p>
</div>
</div>
}>
<LoginPageInner />
</Suspense>
);
}
-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>
);
}
-41
View File
@@ -1,41 +0,0 @@
"use client";
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>
);
}
-26
View File
@@ -44,32 +44,6 @@ export const authConfig = {
session: { strategy: "jwt" }, session: { strategy: "jwt" },
callbacks: { 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.
*/
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnProtectedExample = nextUrl.pathname.startsWith(
"/protected-example"
);
if (isOnAdmin) {
if (isLoggedIn) return true;
return false; // Redirect to /login
}
if (isOnProtectedExample) {
if (isLoggedIn) return true;
return false;
}
return true;
},
/** /**
* Forward the user id from the database user record into the JWT on * Forward the user id from the database user record into the JWT on
* initial sign-in. With database sessions this is what populates * initial sign-in. With database sessions this is what populates
+56 -77
View File
@@ -1,4 +1,6 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { auth } from "@/lib/auth";
import { pool } from "@/lib/db";
import type { AdminUser } from "./admin-permissions-types"; import type { AdminUser } from "./admin-permissions-types";
export type { AdminUser } from "./admin-permissions-types"; export type { AdminUser } from "./admin-permissions-types";
@@ -8,13 +10,19 @@ export type { AdminUser } from "./admin-permissions-types";
* Resolution order: * Resolution order:
* 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev. * 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). * 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. * 3. Auth.js v5 session (JWT cookie) → look up `admin_users` by the
* Auth.js user id (the `users.id` UUID managed by @auth/pg-adapter).
*
* The legacy `rc_auth_uid` / `rc_uid` cookie path has been removed.
* The Auth.js JWT is the single source of truth for identity; the
* `dev_session` cookie is only used for the demo / dev auto-login in
* `src/proxy.ts`.
* *
* `brand_id` is the active brand; `brand_ids` is the full membership list. * `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: * For dev sessions without a real DB, `brand_ids` is populated by:
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table) * - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
* - store_employee: `[<first real brand>]` if a brand exists, else `[]` * - store_employee: `[]`
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev) * - brand_admin: `[]`
*/ */
export async function getAdminUser(): Promise<AdminUser | null> { export async function getAdminUser(): Promise<AdminUser | null> {
const cookieStore = await cookies(); const cookieStore = await cookies();
@@ -24,93 +32,64 @@ export async function getAdminUser(): Promise<AdminUser | null> {
return buildDevAdmin("platform_admin"); return buildDevAdmin("platform_admin");
} }
// ── Dev session bypass (enabled for testing on all envs) ────────────── // ── Dev session bypass (enabled for testing on all envs) ──────────
const dev = cookieStore.get("dev_session")?.value; const dev = cookieStore.get("dev_session")?.value;
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") { if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") {
return buildDevAdmin(dev); return buildDevAdmin(dev);
} }
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login // ── Auth.js v5 session (JWT) ────────────────────────────────────
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value; // After Google sign-in, the encrypted JWT cookie is set. `auth()`
if (!uid) return null; // decrypts it server-side and returns the session — no DB call here,
// just cookie decryption. Then we look up the admin row by the
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) { // Auth.js `users.id` UUID (same ID space as `admin_users.user_id`).
return null; const session = await auth();
if (session?.user?.id) {
const admin = await getAdminUserFromPool(session.user.id);
if (admin) return admin;
} }
// Lookup admin_users by Supabase auth user id return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let adminUsers: unknown[] = [];
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
}
return null;
}
const admin = adminUsers[0] as Record<string, unknown>;
if (!admin.active) return null;
// Load brand_ids from the admin_user_brands junction
const brandIds = await fetchAdminUserBrandIds(supabaseUrl, serviceKey, admin.id as string);
return buildAdminUser(admin, brandIds);
} }
/** /**
* Load `brand_ids` from the admin_user_brands junction for the given admin row. * Look up an admin user by the Auth.js `users.id` UUID using the shared
* Returns an empty array on any failure (e.g. before migration 207 is applied). * `pg` pool. Returns `null` if no active row exists.
*
* The `admin_users.user_id` column is UUID (see 028_fix_caller_uid_type.sql).
* The Auth.js `users.id` is also UUID (see 204_authjs_tables.sql:18). 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 both IDs are in the same UUID space.
*/ */
async function fetchAdminUserBrandIds( async function getAdminUserFromPool(userId: string): Promise<AdminUser | null> {
supabaseUrl: string,
serviceKey: string,
adminRowId: string
): Promise<string[]> {
try { try {
const res = await fetch( const { rows } = await pool.query<Record<string, unknown>>(
`${supabaseUrl}/rest/v1/admin_user_brands?admin_user_id=eq.${adminRowId}&select=brand_id`, "SELECT * FROM admin_users WHERE user_id = $1 AND active = true LIMIT 1",
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } } [userId]
); );
if (!res.ok) return []; if (rows.length === 0) return null;
const data = await res.json().catch(() => []); const admin = rows[0];
if (!Array.isArray(data)) return []; const brandIds = await fetchAdminUserBrandIdsFromPool(admin.id as string);
return data return buildAdminUser(admin, brandIds);
.map((row: Record<string, unknown>) => row.brand_id as string) } catch (e) {
.filter((id): id is string => typeof id === "string"); console.warn("[admin-permissions] getAdminUserFromPool error:", e);
return null;
}
}
/**
* Load `brand_ids` from the admin_user_brands junction for the given
* admin row id, via the shared `pg` pool. Returns an empty array on any
* failure.
*/
async function fetchAdminUserBrandIdsFromPool(adminRowId: string): Promise<string[]> {
try {
const { rows } = await pool.query<{ brand_id: string }>(
"SELECT brand_id FROM admin_user_brands WHERE admin_user_id = $1",
[adminRowId]
);
return rows.map((r) => r.brand_id).filter((id): id is string => typeof id === "string");
} catch { } catch {
return []; return [];
} }
+54 -59
View File
@@ -1,11 +1,8 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import PostgresAdapter from "@auth/pg-adapter"; import PostgresAdapter from "@auth/pg-adapter";
import { Pool } from "pg";
import Credentials from "next-auth/providers/credentials"; import Credentials from "next-auth/providers/credentials";
import { import { pool } from "@/lib/db";
authConfig, import { authConfig, isDevLoginEnabled } from "@/auth.config";
isDevLoginEnabled,
} from "@/auth.config";
/** /**
* Build the dev Credentials provider. Lives here (Node-only) because * Build the dev Credentials provider. Lives here (Node-only) because
@@ -39,56 +36,32 @@ function buildDevCredentialsProvider() {
}); });
} }
/**
* 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. * Final server-side Auth.js config.
* *
* Builds on `authConfig` (edge-safe) and layers on: * Builds on `authConfig` (edge-safe) and layers on:
* 1. The Postgres database adapter * 1. The Postgres database adapter (uses the shared `pool` from @/lib/db)
* 2. The dev Credentials provider (only in development) * 2. The dev Credentials provider (only in development)
* *
* Note: when using a database adapter the session strategy is fixed to * Note: when using a database adapter the session strategy is fixed to
* "database" — Auth.js will persist sessions in the `sessions` table. * "database" — Auth.js will persist sessions in the `sessions` table.
*/ */
/**
* Parse the ADMIN_ALLOWED_EMAILS env var into a lowercased set.
* Returns `null` if the env var is unset/empty — the caller should treat
* `null` as "no allowlist, allow everyone" (backward compatible with
* demo/dev where you want any Google account to get in).
*/
function parseAdminAllowlist(): Set<string> | null {
const raw = process.env.ADMIN_ALLOWED_EMAILS;
if (!raw) return null;
const emails = raw
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
return emails.length > 0 ? new Set(emails) : null;
}
export const { handlers, auth, signIn, signOut } = NextAuth({ export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig, ...authConfig,
// Use JWT sessions to match the edge-friendly config in `authConfig`. // Use JWT sessions to match the edge-friendly config in `authConfig`.
@@ -96,7 +69,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// must use JWT. The Postgres adapter is still wired up so that user // 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 // records are created/updated when a new OAuth sign-in happens — but
// the session itself is stored in the cookie as an encrypted JWT. // the session itself is stored in the cookie as an encrypted JWT.
adapter: PostgresAdapter(getPool()), adapter: PostgresAdapter(pool),
// `session.strategy` is inherited from `authConfig` ("jwt") // `session.strategy` is inherited from `authConfig` ("jwt")
providers: [ providers: [
// Re-declare the providers from authConfig and append the dev // Re-declare the providers from authConfig and append the dev
@@ -105,30 +78,52 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig.providers, ...authConfig.providers,
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []), ...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
], ],
callbacks: {
/**
* Sign-in gate. If ADMIN_ALLOWED_EMAILS is set, the user's email
* must be in the list. Returns `false` to deny the sign-in.
*
* The dev credentials provider is exempt — dev users have synthetic
* emails like "admin@dev.local" and shouldn't be blocked by a
* production allowlist.
*/
async signIn({ user, account }) {
// Bypass allowlist for the dev credentials provider.
if (account?.provider === "dev-login") return true;
const allowlist = parseAdminAllowlist();
if (!allowlist) return true; // No allowlist configured — open mode.
const email = user.email?.toLowerCase();
if (!email || !allowlist.has(email)) {
console.warn(
`[auth] signIn denied: ${email ?? "<no email>"} not in ADMIN_ALLOWED_EMAILS`
);
return false;
}
return true;
},
},
events: { events: {
/** /**
* First-time sign-in: auto-create a `platform_admin` row in * First-time sign-in: auto-create a `platform_admin` row in
* `admin_users` keyed to this auth.js user id, mirroring the legacy * `admin_users` keyed to this Auth.js user id. The RPC is
* `rc_auth_uid` flow. This is the seam between the new auth layer * idempotent (ON CONFLICT DO NOTHING) so repeat sign-ins are no-ops.
* and the existing admin authorization model. *
* This is the seam between the new Auth.js auth layer and the
* existing admin authorization model. After this fires, the user
* is recognized by `getAdminUser()` in `src/lib/admin-permissions.ts`.
*/ */
async signIn({ user }) { async signIn({ user }) {
try { try {
const pool = getPool();
const userId = user.id; const userId = user.id;
if (!userId) return; if (!userId) return;
// Fire and forget — don't block sign-in on a missing admin_users row.
await pool.query( await pool.query(
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`, "SELECT * FROM upsert_admin_user_for_authjs($1)",
[userId] [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) { } catch (e) {
// eslint-disable-next-line no-console // Don't block sign-in on a missing admin_users row.
console.warn("[auth] signIn event error (non-fatal):", e); console.warn("[auth] signIn event error (non-fatal):", e);
} }
}, },
+49
View File
@@ -0,0 +1,49 @@
import { Pool } from "pg";
/**
* Shared `pg.Pool` for direct Postgres access.
*
* This is the single connection pool for the entire app — server actions,
* API routes, and Auth.js all import `pool` from here. No more ad-hoc pools
* in individual files.
*
* Replaces the Supabase JS client (see CLAUDE.md "Supabase is being
* removed in favor of a direct Postgres connection"). SECURITY DEFINER
* RPCs are the recommended way to do reads/writes; this pool is the
* transport.
*
* Connection resolution:
* 1. `DATABASE_URL` — preferred, single connection string
* 2. `SUPABASE_DB_URL` — legacy, for projects still on Supabase
* 3. `POSTGRES_URL` — alternative
*
* Singleton pattern: `globalThis.__pgPool` so Next.js hot reload doesn't
* open a new pool on every request.
*/
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
function createPool(): Pool {
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 deploy workflow and `.env.example` document
// the required vars.
console.warn(
"[db] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — pg pool will fail on first query."
);
}
return new Pool({
connectionString,
max: 10,
idleTimeoutMillis: 30_000,
});
}
export const pool: Pool =
globalForPool.__pgPool ?? (globalForPool.__pgPool = createPool());
+72 -15
View File
@@ -1,26 +1,83 @@
import NextAuth from "next-auth"; import { NextResponse, type NextRequest } from "next/server";
import { authConfig } from "@/auth.config";
/** /**
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16). * 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()`? * Routing policy:
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that * 1. `/admin/*` with no auth cookie + `ALLOW_DEV_LOGIN !== "false"`
* knows which routes need a session. We reuse it here at the edge. * → set `dev_session=platform_admin` and let the request through.
* 2. It auto-populates `request.auth` with the session (JWT-decoded) * This makes `/admin` "just work" in dev/demo without any login
* for any server component/page that reads `auth()` later. * 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 * Auth-cookie flavours recognised:
* in `src/auth.config.ts`. * - `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 = { 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"], matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
}; };
@@ -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';