Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f731f7739 | |||
| 5654ebaecd | |||
| 1cecbce392 | |||
| b63d0415ab | |||
| e2e56252ec | |||
| 48ce5665b9 | |||
| 2d55791458 | |||
| 6c5ca6829f | |||
| 1e9f9c0414 | |||
| 53d995fc99 | |||
| e499139c74 | |||
| 7489da3da0 |
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +27,10 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) return { error: error.message };
|
if (error) return { error: error.message };
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function TestAuthPage() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
|
|
||||||
let adminUser = null;
|
|
||||||
let error: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
adminUser = await getAdminUser();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error = e instanceof Error ? e.message : String(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_auth_uid")
|
|
||||||
? <span className="text-emerald-400">SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
|
|
||||||
: <span className="text-red-400">NOT SET</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_access_token")
|
|
||||||
? <span className="text-yellow-400">SET (not needed)</span>
|
|
||||||
: <span className="text-zinc-500">NOT SET (OK)</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
|
||||||
{error ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-red-400 font-bold">ERROR</p>
|
|
||||||
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
|
|
||||||
</div>
|
|
||||||
) : adminUser ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
|
|
||||||
{JSON.stringify({
|
|
||||||
id: adminUser.id,
|
|
||||||
user_id: adminUser.user_id,
|
|
||||||
role: adminUser.role,
|
|
||||||
brand_id: adminUser.brand_id,
|
|
||||||
active: adminUser.active,
|
|
||||||
}, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-red-400">NOT AUTHENTICATED — null returned</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-4 border-t border-zinc-800">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
|
|
||||||
Go to Admin
|
|
||||||
</a>
|
|
||||||
<form action="/api/logout" method="POST">
|
|
||||||
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const uid =
|
|
||||||
cookieStore.get("rc_auth_uid")?.value ??
|
|
||||||
cookieStore.get("rc_uid")?.value ??
|
|
||||||
null;
|
|
||||||
return NextResponse.json({ uid });
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
|
||||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
|
||||||
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
|
|
||||||
|
|
||||||
let adminUsersStatus = "not_tried";
|
|
||||||
let adminUsersResult: string | null = null;
|
|
||||||
|
|
||||||
if (rcAuthUid) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
if (supabaseUrl && serviceKey) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
adminUsersStatus = String(res.status);
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
adminUsersResult = JSON.stringify(data);
|
|
||||||
} catch (e) {
|
|
||||||
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "missing_env_vars";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "no_rc_auth_uid_cookie";
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
cookies: {
|
|
||||||
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
|
|
||||||
rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
|
|
||||||
rc_access_token: rcAccessToken ? "present" : null,
|
|
||||||
all_cookie_names: allCookies.map(c => c.name),
|
|
||||||
},
|
|
||||||
admin_users: {
|
|
||||||
status: adminUsersStatus,
|
|
||||||
result: adminUsersResult,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
|
||||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })),
|
|
||||||
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING",
|
|
||||||
rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING",
|
|
||||||
raw_rc_auth_uid: rcAuthUid ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
if (!uid) {
|
|
||||||
return NextResponse.json({ error: "No uid cookie found" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !serviceKey) {
|
|
||||||
return NextResponse.json({
|
|
||||||
uid,
|
|
||||||
error: "Missing env vars",
|
|
||||||
supabaseUrl: supabaseUrl ?? "MISSING",
|
|
||||||
serviceKeyPresent: !!serviceKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact same lookup as getAdminUser
|
|
||||||
const lookupRes = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
|
|
||||||
let adminUsers: unknown[] = [];
|
|
||||||
let lookupOk = lookupRes.ok;
|
|
||||||
let lookupStatus = lookupRes.status;
|
|
||||||
let lookupData: unknown = null;
|
|
||||||
|
|
||||||
if (lookupRes.ok) {
|
|
||||||
lookupData = await lookupRes.json().catch(() => []);
|
|
||||||
adminUsers = Array.isArray(lookupData) ? lookupData : [];
|
|
||||||
} else {
|
|
||||||
lookupData = await lookupRes.text().catch(() => "unknown error");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminUsers.length > 0) {
|
|
||||||
return NextResponse.json({
|
|
||||||
uid,
|
|
||||||
result: "found",
|
|
||||||
adminUser: adminUsers[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try auto-create
|
|
||||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
if (!UUID_REGEX.test(uid)) {
|
|
||||||
return NextResponse.json({ uid, result: "invalid_uuid" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
user_id: uid, role: "platform_admin", brand_id: null, active: true,
|
|
||||||
can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
|
|
||||||
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
|
|
||||||
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true,
|
|
||||||
can_manage_settings: true, must_change_password: false
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
uid,
|
|
||||||
result: "auto_created",
|
|
||||||
lookupOk,
|
|
||||||
lookupStatus,
|
|
||||||
adminUsersFound: adminUsers.length,
|
|
||||||
postStatus: postRes.status,
|
|
||||||
postOk: postRes.ok,
|
|
||||||
postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
|
||||||
response.cookies.set("dev_session", "platform_admin", {
|
|
||||||
path: "/",
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
|
||||||
response.cookies.set("dev_session", "platform_admin", {
|
|
||||||
path: "/",
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
|
||||||
const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"];
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const role = url.searchParams.get("role") ?? "platform_admin";
|
|
||||||
const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin";
|
|
||||||
|
|
||||||
const origin = url.origin;
|
|
||||||
|
|
||||||
const response = NextResponse.redirect(new URL("/admin", origin));
|
|
||||||
|
|
||||||
const cookieOptions = {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
response.cookies.set("dev_session", safeRole, {
|
|
||||||
...cookieOptions,
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, {
|
|
||||||
...cookieOptions,
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const { email, password } = await request.json().catch(() => ({}));
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
return NextResponse.json({ error: "Email and password are required." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
|
||||||
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const authRes = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", apikey: supabaseAnonKey },
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const authData = await authRes.json().catch(() => null);
|
|
||||||
|
|
||||||
if (!authRes.ok || !authData?.access_token) {
|
|
||||||
const msg = authData?.error_description ?? authData?.error ?? "Invalid credentials.";
|
|
||||||
return NextResponse.json({ ok: false, error: msg }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = authData.user?.id ?? authData.user_id;
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ ok: false, error: "Auth succeeded but user ID missing." }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set cookie + return JSON — client reads this and navigates
|
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
|
||||||
const response = NextResponse.json({ ok: true });
|
|
||||||
response.cookies.set("rc_auth_uid", userId, {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
secure: isProd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
// Clear all auth cookies (new + legacy)
|
|
||||||
cookieStore.delete("rc_access_token");
|
|
||||||
cookieStore.delete("rc_uid");
|
|
||||||
cookieStore.delete("rc_auth_uid");
|
|
||||||
cookieStore.delete("rc_auth_token");
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const { userId } = await request.json().catch(() => ({}));
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
|
||||||
cookieStore.set("rc_auth_uid", userId, {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: isProd ? "strict" : "lax",
|
|
||||||
secure: isProd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
Reference in New Issue
Block a user