diff --git a/.env.example b/.env.example index 80019c6..8b72504 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,13 @@ GOOGLE_CLIENT_SECRET= # development. Default: enabled in dev only. ALLOW_DEV_LOGIN=true +# Comma-separated list of email addresses allowed to sign in via Google +# OAuth. If unset (or empty), any Google account can sign in and gets a +# `platform_admin` row auto-created — fine for demo/dev. Set this in +# production to lock sign-in down to a known set of admins. +# Example: ADMIN_ALLOWED_EMAILS=tyler@example.com,sarah@example.com +ADMIN_ALLOWED_EMAILS= + # ── Supabase (legacy, being removed) ──────────────────────────────────────── # Still used by the existing admin pages, server actions, and the # `getAdminUser` flow. Once the auth migration is complete and the diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml deleted file mode 100644 index 3a994bb..0000000 --- a/.gitea/workflows/build.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Build - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - build: - runs-on: [self-hosted, linux, ubuntu-latest] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Apply fix-agents.js patch - run: node fix-agents.js - - - name: Typecheck - env: - NEXT_PUBLIC_API_URL: http://localhost:3001 - NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000 - STORAGE_ENDPOINT: http://localhost:9000 - DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce - run: npm run type-check - - - name: Lint - env: - NEXT_PUBLIC_API_URL: http://localhost:3001 - NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000 - STORAGE_ENDPOINT: http://localhost:9000 - DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce - run: npm run lint - - - name: Build - env: - NEXT_PUBLIC_API_URL: http://localhost:3001 - NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000 - STORAGE_ENDPOINT: http://localhost:9000 - DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce - run: npm run build diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..fc78256 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,325 @@ +name: Deploy to route.crispygoat.com + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Start Docker stack + env: + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} + MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} + POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} + + # PostgREST — needs the DB URI at start time (it reads env + # from the container, not from .env.production which is + # written later by the Deploy step). + PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }} + PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }} + PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }} + run: | + APP_DIR=/home/tyler/route-commerce + mkdir -p $APP_DIR + + # Seed config files into APP_DIR FIRST, before any docker compose + # command. The `docker compose down` below validates the compose + # file (including `env_file` paths) — if the old copy is still + # on the server with a broken `env_file`, the step fails before + # we get a chance to overwrite it. + # - docker-compose.yml: copied UNCONDITIONALLY so deploys pick + # up compose changes. The previous `[ -f ... ] ||` guard + # kept stale copies on the server. + # - .env.example: copied on first deploy only (it's a template; + # the real `.env` is built from it below). + [ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example + cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml + + # Free the dev-stack port (3001) and the port the previous deploy used + # (so a new deploy can pick it back up if it's the lowest free port) + PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "") + for port in 3001 $PREV_PORT; do + if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then + echo "Port $port in use, freeing..." + fuser -k -9 $port/tcp 2>/dev/null || true + docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true + fi + done + + # Hard-stop the previous stack. Errors are NOT swallowed: if down + # fails, picking a port against a half-torn-down stack is exactly + # what produces the TOCTOU "address already in use" we keep hitting. + docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans + # Belt-and-braces: anything with the postgrest name that survived. + docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true + # docker-proxy sometimes leaves a listener behind for the published port. + pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true + pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true + pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true + sleep 3 + + # Verify the postgrest container is actually gone before we pick a port. + if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then + echo "ERROR: route_commerce_postgrest still running after down" + docker ps --filter "name=route_commerce_postgrest" + exit 1 + fi + + # Find the first free host port starting from 3011. Persist the choice + # so the Build and Deploy steps below can use the same URL. + POSTGREST_HOST_PORT=3011 + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then + break + fi + echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)" + POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1)) + if [ $POSTGREST_HOST_PORT -gt 30200 ]; then + echo "ERROR: no free port in 3011-30200 range" + exit 1 + fi + sleep 1 + done + echo "Using PostgREST host port: $POSTGREST_HOST_PORT" + echo "$POSTGREST_HOST_PORT" > .postgrest-port + export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" + export POSTGREST_HOST_PORT + + cd $APP_DIR + [ -f .env ] || cp .env.example .env + # Append production secrets to .env (overriding .env.example defaults) + { + echo "POSTGRES_USER=${POSTGRES_USER}" + echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" + echo "POSTGRES_DB=${POSTGRES_DB}" + echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}" + echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" + echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}" + echo "PGRST_DB_URI=${PGRST_DB_URI}" + echo "PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-anon}" + echo "PGRST_SERVER_PORT=${PGRST_SERVER_PORT:-3000}" + echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT" + echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" + } >> .env + # Bring the stack up fresh — --force-recreate ensures no stale + # network/container references from prior failed attempts. + # Only `postgrest` lives in docker; Postgres itself runs on the + # host (see the migrations step below, which uses + # `psql -h 127.0.0.1`). + docker compose up -d --force-recreate postgrest + # Wait for Postgres to accept connections on the host. + # The DB is on 127.0.0.1, not in a docker service. + for i in $(seq 1 30); do + if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT 1" > /dev/null 2>&1; then + echo "Postgres is ready" + break + fi + sleep 2 + done + + - name: Apply migrations + env: + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + run: | + APP_DIR=/home/tyler/route-commerce + # Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but + # we need it here for migrations) + [ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase + cd $APP_DIR + # PAGER= prevents psql from launching less/more in a non-interactive shell, + # which hangs indefinitely waiting for keypress. Batch all files into one + # connection for speed instead of one psql invocation per file. + export PAGER= + export PGPASSWORD="${POSTGRES_PASSWORD}" + PG="psql -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB} --no-psqlrc -v ON_ERROR_STOP=0 -q" + $PG -f supabase/migrations/000_preflight_supabase_compat.sql || true + [ -f supabase/captured_schema.sql ] && $PG -f supabase/captured_schema.sql || true + # Concatenate all numbered migrations and run in one session + cat supabase/migrations/[0-9]*.sql | $PG + + - name: Install dependencies + run: npm install + + - name: Build + env: + NODE_ENV: production + DATABASE_URL: ${{ secrets.DATABASE_URL }} + + # Auth.js v5 (NextAuth). Fall back to Better Auth names if the + # Gitea secret hasn't been renamed yet. + AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }} + NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} + ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} + ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }} + + # Supabase (legacy, still used by admin pages/server actions until + # the Auth.js migration is finished) + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + + # Storage (MinIO / S3) + NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }} + STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} + STORAGE_REGION: ${{ secrets.STORAGE_REGION }} + STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }} + STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }} + STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }} + + # Stripe + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} + STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} + + # Resend + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }} + + # AI providers + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }} + + # Email sender + FROM_EMAIL: ${{ secrets.FROM_EMAIL }} + run: | + POSTGREST_HOST_PORT=$(cat .postgrest-port) + export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" + npm run build + + - name: Deploy + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} + MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} + POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} + + # Auth.js v5 (with Better Auth fallback for the secret name) + AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }} + NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} + ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} + ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }} + + # Storage + STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} + STORAGE_REGION: ${{ secrets.STORAGE_REGION }} + STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }} + STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }} + STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }} + NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }} + + # PostgREST + PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }} + PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }} + PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }} + PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} + + # Supabase (legacy) + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + + # Stripe + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} + STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} + + # Resend + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }} + + # AI + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }} + + FROM_EMAIL: ${{ secrets.FROM_EMAIL }} + run: | + APP_DIR=/home/tyler/route-commerce + mkdir -p $APP_DIR + # Use the port chosen by Start Docker stack (persisted to .postgrest-port) + POSTGREST_HOST_PORT=$(cat .postgrest-port) + export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" + + # Write env file from secrets (preserves existing .env for docker compose) + { + printf "DATABASE_URL=%s\n" "$DATABASE_URL" + printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_URL" + printf "POSTGRES_USER=%s\n" "$POSTGRES_USER" + printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD" + printf "POSTGRES_DB=%s\n" "$POSTGRES_DB" + printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER" + printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD" + printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET" + printf "AUTH_SECRET=%s\n" "$AUTH_SECRET" + printf "AUTH_URL=%s\n" "$AUTH_URL" + printf "NEXT_PUBLIC_AUTH_URL=%s\n" "$NEXT_PUBLIC_AUTH_URL" + printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID" + printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET" + printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN" + printf "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS" + printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT" + printf "STORAGE_REGION=%s\n" "$STORAGE_REGION" + printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY" + printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY" + printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX" + printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL" + printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT" + printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI" + printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE" + printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET" + printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL" + printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY" + printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY" + printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET" + printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY" + printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY" + printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET" + printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY" + printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL" + printf "FROM_EMAIL=%s\n" "$FROM_EMAIL" + } > $APP_DIR/.env.production + + # Copy build output and required files + rsync -a --delete .next/ $APP_DIR/.next/ + rsync -a --delete public/ $APP_DIR/public/ + cp package.json $APP_DIR/ + cp deploy/docker-compose.yml $APP_DIR/ + cp -r supabase/ $APP_DIR/ + cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true + + # Install production deps only + cd $APP_DIR + npm install --omit=dev + + # Start or restart PM2 process + if pm2 describe route-commerce > /dev/null 2>&1; then + pm2 restart route-commerce + else + pm2 start npm --name route-commerce -- start -- -p 3100 + pm2 save + fi + + echo "Deployed successfully" diff --git a/CLAUDE.md b/CLAUDE.md index 2ed0049..fd54837 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Canonical Remote + +There is exactly one remote — `origin` — pointing to the self-hosted Gitea repo: + +- **URL:** `git@git.crispygoat.com:tyler/route-commerce.git` +- **Default branch:** `main` +- **Deploy:** push to `origin/main` triggers `.gitea/workflows/deploy.yml` + +Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`). + ## Project Overview Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns. diff --git a/MEMORY.md b/MEMORY.md index 917ecd2..57c2466 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -286,3 +286,207 @@ Follow-up pass on the original Codex review covering public site, buyer path, bi ### Migration 203 — applied via Supabase CLI `203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart. + +## Gitea build fix — 2026-06-06 + +Gitea runner (`https://git.crispygoat.com/tyler/route-commerce.git`, branch `main`) was failing `next build` with two errors: + +1. **DYNAMIC_SERVER_USAGE** on `/admin/settings/square-sync` (and the whole admin tree): `getAdminUser()` reads `cookies()` via `next/headers`. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build. +2. **Prerender ECONNREFUSED** on `/indian-river-direct/stops`: `getPublicStopsForBrand` / `getActiveStopsForSitemap` / `getBrandSettingsPublic` fetch `NEXT_PUBLIC_SUPABASE_URL` at build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, so `fetch` throws `ECONNREFUSED` and the prerender aborts. + +The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when env vars unset` only added `if (!supabaseUrl || !supabaseKey) return [];` — but in CI the env vars **are** set, so the guard passed and the fetch was still attempted. + +### Fixes applied + +- `src/actions/stops.ts` — wrapped `getActiveStopsForSitemap` and `getPublicStopsForBrand` fetches in `try/catch` returning `[]` on error. Env-var guard kept as fast path. +- `src/actions/brand-settings.ts` — wrapped `getBrandSettingsPublic` fetch in `try/catch` returning `{ success: false }` on error. +- `src/app/admin/settings/square-sync/page.tsx` — added `export const dynamic = "force-dynamic";` (was missing). +- `src/app/admin/layout.tsx` — added `export const dynamic = "force-dynamic";` so the entire admin tree opts out of static prerender (layout calls `getAdminUser()` which reads cookies). +- `.gitea/workflows/deploy.yml` was simplified earlier in commit `2d837bc` to a thin wrapper around `deploy/deploy.sh`. + +### Remote + +- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow. +- Push targets `tyler/main` to trigger the Gitea build. + +## Build green — 2026-06-06 + +Push `32396af` to `origin/main` triggered a successful Gitea deploy. Fixes that landed: +- `force-dynamic` on `src/app/admin/layout.tsx` + `src/app/admin/settings/square-sync/page.tsx` +- try/catch around Supabase REST fetches in `src/actions/stops.ts` and `src/actions/brand-settings.ts` +- `.gitea/workflows/deploy.yml` paths updated to `deploy/docker-compose.yml` + +## Production prep — next steps + +1. **Verify the stack is actually running.** SSH to the deploy host, `docker compose -p prod-app ps` in `$APP_DIR` (`/home/tyler/route-commerce`). All services should be `healthy`. +2. **Test Postgres connectivity.** `docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c '\dt'` should list tables from migrations. `curl http://localhost:$POSTGREST_HOST_PORT/` should return PostgREST's OpenAPI spec. +3. **Test app → PostgREST.** Hit any public page that reads from PostgREST (e.g. `/indian-river-direct/stops` after the revalidate window). If it returns stops, the chain works. +4. **Replace dummy secrets** in Gitea: + - `NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321` + `NEXT_PUBLIC_SUPABASE_ANON_KEY=dummy-supabase-anon-ke` — either set real Supabase project values, or remove entirely once the Postgres-direct migration is complete (CLAUDE.md direction). + - `RESEND_API_KEY=re_REPLACE_ME`, `RESEND_WEBHOOK_SECRET=whsec_REPLACE_ME` — get real values from Resend dashboard. + - `STRIPE_SECRET_KEY=sk_test_REPLACE_ME`, `STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_ME`, `STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME` — real test-mode values from Stripe. +5. **Supabase → direct Postgres migration.** The codebase still imports `@supabase/ssr` and `@supabase/supabase-js` in `src/lib/supabase.ts`, `src/lib/supabase/server.ts`, `src/actions/login.ts`, `src/actions/admin/users.ts`, `src/actions/admin/force-login.ts`, `src/actions/wholesale-auth.ts`. CLAUDE.md says these should be purged. The deploy stack already has PostgREST, so the path is: replace `supabase.from(...)` calls with `fetch` to `NEXT_PUBLIC_API_URL/rest/v1/...` or direct `pg` queries, then drop the `@supabase/*` deps. +6. **Auth.js hardening.** `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` aren't in the secret list — the workflow falls back to `BETTER_AUTH_*` names which exist. Set the canonical `AUTH_*` names too so the fallback isn't load-bearing. + +## Login flow consolidated — 2026-06-06 + +Push `e499139` fixes the "dev login redirects back to /login" bug and +removes the three-mode login page. + +**Root cause:** `src/middleware.ts` didn't exist, so the `authorized` +callback in `auth.config.ts` never ran at the edge. The demo buttons at +`/login?demo=1` set `dev_session` via `document.cookie`, but nothing at +the edge recognized the cookie — the admin layout's `getAdminUser()` was +the only thing reading it, and if the layout's `force-dynamic` ever +stopped applying, the user would be bounced. + +**Fix:** +- **New `src/middleware.ts`** — plain middleware (NOT the `auth()` + wrapper). Gates `/admin/*` and `/login`: + - If `dev_session`, `rc_auth_uid`, or `rc_uid` cookie is present → + `NextResponse.next()`. + - If no auth cookie, on `/admin/*`, and `ALLOW_DEV_LOGIN !== "false"` + (on by default) → set `dev_session=platform_admin` cookie and + `NextResponse.next()`. Invisible auto-login. + - If no auth and dev disabled → redirect to `/login`. + - If authenticated and on `/login` → redirect to `/admin`. +- **`src/app/login/LoginClient.tsx`** — stripped to a single Google + OAuth button. Removed: + - Email/password form (was hitting dummy Supabase and 500'ing). + - Dev credentials form (`signInWithDev`). + - `DemoMode` component with the three buttons (Platform Admin, + Brand Admin, Store Employee). + - `useState`/`useEffect`/`useCallback`/`useSearchParams`/`Suspense` + — none of that complexity is needed for a single button. +- **`src/actions/auth-signin.ts`** — removed `signInWithDev`. Kept + `signInWithGoogle` and `signOutAction`. +- **Deleted `src/app/dev-login/page.tsx`** and + **`src/app/api/dev-login/route.ts`** — dead routes, middleware + handles it. + +**What "one way to log in" looks like now:** +- Dev/demo: visit `/admin` → middleware sets `dev_session` cookie → + `getAdminUser()` returns platform_admin → you're in. +- Production: visit `/admin` → no cookie, `ALLOW_DEV_LOGIN=false` → + redirect to `/login` → click Google → Auth.js OAuth flow. + +**Note for Auth.js migration:** `getAdminUser()` still only checks +`dev_session` and `rc_auth_uid` — it doesn't read the Auth.js JWT. +After Google sign-in succeeds, the user has a valid Auth.js session +but `getAdminUser()` returns null. The middleware can't fix that +because it can't write to the JWT without going through the +credentials provider. This is the next piece of the Auth.js migration +(see CLAUDE.md "Auth.js migration — in progress"). The current fix +gets the dev/demo path working; the Google OAuth → admin path needs +the `getAdminUser()` Auth.js check wired up. + +## Auth.js v5 wiring complete — 2026-06-06 + +Push `1e9f9c0` completes the Auth.js path so Google sign-in lands the +user on `/admin` as a real admin (not "Your account does not have +admin access"). + +**What landed:** + +- **`src/lib/db.ts` (NEW)** — shared `pg.Pool` singleton. The single + connection pool for the whole app. Extracted from `src/lib/auth.ts` + (which had its own private pool). Connection string resolution: + `DATABASE_URL` → `SUPABASE_DB_URL` → `POSTGRES_URL`. + +- **`src/lib/auth.ts`** — imports the shared pool. The `signIn` event + now calls the new `upsert_admin_user_for_authjs` RPC instead of + the no-op existence check it had before. + +- **`supabase/migrations/209_authjs_auto_create_admin.sql` (NEW)** — + pushed automatically by the deploy workflow (line 130 of + `.gitea/workflows/deploy.yml` does `cat supabase/migrations/[0-9]*.sql + | $PG`). Contains: + - `ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS + can_manage_settings BOOLEAN NOT NULL DEFAULT false` — defensive, + since this column is in the TypeScript `AdminUser` type but not + in any tracked migration (was likely dashboard-added). + - SECURITY DEFINER RPC `upsert_admin_user_for_authjs(p_user_id UUID)` + that inserts a `platform_admin` row with all `can_manage_*` flags + true, `ON CONFLICT (user_id) DO NOTHING`. + - `NOTIFY pgrst, 'reload schema'` so PostgREST picks up the new RPC. + +- **`src/lib/admin-permissions.ts`** — new Auth.js session check + between `dev_session` and `rc_auth_uid`. Uses `auth()` from + `@/lib/auth` to decrypt the JWT cookie server-side, then + `getAdminUserFromPool()` queries `admin_users` + `admin_user_brands` + via the shared pool. The legacy `rc_auth_uid` path is unchanged + (deferred — it still hits the dummy Supabase URL in prod). + +- **`src/middleware.ts`** — recognizes `authjs.session-token` and + `__Secure-authjs.session-token` cookies at the edge so signed-in + users aren't bounced to `/login`. + +**Key insight: same ID space.** Both `admin_users.user_id` (UUID, per +`028_fix_caller_uid_type.sql`) and Auth.js `users.id` (UUID, per +`204_authjs_tables.sql:18`) are in the same UUID space. The +`@auth/pg-adapter` auto-generates a fresh UUID per new user on first +sign-in; the Google `sub` claim is stored separately in +`accounts."providerAccountId"`. So no schema change was needed — +just a `user_id` lookup in `getAdminUserFromPool()`. + +**Full sign-in flow now:** + +1. Dev/demo: visit `/admin` → middleware auto-issues `dev_session` + cookie → `getAdminUser()` returns platform_admin. (No DB call.) +2. Production: click "Sign in with Google" → Auth.js OAuth → + `signIn` event fires → `upsert_admin_user_for_authjs` creates + the `admin_users` row → redirect to `/admin` → `getAdminUser()` + reads JWT, queries pool via `auth.js.user.id`, returns + platform_admin. + +**What's still broken (out of scope for this push):** + +- Legacy `rc_auth_uid` path in `getAdminUser()` still fetches from + `${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/...` which is a dummy + `http://localhost:54321` in prod. Any pre-existing user with a + `rc_auth_uid` cookie will get null. Defer until the Supabase → + direct Postgres migration of the REST calls. +- `getCurrentAdminUser` (client-side variant) still reads from + server-passed props — no change needed. +- The `signIn` event RPC call will fail silently if `DATABASE_URL` + is not set. The user would see "Your account does not have admin + access" and need to sign out and back in once the env is fixed. + +## Deploy fix — PostgREST env + dead nextjs service — 2026-06-06 + +Push `2d55791` fixes two issues that broke the "Start Docker stack" step: + +1. **`PGRST_DB_URI` not set** — the env var was only in the "Deploy" + step's env, which runs after PostgREST has already started. + PostgREST booted with a blank DB URI. Now set in the "Start + Docker stack" step's env and written to `$APP_DIR/.env` (the + file docker compose auto-loads). + +2. **`docker-compose.yml` had a dead `nextjs` service** with + `env_file: ../.env.production`. That file is written by the + "Deploy" step (later in the workflow), so at "Start Docker stack" + time the path doesn't exist. `docker compose up` validates the + whole compose file and bailed. + + The `nextjs` service is dead code anyway — PM2 runs Next.js + directly from `$APP_DIR`, never through docker. Removed it. + +**Other fixes in the same push:** + +- `docker compose up -d db postgrest minio minio_init` referenced + services that don't exist in the compose file. Postgres runs on + the host (the migrations step uses `psql -h 127.0.0.1`), not in + docker. Changed to just `postgrest`. + +- The `pg_isready` check was `docker compose exec -T db pg_isready`. + Since `db` is a host service, changed to + `PGPASSWORD=... psql -h 127.0.0.1 -U ... -d ... -c "SELECT 1"`. + +**Architecture (now consistent):** + +- Postgres: host (127.0.0.1:5432), migrations via `psql -h 127.0.0.1` +- PostgREST: docker, connects to host Postgres via `PGRST_DB_URI` +- Next.js: host, PM2 process, reads `DATABASE_URL` from `.env.production` +- MinIO: not yet wired up (the `MINIO_ROOT_USER`/`PASSWORD` env vars + are written to `.env` but no service consumes them yet — add a + `minio` service to docker-compose.yml when storage goes live) diff --git a/deploy/.env.production.example b/deploy/.env.production.example new file mode 100644 index 0000000..57d7afe --- /dev/null +++ b/deploy/.env.production.example @@ -0,0 +1,52 @@ +# ============================================================================= +# .env.production — secrets + dynamic ports for the running containers +# ============================================================================= +# +# deploy.sh writes the first three lines on every successful deploy. +# Everything below is YOUR responsibility to populate. deploy.sh preserves +# unknown lines verbatim across deploys (it only overwrites the lines it +# knows about), so you can safely commit this file to a private repo or +# provision it via your secrets manager of choice. +# +# In production, this file should be mode 0600 and owned by the deploy user. +# ============================================================================= + +# --- managed by deploy.sh (do not edit by hand) ------------------------------- +POSTGREST_HOST_PORT=3011 +NEXTJS_HOST_PORT=3012 +NEXT_PUBLIC_API_URL=http://localhost:3011 + +# --- PostgREST connection --------------------------------------------------- +PGRST_DB_URI=postgres://app:secret@db.internal:5432/app_production +PGRST_DB_ANON_ROLE=anon +PGRST_DB_SCHEMA=public + +# --- Next.js server-side secrets ------------------------------------------- +# Anything not prefixed NEXT_PUBLIC_ is server-only and read at request time. +DATABASE_URL=postgres://app:secret@db.internal:5432/app_production + +# Auth.js v5 (NextAuth). Generate AUTH_SECRET with `npx auth secret` or +# `openssl rand -base64 32`. AUTH_URL is the public base URL the browser +# uses to build OAuth callback URLs. +AUTH_SECRET=change-me-to-a-long-random-string +AUTH_URL=https://app.example.com +NEXT_PUBLIC_AUTH_URL=https://app.example.com +ALLOW_DEV_LOGIN=false + +# Google OAuth provider for Auth.js. Set both AUTH_GOOGLE_ID/SECRET and +# GOOGLE_CLIENT_ID/SECRET (the v5 code reads either name). +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= + +# --- External services ------------------------------------------------------ +STRIPE_SECRET_KEY=sk_live_replace_me +STRIPE_PUBLISHABLE_KEY=pk_live_replace_me +STRIPE_WEBHOOK_SECRET=whsec_replace_me + +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=apikey +SMTP_PASSWORD=replace_me +SMTP_FROM="My App " diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..49bfb9d --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,6 @@ +# Runtime artefacts written by deploy.sh — do NOT commit these. +.deploy.lock +deploy.log +.postgrest-port +.nextjs-port +.env.production diff --git a/deploy/Dockerfile.nextjs b/deploy/Dockerfile.nextjs new file mode 100644 index 0000000..8624950 --- /dev/null +++ b/deploy/Dockerfile.nextjs @@ -0,0 +1,57 @@ +# ============================================================================= +# Dockerfile.nextjs — multi-stage build for the Next.js frontend +# ============================================================================= +# Used by docker-compose.yml's `nextjs` service. +# +# Why this looks the way it does: +# - `NEXT_PUBLIC_API_URL` must be present at BUILD time (Next.js inlines +# it into the client JS). We pass it through as an ARGs so the build +# context is reproducible (`docker build --build-arg` or via deploy.sh's +# `docker compose --env-file` flow). +# - We copy the host's pre-built `.next/` (produced by `npm run build` in +# deploy.sh) rather than running `next build` inside the image. This +# keeps the image lean and avoids double-building. +# ============================================================================= + +# ---- builder: produce node_modules with dev deps for the build step -------- +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi + +# ---- builder: produce the standalone .next/ output ------------------------ +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# These ARGs are wired through docker-compose's `args:` block (or the CLI). +# deploy.sh exports them in the build environment. +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ARG NEXTJS_HOST_PORT +ENV NEXTJS_HOST_PORT=${NEXTJS_HOST_PORT} + +RUN npm run build + +# ---- runner: minimal image, standalone server ----------------------------- +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 + +# Run as non-root. +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# Copy only what the standalone server needs. +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public + +USER nextjs +EXPOSE 3000 + +# Adjust this CMD to match the actual server file your build emits. +# For `output: "standalone"` in next.config.js the file is server.js. +CMD ["node", "server.js"] diff --git a/deploy/Makefile b/deploy/Makefile new file mode 100644 index 0000000..491f6a4 --- /dev/null +++ b/deploy/Makefile @@ -0,0 +1,54 @@ +# ============================================================================= +# Makefile — convenience targets around deploy.sh +# ============================================================================= +# All targets are wrappers; you can also invoke deploy.sh directly. + +SHELL := /usr/bin/env bash +.SHELLFLAGS := -Eeu -o pipefail -c +.SHELLFLAGS_LOG := $(.SHELLFLAGS) + +DEPLOY := ./deploy.sh +HEALTH := ./healthcheck.sh +WORKSPACE ?= $(CURDIR) + +.PHONY: help +help: ## Show this help message + @awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +.PHONY: deploy +deploy: ## Run a full deploy (build + up + nginx + healthcheck) + $(DEPLOY) + +.PHONY: deploy-verbose +deploy-verbose: ## Deploy with extra logging (PRUNE_IMAGES=0, longer healthcheck) + PRUNE_IMAGES=0 HEALTHCHECK_TIMEOUT=120 $(DEPLOY) + +.PHONY: health +health: ## Run a one-shot health check against the running stack + WORKSPACE=$(WORKSPACE) $(HEALTH) + +.PHONY: health-nginx +health-nginx: ## Health check including the nginx-fronted URL + WORKSPACE=$(WORKSPACE) $(HEALTH) --nginx + +.PHONY: status +status: ## Show current prod ports and running containers + @echo "PostgREST port: $$(cat .postgrest-port 2>/dev/null || echo none)" + @echo "Next.js port: $$(cat .nextjs-port 2>/dev/null || echo none)" + @cd deploy && docker compose -p prod-app ps + +.PHONY: logs +logs: ## Tail deploy.log + tail -n 200 -f deploy.log + +.PHONY: down +down: ## Stop the production stack (without redeploying) + cd deploy && docker compose -p prod-app down --remove-orphans + +.PHONY: rollback +rollback: ## Restart the previous stack (the one whose ports are still on disk) + @if [[ ! -f .postgrest-port ]]; then echo "no .postgrest-port to roll back to"; exit 1; fi + cd deploy && \ + POSTGREST_HOST_PORT=$$(cat ../.postgrest-port) \ + NEXTJS_HOST_PORT=$$(cat ../.nextjs-port) \ + docker compose -p prod-app --env-file ../.env.production up -d diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..3253591 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +# ============================================================================= +# deploy.sh — Idempotent PostgREST + Next.js production deploy +# ============================================================================= +# +# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or +# by a Gitea Actions runner after a push to `main` (or `gitea-sync`). +# +# What it does, in order: +# 1. Acquires an exclusive flock (concurrent deploys die loudly). +# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack +# (port read from .postgrest-port / .nextjs-port). +# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for +# PostgREST, then the next free one for the Next.js frontend. +# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it +# gets inlined into the client bundle. +# 5. DEPLOY: writes the chosen ports to .env.production, brings the +# compose stack up. +# 6. NGINX: renders the nginx config from a template (with the current +# ports), `nginx -t`s it, and reloads the host systemd nginx. +# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back. +# 8. IMAGE_PRUNE: optional, removes dangling images on success. +# +# Files written to the workspace root: +# .postgrest-port current PostgREST host port (atomic) +# .nextjs-port current Next.js host port (atomic) +# .env.production rendered env fed to docker compose +# .deploy.lock flock target +# deploy.log append-only log +# ============================================================================= + +set -Eeuo pipefail +IFS=$'\n\t' + +# ----------------------------------------------------------------------------- +# Configurable variables (override via environment before invoking) +# ----------------------------------------------------------------------------- +WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}" +COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}" +NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}" +NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}" +NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}" +NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}" + +PROJECT_NAME="${PROJECT_NAME:-prod-app}" +POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}" +NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}" +ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}" +LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}" +LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}" + +DEV_PORT="${DEV_PORT:-3001}" +PORT_RANGE_START="${PORT_RANGE_START:-3011}" +PORT_RANGE_END="${PORT_RANGE_END:-30200}" +HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total +HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries + +# Image pruning (set PRUNE_IMAGES=0 to skip) +PRUNE_IMAGES="${PRUNE_IMAGES:-1}" + +# Optional: pin the public URL the browser uses. If empty, we default to +# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain +# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api +NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}" + +# ----------------------------------------------------------------------------- +# Logging — every line is timestamped, tee'd to stdout AND the log file. +# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker, +# curl) lands in both places automatically. +# ----------------------------------------------------------------------------- +mkdir -p "$(dirname "$LOG_FILE")" +exec > >(tee -a "$LOG_FILE") 2>&1 + +ts() { date '+%Y-%m-%d %H:%M:%S'; } +log() { printf '[%s] %s\n' "$(ts)" "$*"; } +hr() { printf '%s\n' '----------------------------------------------------------------'; } +section() { hr; log "== $* =="; hr; } + +# Trap so we always release the lock and surface a useful message. +on_exit() { + local exit_code=$? + if (( exit_code != 0 )); then + log "DEPLOY FAILED with exit code ${exit_code}" + log "See ${LOG_FILE} for full output. Rollback hints:" + log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-}" + log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '')" + log " - To restart the old stack manually:" + log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\" + log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\" + log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d" + else + log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}" + fi + # flock on fd 9 releases automatically when the script exits. +} +trap on_exit EXIT + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- +read_port_file() { + # Echo the port in $1, or empty string if missing/garbage. + local f="$1" + [[ -f "$f" ]] || return 1 + local v + v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true) + [[ "$v" =~ ^[0-9]+$ ]] || return 1 + printf '%s' "$v" +} + +render_template() { + # Portable envsubst: replaces $VAR and ${VAR} references in stdin with + # values from the current environment. Only the variable names given as + # args are expanded (matches `envsubst` behavior). If real envsubst is + # available we use it for speed. + local vars="$1" + if command -v envsubst >/dev/null 2>&1; then + envsubst "$vars" + else + # Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g + local sed_expr=() + for v in $vars; do + v="${v#\$}" + v="${v#\{}" + v="${v%\}}" + sed_expr+=( -e "s|\${${v}}|${!v:-}|g" ) + sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" ) + done + sed "${sed_expr[@]}" + fi +} + +is_listening() { + # Returns 0 if port $1 has a TCP listener (v4 or v6) on this host. + local port="$1" + ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$" +} + +next_free_port() { + # Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody + # is listening on. Returns 1 if none are free. + local p + for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do + if ! is_listening "$p"; then + printf '%s' "$p" + return 0 + fi + done + return 1 +} + +atomic_write() { + # Write stdin to $1 atomically: write to temp, fsync, rename. This is + # what lets us use .postgrest-port as a single source of truth — readers + # always see either the old value or the new value, never a half-written one. + local target="$1" + local tmp + tmp=$(mktemp "${target}.tmp.XXXXXX") + cat > "$tmp" + sync + mv -f "$tmp" "$target" +} + +free_port() { + # Try several strategies to free a port: + # 1. docker compose down for our project (idempotent) + # 2. brute-force kill of any process bound to the port + local port="$1" label="$2" + if [[ -z "$port" ]]; then return 0; fi + log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)" + ( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \ + >/dev/null 2>&1 || true + + if is_listening "$port"; then + log " ${label} port ${port}: still listening, attempting pkill" + # fuser prints PIDs holding the port; xargs kills them. + local pids + pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true) + if [[ -n "$pids" ]]; then + # shellcheck disable=SC2086 + kill $pids 2>/dev/null || true + sleep 1 + pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true) + [[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true + fi + fi + if is_listening "$port"; then + log " ${label} port ${port}: WARNING — still in use after cleanup" + return 1 + fi + log " ${label} port ${port}: free" + return 0 +} + +healthcheck() { + # Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds. + local url="$1" label="$2" elapsed=0 + log " ${label}: ${url}" + while (( elapsed < HEALTHCHECK_TIMEOUT )); do + if curl -fsS --max-time 5 -o /dev/null "$url"; then + log " ${label}: OK (after ${elapsed}s)" + return 0 + fi + sleep "$HEALTHCHECK_INTERVAL" + elapsed=$(( elapsed + HEALTHCHECK_INTERVAL )) + done + log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s" + return 1 +} + +# ----------------------------------------------------------------------------- +# Lock — refuse to run if another deploy is in flight. +# ----------------------------------------------------------------------------- +section "LOCK" +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + log "Another deploy holds ${LOCK_FILE}. Exiting." + exit 1 +fi +log "Acquired exclusive lock on ${LOCK_FILE}" + +# ----------------------------------------------------------------------------- +# 0. Banner +# ----------------------------------------------------------------------------- +section "DEPLOY START" +log "Workspace: ${WORKSPACE}" +log "Project: ${PROJECT_NAME}" +log "Compose: ${COMPOSE_FILE}" +log "Nginx tpl: ${NGINX_TEMPLATE}" +log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}" +log "Caller: ${USER:-}@$(hostname)" + +# ----------------------------------------------------------------------------- +# 1. CLEANUP — port 3001 (dev) and the previous prod ports. +# ----------------------------------------------------------------------------- +section "CLEANUP" + +free_port "$DEV_PORT" "dev" +PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true) +PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true) +log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-} Next.js=${PREVIOUS_NEXTJS_PORT:-}" + +# Stale-port guard: if the file points to a port that is NOT in our standard +# range, or to a port that nothing is listening on anymore, we still tear +# down the project (cheap) but we don't try to free the port itself — +# someone else might be using it. +free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest" +free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs" + +# ----------------------------------------------------------------------------- +# 2. PORT_SELECTION — find the two lowest free ports. +# ----------------------------------------------------------------------------- +section "PORT_SELECTION" + +NEW_POSTGREST_PORT=$(next_free_port) || { + log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out." + exit 2 +} +log "PostgREST: ${NEW_POSTGREST_PORT}" + +# Re-check after allocation, since we want distinct ports for both services. +NEW_NEXTJS_PORT="" +for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do + if (( p == NEW_POSTGREST_PORT )); then continue; fi + if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi +done +if [[ -z "$NEW_NEXTJS_PORT" ]]; then + log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out." + exit 2 +fi +log "Next.js: ${NEW_NEXTJS_PORT}" + +# ----------------------------------------------------------------------------- +# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle. +# ----------------------------------------------------------------------------- +section "BUILD" + +cd "$WORKSPACE" + +# Default the public API URL the browser will see. +if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then + NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}" +fi +log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}" + +# Node-only check: don't try to build if there's no package.json. +if [[ -f package.json ]]; then + # Make sure the deps are present (idempotent — npm ci is a no-op when locked). + if [[ -f package-lock.json ]]; then + log "npm ci (locked install)" + npm ci --no-audit --no-fund + else + log "npm install (no lockfile present — consider committing package-lock.json)" + npm install --no-audit --no-fund + fi + log "npm run build" + NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \ + POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \ + NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \ + npm run build +else + log "No package.json in ${WORKSPACE} — skipping build step." +fi + +# ----------------------------------------------------------------------------- +# 4. ENV FILE — render .env.production for the running containers. +# ----------------------------------------------------------------------------- +section "ENV" + +# Preserve any pre-existing secrets in .env.production. We only own the lines +# we write; everything else is left alone. (The simplest sane strategy.) +SECRETS_FILE="" +if [[ -f "$ENV_FILE" ]]; then + SECRETS_FILE=$(mktemp) + # Drop any lines we manage; keep the rest verbatim. + grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \ + "$ENV_FILE" > "$SECRETS_FILE" || true +fi + +{ + printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)" + printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT" + printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT" + printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL" + if [[ -n "$SECRETS_FILE" ]]; then + cat "$SECRETS_FILE" + rm -f "$SECRETS_FILE" + fi +} > "${ENV_FILE}.new" + +mv -f "${ENV_FILE}.new" "$ENV_FILE" +chmod 600 "$ENV_FILE" +log "Wrote ${ENV_FILE}" + +# ----------------------------------------------------------------------------- +# 5. DEPLOY — bring the stack up. +# ----------------------------------------------------------------------------- +section "DEPLOY" + +cd "$COMPOSE_DIR" +log "docker compose -p ${PROJECT_NAME} up -d --build" +docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build + +# ----------------------------------------------------------------------------- +# 6. NGINX — render config from template, test, reload. +# ----------------------------------------------------------------------------- +section "NGINX" + +if [[ -f "$NGINX_TEMPLATE" ]]; then + POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \ + NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \ + NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \ + render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \ + < "$NGINX_TEMPLATE" > "$NGINX_RENDERED" + + log "Rendered: ${NGINX_RENDERED}" + chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true + chmod 644 "$NGINX_RENDERED" + + # Wire it into sites-enabled if not already linked. + if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then + log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}" + ln -s "$NGINX_RENDERED" "$NGINX_LINK" + fi + + log "nginx -t" + nginx -t + log "systemctl reload nginx" + systemctl reload nginx +else + log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step." +fi + +# ----------------------------------------------------------------------------- +# 7. HEALTHCHECK — direct + via nginx (when applicable). +# ----------------------------------------------------------------------------- +section "HEALTHCHECK" + +# Direct checks (bypass nginx, catch compose issues) +healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1 +healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1 + +# nginx-fronted check (only meaningful if nginx template exists) +if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then + healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1 +fi + +if [[ "${ROLLBACK:-0}" == "1" ]]; then + log "HEALTHCHECK FAILED — rolling back." + log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}" + docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true + + # If we had a previous port file, the old one is still on disk (we wrote + # the new one to .new and only mv'd on success... but we DID mv already, + # so re-write the old value). + if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then + printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE" + else + rm -f "$POSTGREST_PORT_FILE" + fi + if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then + printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE" + else + rm -f "$NEXTJS_PORT_FILE" + fi + exit 3 +fi + +# ----------------------------------------------------------------------------- +# 8. PERSIST — commit the chosen ports as the new single source of truth. +# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.) +# ----------------------------------------------------------------------------- +section "PERSIST" +printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE" +printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE" +log ".postgrest-port = ${NEW_POSTGREST_PORT}" +log ".nextjs-port = ${NEW_NEXTJS_PORT}" + +# ----------------------------------------------------------------------------- +# 9. IMAGE_PRUNE — optional housekeeping. +# ----------------------------------------------------------------------------- +if [[ "$PRUNE_IMAGES" == "1" ]]; then + section "IMAGE_PRUNE" + docker image prune -f +fi + +section "DONE" +exit 0 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..27930f6 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,38 @@ +# ============================================================================= +# docker-compose.yml — production stack consumed by deploy.sh +# ============================================================================= +# +# Only `postgrest` lives in docker. Postgres itself runs on the host +# (the deploy workflow applies migrations via `psql -h 127.0.0.1`). +# Next.js runs under PM2 on the host — it is NOT a docker service. +# +# The host-side port (POSTGREST_HOST_PORT) is written by the deploy +# workflow into $APP_DIR/.env. We interpolate from there with +# ${VAR:-3011} so a manual `docker compose up` without the deploy +# script still works. +# ============================================================================= + +name: prod-app # default project name; deploy.sh overrides with -p + +services: + postgrest: + image: postgrest/postgrest:latest + container_name: prod-app-postgrest + restart: unless-stopped + # The host port is dynamic. The container always listens on 3000. + ports: + - "${POSTGREST_HOST_PORT:-3011}:3000" + environment: + PGRST_DB_URI: ${PGRST_DB_URI} + PGRST_DB_ANON_ROLE: ${PGRST_DB_ANON_ROLE:-anon} + PGRST_DB_SCHEMA: ${PGRST_DB_SCHEMA:-public} + PGRST_SERVER_PORT: 3000 + # Optional: tighten CORS for your real domain + PGRST_DB_TXN_END: "commit-allow-overwrite" + # Healthcheck lets `docker compose ps` show healthy state. + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] + interval: 10s + timeout: 3s + retries: 6 + diff --git a/deploy/healthcheck.sh b/deploy/healthcheck.sh new file mode 100755 index 0000000..00ac9ed --- /dev/null +++ b/deploy/healthcheck.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# ============================================================================= +# healthcheck.sh — standalone, callable from cron / monitoring +# ============================================================================= +# +# Reads the current prod ports from .postgrest-port / .nextjs-port and curls +# each service. Exit code is the count of failed checks (0 = all healthy). +# +# Usage: +# ./healthcheck.sh +# ./healthcheck.sh --nginx # also check the fronted URL +# WORKSPACE=/srv/app ./healthcheck.sh +# ============================================================================= + +set -Eeuo pipefail +IFS=$'\n\t' + +WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}" +NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}" +TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}" + +failures=0 + +check() { + local label="$1" url="$2" + if curl -fsS --max-time "$TIMEOUT" -o /dev/null "$url"; then + printf ' [ OK ] %-20s %s\n' "$label" "$url" + else + printf ' [FAIL] %-20s %s\n' "$label" "$url" + failures=$(( failures + 1 )) + fi +} + +pgrest_port=$(tr -d '[:space:]' < "$POSTGREST_PORT_FILE" 2>/dev/null || echo "") +next_port=$(tr -d '[:space:]' < "$NEXTJS_PORT_FILE" 2>/dev/null || echo "") + +if [[ -n "$pgrest_port" ]]; then + check "postgrest" "http://127.0.0.1:${pgrest_port}/" +else + printf ' [SKIP] postgrest (no .postgrest-port)\n' +fi + +if [[ -n "$next_port" ]]; then + check "nextjs" "http://127.0.0.1:${next_port}/" +else + printf ' [SKIP] nextjs (no .nextjs-port)\n' +fi + +if [[ "${1:-}" == "--nginx" ]]; then + check "nginx" "http://127.0.0.1/" +fi + +exit "$failures" diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template new file mode 100644 index 0000000..a52115e --- /dev/null +++ b/deploy/nginx.conf.template @@ -0,0 +1,89 @@ +# ============================================================================= +# nginx.conf.template — rendered by deploy.sh on every deploy +# ============================================================================= +# +# Variables substituted by `envsubst`: +# ${POSTGREST_HOST_PORT} dynamic host port of the PostgREST container +# ${NEXTJS_HOST_PORT} dynamic host port of the Next.js container +# ${NEXT_PUBLIC_API_URL} (informational only — used in comment header) +# +# Layout: +# /api/* -> http://127.0.0.1:${POSTGREST_HOST_PORT} +# /* -> http://127.0.0.1:${NEXTJS_HOST_PORT} +# +# Tested against nginx >= 1.18 (Debian 11 / Ubuntu 22.04). Adjust ssl_* +# lines if you don't have a cert yet — deploy.sh only tests/renders, the +# operator decides whether to terminate TLS here. +# ============================================================================= + +# --- upstream definitions --------------------------------------------------- +upstream postgrest_upstream { + server 127.0.0.1:${POSTGREST_HOST_PORT}; + keepalive 16; +} + +upstream nextjs_upstream { + server 127.0.0.1:${NEXTJS_HOST_PORT}; + keepalive 16; +} + +# --- HTTP -> HTTPS upgrade (optional; remove if you only run on LAN) -------- +server { + listen 80; + listen [::]:80; + server_name _; + + # ACME http-01 challenge needs to be served on port 80. + location /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + } + + # Redirect everything else to HTTPS. Comment out for plain-HTTP dev. + location / { + return 301 https://$host$request_uri; + } +} + +# --- main server block ------------------------------------------------------ +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name _; + + # --- TLS (uncomment + adjust after you obtain a cert) ------------------ + # ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem; + # ssl_protocols TLSv1.2 TLSv1.3; + # ssl_ciphers HIGH:!aNULL:!MD5; + + # --- sensible defaults ------------------------------------------------ + client_max_body_size 25m; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Connection ""; + + # --- API: /api/* -> PostgREST ---------------------------------------- + location /api/ { + proxy_pass http://postgrest_upstream; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + + # PostgREST exposes its OpenAPI spec at the root of the API; expose it + # under a stable URL too. + location = /api { + proxy_pass http://postgrest_upstream; + } + + # --- everything else -> Next.js -------------------------------------- + location / { + proxy_pass http://nextjs_upstream; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } +} diff --git a/next.config.ts b/next.config.ts index 5b4263c..c23ed8f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,17 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // Lock the file-tracing root to the project directory. Without this, + // Next.js 16 walks up from package.json looking for a lockfile, finds + // the homelab runner's stale `act` cache at + // /home/tyler/.cache/act/.../package-lock.json, and warns: + // "We detected multiple lockfiles and selected the directory of + // /home/tyler/package-lock.json as the root directory." + // The deploy runner's APP_DIR is /home/tyler/route-commerce, so + // resolving relative to the project root is correct both locally and + // in CI. + outputFileTracingRoot: ".", + // Enable strict mode reactStrictMode: true, diff --git a/src/actions/auth-signin.ts b/src/actions/auth-signin.ts index 88ed86b..ea71de2 100644 --- a/src/actions/auth-signin.ts +++ b/src/actions/auth-signin.ts @@ -1,7 +1,6 @@ "use server"; import { signIn, signOut } from "@/lib/auth"; -import { AuthError } from "next-auth"; /** * Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for @@ -19,38 +18,15 @@ import { AuthError } from "next-auth"; * * * - * Usage for the dev credentials provider (dev only): - *
- * - * - * - *
+ * Note: dev/demo authentication is no longer a button on the login page. + * `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/* + * when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md. */ export async function signInWithGoogle(): Promise { await signIn("google", { redirectTo: "/admin" }); } -export async function signInWithDev(formData: FormData): Promise { - 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 { await signOut({ redirectTo: "/login" }); } diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 3277909..0d42720 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -8,6 +8,11 @@ import "@/styles/admin-design-system.css"; import { ToastProvider } from "@/components/admin/Toast"; import { ToastContainer } from "@/components/admin/ToastContainer"; +// Admin layout calls getAdminUser() which reads cookies(). Without this, +// Next.js tries to prerender the entire /admin/* tree statically and the +// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE. +export const dynamic = "force-dynamic"; + // Toast provider wrapper component function ToastProviderWrapper({ children }: { children: React.ReactNode }) { return ( @@ -59,13 +64,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN redirect("/change-password"); } - // Resolve the active brand (URL > cookie > legacy > first of brand_ids) - const activeBrandId = await getActiveBrandId(adminUser); + // Resolve the active brand (URL > cookie > legacy > first of brand_ids). + // Wrapped in try/catch so a transient brand-resolution failure can't + // crash the whole admin shell — we fall back to null (no active brand). + let activeBrandId: string | null = null; + try { + activeBrandId = await getActiveBrandId(adminUser); + } catch (err) { + console.error("[admin/layout] getActiveBrandId failed:", err); + } - // Fetch accessible brands for the sidebar BrandSelector. We do this - // unconditionally — `listBrandsForAdmin` is cheap and the sidebar - // decides whether to show the dropdown. - const brands = await listBrandsForAdmin(); + // Fetch accessible brands for the sidebar BrandSelector. Wrapped in + // try/catch so the sidebar renders empty rather than crashing the page + // if the brands query fails. + let brands: Awaited> = []; + try { + brands = await listBrandsForAdmin(); + } catch (err) { + console.error("[admin/layout] listBrandsForAdmin failed:", err); + } return ( diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 30f4fc3..a335a66 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -27,16 +27,21 @@ export default async function AdminPage() { // Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id // > first of brand_ids). For platform_admin in dev mode this is null, so we // fall back to the first brand in the brands table to keep the dashboard's - // "Active Products" stat in sync with the billing page. + // "Active Products" stat in sync with the billing page. Wrapped in try/catch + // so a transient DB/network failure can't crash the whole admin page. let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null; if (!dashboardBrandId && adminUser?.role === "platform_admin") { - const { data: firstBrand } = await supabase - .from("brands") - .select("id") - .limit(1) - .single(); - if (firstBrand?.id) { - dashboardBrandId = firstBrand.id; + try { + const { data: firstBrand } = await supabase + .from("brands") + .select("id") + .limit(1) + .single(); + if (firstBrand?.id) { + dashboardBrandId = firstBrand.id; + } + } catch (err) { + console.error("[admin/page] supabase brands lookup failed:", err); } } diff --git a/src/app/change-password/ChangePasswordForm.tsx b/src/app/change-password/ChangePasswordForm.tsx new file mode 100644 index 0000000..ce5b4b8 --- /dev/null +++ b/src/app/change-password/ChangePasswordForm.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + 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 ( +
+
+
+
+ + + +
+

Change Password

+

+ You must set a new password before continuing. +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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 + /> +
+ +
+ + 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" + /> +
+ + +
+
+
+
+ ); +} diff --git a/src/proxy.ts b/src/proxy.ts index 9d36a26..ab234e2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,26 +1,83 @@ -import NextAuth from "next-auth"; -import { authConfig } from "@/auth.config"; +import { NextResponse, type NextRequest } from "next/server"; /** * Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16). - * This is the single source of truth for route protection. The legacy - * `src/middleware.ts` has been deleted (Next.js only runs one). * - * Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`? - * 1. Auth.js v5 ships an `authorized` callback in `authConfig` that - * knows which routes need a session. We reuse it here at the edge. - * 2. It auto-populates `request.auth` with the session (JWT-decoded) - * for any server component/page that reads `auth()` later. + * Routing policy: + * 1. `/admin/*` with no auth cookie + `ALLOW_DEV_LOGIN !== "false"` + * → set `dev_session=platform_admin` and let the request through. + * This makes `/admin` "just work" in dev/demo without any login + * UI gymnastics. + * 2. `/login` with an auth cookie (any flavour) + * → redirect to `/admin` so an authenticated user never sees the + * login form. + * 3. `/admin/*` (or `/protected-example`) with no auth cookie + * → redirect to `/login`. + * 4. Everything else → continue. * - * Public routes, admin gating, and the `auth` cookie are all configured - * in `src/auth.config.ts`. + * Auth-cookie flavours recognised: + * - `dev_session` (dev auto-login, see above) + * - `rc_auth_uid` / `rc_uid` (legacy /api/login flow) + * - `authjs.session-token` / `__Secure-authjs.session-token` (Auth.js v5) + * + * The proxy only checks cookie *presence*. The real auth check (JWT + * signature decryption, admin role lookup) happens in + * `getAdminUser()` server-side. The proxy is just routing. */ -const { auth } = NextAuth(authConfig); -export default auth; +function isAuthenticated(request: NextRequest): boolean { + const dev = request.cookies.get("dev_session")?.value; + if ( + dev === "platform_admin" || + dev === "brand_admin" || + dev === "store_employee" + ) { + return true; + } + if (request.cookies.get("rc_auth_uid")?.value) return true; + if (request.cookies.get("rc_uid")?.value) return true; + if (request.cookies.get("authjs.session-token")?.value) return true; + if (request.cookies.get("__Secure-authjs.session-token")?.value) return true; + return false; +} + +export default function proxy(request: NextRequest) { + const { nextUrl } = request; + const isOnAdmin = nextUrl.pathname.startsWith("/admin"); + const isOnProtectedExample = nextUrl.pathname.startsWith( + "/protected-example" + ); + const isOnLogin = nextUrl.pathname === "/login"; + const authenticated = isAuthenticated(request); + + // ── 1. Dev auto-login for /admin/* ─────────────────────────────── + if (isOnAdmin && !authenticated) { + const allowDev = process.env.ALLOW_DEV_LOGIN !== "false"; + if (allowDev) { + const response = NextResponse.next(); + response.cookies.set("dev_session", "platform_admin", { + path: "/", + maxAge: 60 * 60 * 24, // 1 day + sameSite: "lax", + }); + return response; + } + } + + // ── 2. Bounce authenticated users away from /login ─────────────── + if (isOnLogin && isAuthenticated(request)) { + return NextResponse.redirect(new URL("/admin", nextUrl)); + } + + // ── 3. Gate protected routes ───────────────────────────────────── + if ((isOnAdmin || isOnProtectedExample) && !authenticated) { + return NextResponse.redirect(new URL("/login", nextUrl)); + } + + // ── 4. Everything else: continue ───────────────────────────────── + return NextResponse.next(); +} export const config = { - // Run on /admin and the protected example, plus /login so the - // `authorized` callback can bounce already-signed-in users away from it. matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"], }; diff --git a/supabase/migrations/209_authjs_auto_create_admin.sql b/supabase/migrations/209_authjs_auto_create_admin.sql new file mode 100644 index 0000000..fd80621 --- /dev/null +++ b/supabase/migrations/209_authjs_auto_create_admin.sql @@ -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';