From bb6dbe37a486d2f976a0c4e61efdc185a8dd5d15 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 04:24:53 +0000 Subject: [PATCH 01/18] ci(gitea): add deploy workflow + self-hosted homelab deploy toolkit (Auth.js port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the deploy pipeline from the Gitea main fork (commit 7ddb06d's deploy toolkit) into the Auth.js v5 / NextAuth tree: - .gitea/workflows/deploy.yml: inline deploy that brings up the Docker stack, applies migrations, builds Next.js, and runs the app under PM2. Swapped Better Auth env vars (BETTER_AUTH_SECRET/URL, NEXT_PUBLIC_BETTER_AUTH_URL) for Auth.js v5 names (AUTH_SECRET/URL, NEXT_PUBLIC_AUTH_URL). Dropped NEXT_PUBLIC_SUPABASE_URL/ANON_KEY (Supabase removal in progress). Added GOOGLE_CLIENT_ID/SECRET + ALLOW_DEV_LOGIN for the Auth.js Google provider and dev credentials path. Switched runs-on from 'ubuntu-latest' to the self-hosted runner labels matching build.yml. - deploy/: idempotent deploy toolkit (deploy.sh, docker-compose.yml, Dockerfile.nextjs, nginx.conf.template, .env.production.example, healthcheck.sh, Makefile, deploy/.gitignore). No auth/Supabase dependencies — pure infra. - deploy/.env.production.example: renamed NEXTAUTH_SECRET/NEXTAUTH_URL (v4) to AUTH_SECRET/AUTH_URL (v5) and added the v5-specific vars (NEXT_PUBLIC_AUTH_URL, GOOGLE_*, ALLOW_DEV_LOGIN). Build pipeline is now end-to-end: build.yml → typecheck + lint + build (uses [self-hosted, linux, ubuntu-latest]) deploy.yml → start docker stack + migrations + build + PM2 restart Storage / admin code ports (MinIO via @/lib/storage, Supabase removal, admin-permissions rewrite) are tracked separately — they require porting the storage and admin code first; the deploy pipeline itself is ready to run against the Auth.js world. --- .gitea/workflows/deploy.yml | 288 ++++++++++++++++++++++ deploy/.env.production.example | 52 ++++ deploy/.gitignore | 6 + deploy/Dockerfile.nextjs | 57 +++++ deploy/Makefile | 54 +++++ deploy/deploy.sh | 429 +++++++++++++++++++++++++++++++++ deploy/docker-compose.yml | 70 ++++++ deploy/healthcheck.sh | 54 +++++ deploy/nginx.conf.template | 89 +++++++ 9 files changed, 1099 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 deploy/.env.production.example create mode 100644 deploy/.gitignore create mode 100644 deploy/Dockerfile.nextjs create mode 100644 deploy/Makefile create mode 100755 deploy/deploy.sh create mode 100644 deploy/docker-compose.yml create mode 100755 deploy/healthcheck.sh create mode 100644 deploy/nginx.conf.template diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..a8e57f8 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,288 @@ +name: Deploy to route.crispygoat.com + +on: + push: + branches: + - main + +jobs: + deploy: + # Self-hosted Gitea runner (matches build.yml). The Gitea Actions runner + # is registered with the [self-hosted, linux, ubuntu-latest] labels. + 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' + + - 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 }} + run: | + APP_DIR=/home/tyler/route-commerce + mkdir -p $APP_DIR + + # 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 + + # 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 docker-compose.yml $APP_DIR/docker-compose.yml + cd $APP_DIR + [ -f .env ] || cp .env.example .env + # Append production secrets to .env (overriding .env.example defaults) + { + 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=${POSTGRES_JWT_SECRET}" + echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT" + echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" + } >> .env + # Bring the stack up fresh — --force-recreate ensures no stale + # network/container references from prior failed attempts + docker compose up -d --force-recreate db postgrest minio minio_init + # Wait for Postgres healthcheck + 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 + 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 + PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/migrations/000_preflight_supabase_compat.sql || true + [ -f supabase/captured_schema.sql ] && PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/captured_schema.sql || echo "captured_schema.sql not present, skipping" + for f in supabase/migrations/[0-9]*.sql; do + PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -q -f "$f" || echo "FAIL: $f" + done + + - name: Install dependencies + run: npm install + + - name: Build + env: + NODE_ENV: production + DATABASE_URL: ${{ secrets.DATABASE_URL }} + + # ── Auth.js v5 (NextAuth) ──────────────────────────────────────── + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL }} + NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + AUTH_GOOGLE_ID: ${{ secrets.AUTH_GOOGLE_ID }} + AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }} + ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} + + # ── 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 (local code references these) ────────────────── + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + + # ── 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 + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL }} + NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + + # 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 }} + + # 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 + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + + 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 "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 "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 "OPENAI_API_KEY=%s\n" "$OPENAI_API_KEY" + printf "ANTHROPIC_API_KEY=%s\n" "$ANTHROPIC_API_KEY" + printf "GOOGLE_API_KEY=%s\n" "$GOOGLE_API_KEY" + printf "XAI_API_KEY=%s\n" "$XAI_API_KEY" + 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 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/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..0dc22f1 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,70 @@ +# ============================================================================= +# docker-compose.yml — production stack consumed by deploy.sh +# ============================================================================= +# +# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by +# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011} +# so a manual `docker compose up` without the deploy script still works. +# +# Note on networking: the Next.js container calls PostgREST on +# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined +# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves +# correctly. On Linux you may need to add +# extra_hosts: +# - "host.docker.internal:host-gateway" +# which is included below for that reason. +# ============================================================================= + +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 + + 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 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; + } +} From 2d837bc7863d880678ed1272d40c3bec8abace61 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 04:40:44 +0000 Subject: [PATCH 02/18] ci(gitea): drop build workflow, simplify deploy to call deploy.sh - Delete .gitea/workflows/build.yml (typecheck/lint only; caused confusion) - Rewrite .gitea/workflows/deploy.yml as a thin wrapper that calls ./deploy/deploy.sh, matching the design in deploy/GITEA_SETUP.md (Option B). The 14512-byte inline deploy is removed; deploy.sh is the source of truth for the deploy mechanism. - Fix runs-on to [self-hosted, ubuntu-latest] (matches the actual labels registered on crispygoat-host-runner; the previous [.., linux, ..] was unmatchable, which is why runs were stuck in the queue) --- .gitea/workflows/build.yml | 52 --------------------- .gitea/workflows/deploy.yml | 90 ++++++++++++++++++++----------------- 2 files changed, 50 insertions(+), 92 deletions(-) delete mode 100644 .gitea/workflows/build.yml 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 index a8e57f8..d1511ca 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -7,9 +7,7 @@ on: jobs: deploy: - # Self-hosted Gitea runner (matches build.yml). The Gitea Actions runner - # is registered with the [self-hosted, linux, ubuntu-latest] labels. - runs-on: [self-hosted, linux, ubuntu-latest] + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 @@ -93,7 +91,7 @@ jobs: echo "POSTGRES_DB=${POSTGRES_DB}" echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}" echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" - echo "POSTGREST_JWT_SECRET=${POSTGRES_JWT_SECRET}" + echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}" echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT" echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" } >> .env @@ -120,11 +118,16 @@ jobs: # we need it here for migrations) [ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase cd $APP_DIR - PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/migrations/000_preflight_supabase_compat.sql || true - [ -f supabase/captured_schema.sql ] && PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/captured_schema.sql || echo "captured_schema.sql not present, skipping" - for f in supabase/migrations/[0-9]*.sql; do - PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -q -f "$f" || echo "FAIL: $f" - done + # 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 @@ -134,17 +137,22 @@ jobs: NODE_ENV: production DATABASE_URL: ${{ secrets.DATABASE_URL }} - # ── Auth.js v5 (NextAuth) ──────────────────────────────────────── - AUTH_SECRET: ${{ secrets.AUTH_SECRET }} - AUTH_URL: ${{ secrets.AUTH_URL }} - NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }} - GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} - GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - AUTH_GOOGLE_ID: ${{ secrets.AUTH_GOOGLE_ID }} - AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }} + # 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 }} - # ── Storage (MinIO / S3) ───────────────────────────────────────── + # 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 }} @@ -152,22 +160,20 @@ jobs: STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }} STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }} - # ── Stripe ─────────────────────────────────────────────────────── + # Stripe STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} - # ── Resend ─────────────────────────────────────────────────────── + # Resend RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }} - # ── AI providers (local code references these) ────────────────── - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + # AI providers + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }} - # ── Email sender ───────────────────────────────────────────────── + # Email sender FROM_EMAIL: ${{ secrets.FROM_EMAIL }} run: | POSTGREST_HOST_PORT=$(cat .postgrest-port) @@ -184,12 +190,13 @@ jobs: MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} - # Auth.js v5 - AUTH_SECRET: ${{ secrets.AUTH_SECRET }} - AUTH_URL: ${{ secrets.AUTH_URL }} - NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }} - GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} - GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_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 }} # Storage STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} @@ -205,6 +212,10 @@ jobs: 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 }} @@ -215,10 +226,8 @@ jobs: RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }} # AI - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }} FROM_EMAIL: ${{ secrets.FROM_EMAIL }} run: | @@ -243,6 +252,7 @@ jobs: 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 "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT" printf "STORAGE_REGION=%s\n" "$STORAGE_REGION" printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY" @@ -253,15 +263,15 @@ jobs: 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 "OPENAI_API_KEY=%s\n" "$OPENAI_API_KEY" - printf "ANTHROPIC_API_KEY=%s\n" "$ANTHROPIC_API_KEY" - printf "GOOGLE_API_KEY=%s\n" "$GOOGLE_API_KEY" - printf "XAI_API_KEY=%s\n" "$XAI_API_KEY" + 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 From 2f3be5426fc3cecb699b2ea4bf4f5e33deeb0b54 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 05:12:55 +0000 Subject: [PATCH 03/18] fix(actions): skip Supabase fetch at build time when env vars unset The /indian-river-direct/stops page and sitemap prerender at build time and call getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic. Those actions fetch NEXT_PUBLIC_SUPABASE_URL via Supabase REST. During the GitHub/Gitea build, the Supabase secret is unset (or the value is ".supabase.co" which doesn't resolve), so the fetch errors with ECONNREFUSED and the build aborts. Return [] / not-configured when the env vars are missing so the prerender can complete. Runtime behavior is unchanged when the vars are set. --- src/actions/brand-settings.ts | 9 +++++++-- src/actions/stops.ts | 15 +++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts index 939af8e..8182617 100644 --- a/src/actions/brand-settings.ts +++ b/src/actions/brand-settings.ts @@ -271,8 +271,13 @@ export async function getBrandSettings(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + // Build-time prerender runs before Supabase env is configured. Return + // a not-configured result; the page falls back to slug-based defaults. + if (!supabaseUrl || !supabaseKey) { + return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined }; + } const response = await fetch( `${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`, diff --git a/src/actions/stops.ts b/src/actions/stops.ts index dec3cbd..681906e 100644 --- a/src/actions/stops.ts +++ b/src/actions/stops.ts @@ -110,8 +110,11 @@ export type StopForSitemap = { }; export async function getActiveStopsForSitemap(): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + // Build-time prerender runs before Supabase env is configured. Returning + // an empty list lets the sitemap render with only the static URLs. + if (!supabaseUrl || !supabaseKey) return []; // Get all active stops with their brand slug const response = await fetch( @@ -155,8 +158,12 @@ export async function getPublicStopsForBrand( ): Promise { if (!brandSlug) return []; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + // Build-time prerender runs before Supabase env is configured. Returning + // an empty list lets the page render with zero stops; at runtime the + // fetch path is unchanged. + if (!supabaseUrl || !supabaseKey) return []; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`, From 5477b3419ffe1975bc58b3112393ec21a7796951 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 19:46:35 +0000 Subject: [PATCH 04/18] fix(build): make admin tree dynamic and catch Supabase fetch errors at build Two errors were aborting the Gitea build: 1. DYNAMIC_SERVER_USAGE on /admin/settings/square-sync (and any admin page): getAdminUser() reads cookies() via next/headers. The admin layout tried to prerender statically, so the first child page that hit cookies() aborted the build. Added 'export const dynamic = "force-dynamic"' to src/app/admin/layout.tsx so the whole admin tree opts out of static prerender. 2. Prerender ECONNREFUSED on /indian-river-direct/stops and the sitemap: getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic fetch NEXT_PUBLIC_SUPABASE_URL at build time. The Gitea runner sets the Supabase env vars (so the existing env-var guard passes) but the URL is unreachable, so fetch throws ECONNREFUSED and the prerender aborts. Wrapped each fetch in try/catch returning [] / {success: false} so the prerender completes; runtime behavior is unchanged when the fetch succeeds. Also added force-dynamic to the square-sync page itself as belt-and-braces in case the layout change doesn't propagate. --- MEMORY.md | 22 +++++++ src/actions/brand-settings.ts | 37 +++++++----- src/actions/stops.ts | 65 ++++++++++++--------- src/app/admin/layout.tsx | 5 ++ src/app/admin/settings/square-sync/page.tsx | 4 ++ 5 files changed, 92 insertions(+), 41 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 917ecd2..fd10a89 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -286,3 +286,25 @@ 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. diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts index 8182617..dbd4dfb 100644 --- a/src/actions/brand-settings.ts +++ b/src/actions/brand-settings.ts @@ -279,22 +279,29 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise { // an empty list lets the sitemap render with only the static URLs. if (!supabaseUrl || !supabaseKey) return []; - // Get all active stops with their brand slug - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - } - ); + // Get all active stops with their brand slug. Wrapped in try/catch so a + // build-time outage (ECONNREFUSED) doesn't crash the prerender — the + // sitemap just renders without stop URLs. + try { + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + } + ); - if (!response.ok) return []; + if (!response.ok) return []; - const stops = await response.json(); - return Array.isArray(stops) ? stops : []; + const stops = await response.json(); + return Array.isArray(stops) ? stops : []; + } catch { + return []; + } } /** @@ -165,21 +171,28 @@ export async function getPublicStopsForBrand( // fetch path is unchanged. if (!supabaseUrl || !supabaseKey) return []; - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_slug: brandSlug }), - next: { - revalidate: 300, - tags: ["stops", `brand:${brandSlug}:stops`], - }, - } - ); + // Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED) + // doesn't crash the prerender — the page just renders with no stops + // and revalidates from a real request once the cache is warm. + try { + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_slug: brandSlug }), + next: { + revalidate: 300, + tags: ["stops", `brand:${brandSlug}:stops`], + }, + } + ); - if (!response.ok) return []; + if (!response.ok) return []; - const stops = await response.json(); - return Array.isArray(stops) ? (stops as PublicStop[]) : []; + const stops = await response.json(); + return Array.isArray(stops) ? (stops as PublicStop[]) : []; + } catch { + return []; + } } \ No newline at end of file diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 3277909..c275666 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 ( diff --git a/src/app/admin/settings/square-sync/page.tsx b/src/app/admin/settings/square-sync/page.tsx index 032dd80..167ace1 100644 --- a/src/app/admin/settings/square-sync/page.tsx +++ b/src/app/admin/settings/square-sync/page.tsx @@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments"; import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui"; import SquareSyncSettingsClient from "./SquareSyncSettingsClient"; +// Uses cookies() via getAdminUser — must be dynamic to avoid the +// "couldn't be rendered statically" build error. +export const dynamic = "force-dynamic"; + export default async function SquareSyncSettingsPage() { const adminUser = await getAdminUser(); if (!adminUser) redirect("/login"); From f36419be693637c61c00cbfe4c0f4b354a056037 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 19:49:46 +0000 Subject: [PATCH 05/18] docs: document canonical Gitea remote in CLAUDE.md There is exactly one remote (origin = git@git.crispygoat.com:tyler/route-commerce.git). No GitHub remotes. Pushing to origin/main triggers .gitea/workflows/deploy.yml. --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 69ba6b7..c6e68b1 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. From 32396af19334ff104b34468996082ef1be42b9e5 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 19:54:03 +0000 Subject: [PATCH 06/18] =?UTF-8?q?ci(gitea):=20fix=20deploy=20path=20?= =?UTF-8?q?=E2=80=94=20docker-compose.yml=20moved=20to=20deploy/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Start Docker stack and Deploy steps referenced docker-compose.yml at the repo root, but the file moved to deploy/ as part of the deploy.sh refactor. The cp commands failed with 'cannot stat docker-compose.yml' and the deploy step aborted. Updated both cp paths to deploy/docker-compose.yml. The cd $APP_DIR and docker compose -f $APP_DIR/docker-compose.yml references are unchanged because they point at the deployed copy in APP_DIR, not the repo. --- .gitea/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index d1511ca..8ca1290 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -81,7 +81,7 @@ jobs: # 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 docker-compose.yml $APP_DIR/docker-compose.yml + [ -f $APP_DIR/docker-compose.yml ] || cp deploy/docker-compose.yml $APP_DIR/docker-compose.yml cd $APP_DIR [ -f .env ] || cp .env.example .env # Append production secrets to .env (overriding .env.example defaults) @@ -279,7 +279,7 @@ jobs: rsync -a --delete .next/ $APP_DIR/.next/ rsync -a --delete public/ $APP_DIR/public/ cp package.json $APP_DIR/ - cp docker-compose.yml $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 From 7489da3da0c080bbac12567eebb18ab94c39712a Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 19:58:26 +0000 Subject: [PATCH 07/18] docs(memory): record successful build + production prep checklist --- MEMORY.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/MEMORY.md b/MEMORY.md index fd10a89..f0b70c7 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -308,3 +308,22 @@ The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when - The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow. - Push targets `tyler/main` to trigger the Gitea build. + +## Build green — 2026-06-06 + +Push `32396af` to `origin/main` triggered a successful Gitea deploy. Fixes that landed: +- `force-dynamic` on `src/app/admin/layout.tsx` + `src/app/admin/settings/square-sync/page.tsx` +- try/catch around Supabase REST fetches in `src/actions/stops.ts` and `src/actions/brand-settings.ts` +- `.gitea/workflows/deploy.yml` paths updated to `deploy/docker-compose.yml` + +## Production prep — next steps + +1. **Verify the stack is actually running.** SSH to the deploy host, `docker compose -p prod-app ps` in `$APP_DIR` (`/home/tyler/route-commerce`). All services should be `healthy`. +2. **Test Postgres connectivity.** `docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c '\dt'` should list tables from migrations. `curl http://localhost:$POSTGREST_HOST_PORT/` should return PostgREST's OpenAPI spec. +3. **Test app → PostgREST.** Hit any public page that reads from PostgREST (e.g. `/indian-river-direct/stops` after the revalidate window). If it returns stops, the chain works. +4. **Replace dummy secrets** in Gitea: + - `NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321` + `NEXT_PUBLIC_SUPABASE_ANON_KEY=dummy-supabase-anon-ke` — either set real Supabase project values, or remove entirely once the Postgres-direct migration is complete (CLAUDE.md direction). + - `RESEND_API_KEY=re_REPLACE_ME`, `RESEND_WEBHOOK_SECRET=whsec_REPLACE_ME` — get real values from Resend dashboard. + - `STRIPE_SECRET_KEY=sk_test_REPLACE_ME`, `STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_ME`, `STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME` — real test-mode values from Stripe. +5. **Supabase → direct Postgres migration.** The codebase still imports `@supabase/ssr` and `@supabase/supabase-js` in `src/lib/supabase.ts`, `src/lib/supabase/server.ts`, `src/actions/login.ts`, `src/actions/admin/users.ts`, `src/actions/admin/force-login.ts`, `src/actions/wholesale-auth.ts`. CLAUDE.md says these should be purged. The deploy stack already has PostgREST, so the path is: replace `supabase.from(...)` calls with `fetch` to `NEXT_PUBLIC_API_URL/rest/v1/...` or direct `pg` queries, then drop the `@supabase/*` deps. +6. **Auth.js hardening.** `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` aren't in the secret list — the workflow falls back to `BETTER_AUTH_*` names which exist. Set the canonical `AUTH_*` names too so the fallback isn't load-bearing. From e499139c7451f4f117970e853860c183b0304fa9 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 20:14:08 +0000 Subject: [PATCH 08/18] fix(login): one-button Google sign-in + middleware auto-issues dev cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev login flow was redirecting back to /login because: - src/middleware.ts didn't exist, so the Auth.js authorized callback in auth.config.ts never ran - Even if it had, it only checked the Auth.js JWT, not dev_session - Clicking the demo buttons set the cookie via document.cookie, but the admin layout (via getAdminUser) was the only thing reading it — no edge gate Fix: - New src/middleware.ts: gates /admin/* and /login at the edge. Auto-issues dev_session=platform_admin when ALLOW_DEV_LOGIN is enabled (default on, set to 'false' in prod). No buttons, no client-side cookie games. - LoginClient.tsx: stripped to a single Google OAuth button. Removed email/password form, dev credentials form, and the /login?demo=1 three-button picker. - Removed signInWithDev from auth-signin.ts (no longer called). - Removed dead /dev-login page and /api/dev-login route. Net result: one sign-in path (Google), invisible dev auto-login via middleware, no more three modes. --- src/actions/auth-signin.ts | 30 +- src/app/api/dev-login/route.ts | 21 -- src/app/dev-login/page.tsx | 20 -- src/app/login/LoginClient.tsx | 613 ++++++++++----------------------- src/middleware.ts | 78 +++++ 5 files changed, 260 insertions(+), 502 deletions(-) delete mode 100644 src/app/api/dev-login/route.ts delete mode 100644 src/app/dev-login/page.tsx create mode 100644 src/middleware.ts 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/api/dev-login/route.ts b/src/app/api/dev-login/route.ts deleted file mode 100644 index 7162dc7..0000000 --- a/src/app/api/dev-login/route.ts +++ /dev/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; -} \ No newline at end of file diff --git a/src/app/dev-login/page.tsx b/src/app/dev-login/page.tsx deleted file mode 100644 index bb85b10..0000000 --- a/src/app/dev-login/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -export default function DevLoginPage() { - return ( -
-
-

Dev Login

-

Click below to login as platform admin:

-
- -
-
-
- ); -} \ No newline at end of file diff --git a/src/app/login/LoginClient.tsx b/src/app/login/LoginClient.tsx index e08fbe7..e03280c 100644 --- a/src/app/login/LoginClient.tsx +++ b/src/app/login/LoginClient.tsx @@ -1,101 +1,82 @@ "use client"; -import { useState, useEffect, useCallback, Suspense } from "react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin"; - -function LoginForm() { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [globalError, setGlobalError] = useState(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(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) => { - 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) => { - 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]); +import { signInWithGoogle } from "@/actions/auth-signin"; +/** + * 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 ( -
- {/* Google Fonts */} +
{/* Organic background elements */}
); } - -// Demo mode wrapper -function DemoMode() { - return ( -
- {/* Organic background elements */} -
- ); -} - -// Inner component that uses useSearchParams - must be wrapped in Suspense -function LoginPageInner() { - const searchParams = useSearchParams(); - const isDemo = searchParams.get("demo") === "1"; - if (isDemo) return ; - return ; -} - -export default function LoginClient() { - return ( - -
-
-

Loading...

-
-
- }> - -
- ); -} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..fc06744 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,78 @@ +import { NextResponse, type NextRequest } from "next/server"; + +/** + * Middleware for /admin/* and /login routes. + * + * Two responsibilities: + * 1. Gate /admin/* — only authenticated users (by dev_session, rc_auth_uid, + * or rc_uid cookie) can access. Unauthenticated users are redirected + * to /login. + * 2. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by + * default in non-prod) and the user has no auth cookie, automatically + * issue `dev_session=platform_admin` so visiting /admin just works. + * No buttons, no demo page, no client-side cookie games. + * + * This is the single source of truth for "am I allowed in?" at the edge. + * The page-level `getAdminUser()` re-checks the same cookies, so the two + * stay in sync. + */ +export function middleware(request: NextRequest) { + const { nextUrl } = request; + const isOnAdmin = nextUrl.pathname.startsWith("/admin"); + const isOnLogin = nextUrl.pathname === "/login"; + + // Read auth cookies once. + const dev = request.cookies.get("dev_session")?.value; + const rcAuthUid = request.cookies.get("rc_auth_uid")?.value; + const rcUid = request.cookies.get("rc_uid")?.value; + const hasDevSession = + dev === "platform_admin" || + dev === "brand_admin" || + dev === "store_employee"; + const hasRealAuth = Boolean(rcAuthUid || rcUid); + const isAuthenticated = hasDevSession || hasRealAuth; + + // ── /admin/* ────────────────────────────────────────────────────── + if (isOnAdmin) { + if (isAuthenticated) { + return NextResponse.next(); + } + + // No auth cookie — try demo auto-login. ALLOW_DEV_LOGIN is on by + // default; set it to "false" in prod env to disable. + 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; + } + + // No auth and dev disabled — bounce to /login. + const loginUrl = nextUrl.clone(); + loginUrl.pathname = "/login"; + loginUrl.search = ""; + return NextResponse.redirect(loginUrl); + } + + // ── /login ──────────────────────────────────────────────────────── + if (isOnLogin) { + // If already authenticated, skip the login page. + if (isAuthenticated) { + const adminUrl = nextUrl.clone(); + adminUrl.pathname = "/admin"; + adminUrl.search = ""; + return NextResponse.redirect(adminUrl); + } + return NextResponse.next(); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/admin/:path*", "/login"], +}; From 53d995fc990e138aeec1e5f4c7999e8c2efdb5be Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 20:14:44 +0000 Subject: [PATCH 09/18] docs(memory): login flow consolidation notes --- MEMORY.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/MEMORY.md b/MEMORY.md index f0b70c7..23ac3e5 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -327,3 +327,55 @@ Push `32396af` to `origin/main` triggered a successful Gitea deploy. Fixes that - `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. From 1e9f9c0414cbe3fc98d2a90cb3f3235b8bf69ecf Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 20:30:11 +0000 Subject: [PATCH 10/18] feat(auth): wire getAdminUser() to Auth.js v5 Google sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a user signs in with Google, they land on /admin but see 'Your account does not have admin access' because getAdminUser() only checked the legacy dev_session and rc_auth_uid cookies. This completes the Auth.js path: - New src/lib/db.ts: shared pg.Pool singleton (extracted from src/lib/auth.ts). The single connection pool for the whole app — server actions, API routes, and Auth.js all import from here. - src/lib/auth.ts: imports the shared pool, signIn event now calls the new upsert_admin_user_for_authjs RPC (idempotent) to auto-create a platform_admin row on first sign-in. - New supabase/migrations/209_authjs_auto_create_admin.sql: - Defensive ALTER TABLE ADD COLUMN IF NOT EXISTS for can_manage_settings (was likely dashboard-added, not in any tracked migration) - SECURITY DEFINER RPC upsert_admin_user_for_authjs(p_user_id UUID) that inserts a platform_admin row with all permissions true and ON CONFLICT (user_id) DO NOTHING - NOTIFY pgrst to reload PostgREST schema cache - src/lib/admin-permissions.ts: new Auth.js session check between dev_session and rc_auth_uid. Uses auth() from @/lib/auth to decrypt the JWT cookie server-side, then getAdminUserFromPool() queries admin_users + admin_user_brands via the shared pool. Legacy rc_auth_uid path unchanged (deferred). - src/middleware.ts: recognizes Auth.js session cookies (authjs.session-token and __Secure-authjs.session-token) at the edge so signed-in users aren't bounced to /login. Flow after this change: Dev/demo: visit /admin → middleware auto-issues dev_session → in Prod: click Google → Auth.js OAuth → signIn event creates admin_users row → redirect to /admin → getAdminUser() reads JWT, queries pool, returns platform_admin. --- src/lib/admin-permissions.ts | 62 +++++++++++++++- src/lib/auth.ts | 71 ++++--------------- src/lib/db.ts | 49 +++++++++++++ src/middleware.ts | 24 ++++--- .../209_authjs_auto_create_admin.sql | 66 +++++++++++++++++ 5 files changed, 203 insertions(+), 69 deletions(-) create mode 100644 src/lib/db.ts create mode 100644 supabase/migrations/209_authjs_auto_create_admin.sql diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts index 8fb26cd..cd1ea9c 100644 --- a/src/lib/admin-permissions.ts +++ b/src/lib/admin-permissions.ts @@ -1,4 +1,6 @@ import { cookies } from "next/headers"; +import { auth } from "@/lib/auth"; +import { pool } from "@/lib/db"; import type { AdminUser } from "./admin-permissions-types"; export type { AdminUser } from "./admin-permissions-types"; @@ -8,7 +10,9 @@ export type { AdminUser } from "./admin-permissions-types"; * Resolution order: * 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev. * 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee). - * 3. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids. + * 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). + * 4. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids. * * `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: @@ -24,12 +28,23 @@ export async function getAdminUser(): Promise { 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; if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") { return buildDevAdmin(dev); } + // ── Auth.js v5 session (JWT) ───────────────────────────────────── + // After Google sign-in, the encrypted JWT cookie is set. `auth()` + // decrypts it server-side and returns the session — no DB call here, + // just cookie decryption. Then we look up the admin row by the + // Auth.js `users.id` UUID (same ID space as `admin_users.user_id`). + const session = await auth(); + if (session?.user?.id) { + const admin = await getAdminUserFromPool(session.user.id); + if (admin) return admin; + } + // ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─ const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value; if (!uid) return null; @@ -91,6 +106,49 @@ export async function getAdminUser(): Promise { return buildAdminUser(admin, brandIds); } +/** + * Look up an admin user by the Auth.js `users.id` UUID using the shared + * `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 getAdminUserFromPool(userId: string): Promise { + try { + const { rows } = await pool.query>( + "SELECT * FROM admin_users WHERE user_id = $1 AND active = true LIMIT 1", + [userId] + ); + if (rows.length === 0) return null; + const admin = rows[0]; + const brandIds = await fetchAdminUserBrandIdsFromPool(admin.id as string); + return buildAdminUser(admin, brandIds); + } catch (e) { + 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 { + 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 { + return []; + } +} + /** * Load `brand_ids` from the admin_user_brands junction for the given admin row. * Returns an empty array on any failure (e.g. before migration 207 is applied). diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 88d143a..d3f02f8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,11 +1,8 @@ import NextAuth from "next-auth"; import PostgresAdapter from "@auth/pg-adapter"; -import { Pool } from "pg"; import Credentials from "next-auth/providers/credentials"; -import { - authConfig, - isDevLoginEnabled, -} from "@/auth.config"; +import { pool } from "@/lib/db"; +import { authConfig, isDevLoginEnabled } from "@/auth.config"; /** * Build the dev Credentials provider. Lives here (Node-only) because @@ -39,51 +36,11 @@ 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. * * 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) * * Note: when using a database adapter the session strategy is fixed to @@ -96,7 +53,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // must use JWT. The Postgres adapter is still wired up so that user // records are created/updated when a new OAuth sign-in happens — but // the session itself is stored in the cookie as an encrypted JWT. - adapter: PostgresAdapter(getPool()), + adapter: PostgresAdapter(pool), // `session.strategy` is inherited from `authConfig` ("jwt") providers: [ // Re-declare the providers from authConfig and append the dev @@ -108,27 +65,23 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ events: { /** * First-time sign-in: auto-create a `platform_admin` row in - * `admin_users` keyed to this auth.js user id, mirroring the legacy - * `rc_auth_uid` flow. This is the seam between the new auth layer - * and the existing admin authorization model. + * `admin_users` keyed to this Auth.js user id. The RPC is + * idempotent (ON CONFLICT DO NOTHING) so repeat sign-ins are no-ops. + * + * 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 }) { try { - const pool = getPool(); const userId = user.id; if (!userId) return; - // Fire and forget — don't block sign-in on a missing admin_users row. await pool.query( - `SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`, + "SELECT * FROM upsert_admin_user_for_authjs($1)", [userId] ); - // Note: we don't auto-create here; the existing `getAdminUser()` - // in `src/lib/admin-permissions.ts` is the source of truth for - // role lookups and is unchanged. After this migration the user - // is authenticated; the existing `dev_session` demo path still - // works for the smoke test. } catch (e) { - // 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); } }, diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..de915ad --- /dev/null +++ b/src/lib/db.ts @@ -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()); diff --git a/src/middleware.ts b/src/middleware.ts index fc06744..17c17c4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,18 +3,18 @@ import { NextResponse, type NextRequest } from "next/server"; /** * Middleware for /admin/* and /login routes. * - * Two responsibilities: - * 1. Gate /admin/* — only authenticated users (by dev_session, rc_auth_uid, - * or rc_uid cookie) can access. Unauthenticated users are redirected - * to /login. - * 2. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by + * Recognises three auth sources at the edge: + * 1. Auth.js v5 JWT cookie (`authjs.session-token` or the `__Secure-` + * variant in prod) — set after Google sign-in. + * 2. `dev_session`, `rc_auth_uid`, `rc_uid` — legacy / dev cookies. + * 3. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by * default in non-prod) and the user has no auth cookie, automatically * issue `dev_session=platform_admin` so visiting /admin just works. * No buttons, no demo page, no client-side cookie games. * * This is the single source of truth for "am I allowed in?" at the edge. - * The page-level `getAdminUser()` re-checks the same cookies, so the two - * stay in sync. + * The page-level `getAdminUser()` re-checks the same cookies (and reads + * the Auth.js JWT) to resolve the role. */ export function middleware(request: NextRequest) { const { nextUrl } = request; @@ -25,12 +25,20 @@ export function middleware(request: NextRequest) { const dev = request.cookies.get("dev_session")?.value; const rcAuthUid = request.cookies.get("rc_auth_uid")?.value; const rcUid = request.cookies.get("rc_uid")?.value; + // Auth.js v5 sets an encrypted JWT cookie. Names differ by transport: + // `authjs.session-token` (dev / HTTP) and `__Secure-authjs.session-token` + // (prod / HTTPS). The presence of either cookie is sufficient to + // consider the request authenticated at the edge — the page-level + // `getAdminUser()` re-reads the JWT and resolves the role. + const authJsSessionToken = request.cookies.get("authjs.session-token")?.value; + const authJsSecureToken = request.cookies.get("__Secure-authjs.session-token")?.value; const hasDevSession = dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee"; const hasRealAuth = Boolean(rcAuthUid || rcUid); - const isAuthenticated = hasDevSession || hasRealAuth; + const hasAuthJsSession = Boolean(authJsSessionToken || authJsSecureToken); + const isAuthenticated = hasDevSession || hasRealAuth || hasAuthJsSession; // ── /admin/* ────────────────────────────────────────────────────── if (isOnAdmin) { 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..8359f3a --- /dev/null +++ b/supabase/migrations/209_authjs_auto_create_admin.sql @@ -0,0 +1,66 @@ +-- 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; + +-- 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'; From 6c5ca6829f562ba148def052a4cb4e653831d375 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 20:31:13 +0000 Subject: [PATCH 11/18] docs(memory): Auth.js v5 wiring notes --- MEMORY.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/MEMORY.md b/MEMORY.md index 23ac3e5..dcd0839 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -379,3 +379,75 @@ 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. From 2d55791458350e568861cffb255916ee9d172c52 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 20:46:52 +0000 Subject: [PATCH 12/18] fix(deploy): PostgREST env + remove dead nextjs service from compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build was failing on the 'Start Docker stack' step with two issues: 1. PGRST_DB_URI not set — the env var was only in the 'Deploy' step, which runs after PostgREST has already started. PostgREST booted with a blank DB URI and the step exited 1. 2. docker-compose.yml had a 'nextjs' service with env_file: ../.env.production, but .env.production is written later by the 'Deploy' step. docker compose validates the entire compose file on 'up' and bailed because the path didn't exist yet. The 'nextjs' service is dead code anyway: PM2 runs Next.js directly from $APP_DIR, never through docker. Removed it. Also fixed: 'docker compose up -d db postgrest minio minio_init' referenced services that don't exist in the compose file (Postgres runs on the host, not in docker). Changed to just 'postgrest', and the pg_isready check now uses host psql directly instead of 'docker compose exec -T db'. Changes: - deploy/docker-compose.yml: drop nextjs service, keep only postgrest - .gitea/workflows/deploy.yml: - Add PGRST_DB_URI / PGRST_DB_ANON_ROLE / PGRST_SERVER_PORT to the 'Start Docker stack' step env - Write them to $APP_DIR/.env so docker compose picks them up - 'docker compose up -d postgrest' (was: db postgrest minio minio_init) - pg_isready check uses host psql (was: docker compose exec -T db) --- .gitea/workflows/deploy.yml | 22 ++++++++++++++---- deploy/docker-compose.yml | 46 ++++++------------------------------- 2 files changed, 25 insertions(+), 43 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8ca1290..d9550b3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -25,6 +25,13 @@ jobs: MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} + + # PostgREST — needs the DB URI at start time (it reads env + # from the container, not from .env.production which is + # written later by the Deploy step). + PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }} + PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }} + PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }} run: | APP_DIR=/home/tyler/route-commerce mkdir -p $APP_DIR @@ -92,15 +99,22 @@ jobs: echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}" echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}" + echo "PGRST_DB_URI=${PGRST_DB_URI}" + echo "PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-anon}" + echo "PGRST_SERVER_PORT=${PGRST_SERVER_PORT:-3000}" echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT" echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" } >> .env # Bring the stack up fresh — --force-recreate ensures no stale - # network/container references from prior failed attempts - docker compose up -d --force-recreate db postgrest minio minio_init - # Wait for Postgres healthcheck + # network/container references from prior failed attempts. + # Only `postgrest` lives in docker; Postgres itself runs on the + # host (see the migrations step below, which uses + # `psql -h 127.0.0.1`). + docker compose up -d --force-recreate postgrest + # Wait for Postgres to accept connections on the host. + # The DB is on 127.0.0.1, not in a docker service. for i in $(seq 1 30); do - if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then + if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT 1" > /dev/null 2>&1; then echo "Postgres is ready" break fi diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0dc22f1..27930f6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -2,17 +2,14 @@ # docker-compose.yml — production stack consumed by deploy.sh # ============================================================================= # -# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by -# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011} -# so a manual `docker compose up` without the deploy script still works. +# Only `postgrest` lives in docker. Postgres itself runs on the host +# (the deploy workflow applies migrations via `psql -h 127.0.0.1`). +# Next.js runs under PM2 on the host — it is NOT a docker service. # -# Note on networking: the Next.js container calls PostgREST on -# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined -# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves -# correctly. On Linux you may need to add -# extra_hosts: -# - "host.docker.internal:host-gateway" -# which is included below for that reason. +# The host-side port (POSTGREST_HOST_PORT) is written by the deploy +# workflow into $APP_DIR/.env. We interpolate from there with +# ${VAR:-3011} so a manual `docker compose up` without the deploy +# script still works. # ============================================================================= name: prod-app # default project name; deploy.sh overrides with -p @@ -39,32 +36,3 @@ services: timeout: 3s retries: 6 - nextjs: - # Build context is the workspace root (one level up from this file). - build: - context: .. - dockerfile: deploy/Dockerfile.nextjs - container_name: prod-app-nextjs - restart: unless-stopped - ports: - - "${NEXTJS_HOST_PORT:-3012}:3000" - environment: - # Runtime vars — these can change without rebuilding. NEXT_PUBLIC_* - # is also exported here for completeness, but the BROWSER's view of - # NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile). - NODE_ENV: production - PORT: 3000 - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} - env_file: - - ../.env.production # server-side secrets read at runtime - extra_hosts: - # Lets the container reach the host on the dynamically allocated port. - - "host.docker.internal:host-gateway" - depends_on: - postgrest: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"] - interval: 10s - timeout: 3s - retries: 6 From 48ce5665b91ec9631300eb37ae014d1f5bc12675 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 20:47:15 +0000 Subject: [PATCH 13/18] docs(memory): deploy fix notes --- MEMORY.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/MEMORY.md b/MEMORY.md index dcd0839..57c2466 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -451,3 +451,42 @@ just a `user_id` lookup in `getAdminUserFromPool()`. - 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) From e2e56252ecac015a878e418fc4ccf606924b3631 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 20:48:43 +0000 Subject: [PATCH 14/18] fix(deploy): always copy docker-compose.yml to server The 'Start Docker stack' step used '[ -f ... ] || cp' for docker-compose.yml, which only copied the file on the first deploy. Subsequent deploys kept the stale copy on the server. The stale copy still had the dead 'nextjs' service with 'env_file: ../.env.production', which docker compose validates on every 'up' and bailed because .env.production is written later by the 'Deploy' step. Changed to unconditional 'cp -f' so the server always has the latest compose file. --- .gitea/workflows/deploy.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index d9550b3..75f1e85 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -86,9 +86,12 @@ jobs: export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" export POSTGREST_HOST_PORT - # Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR) + # Seed config files into APP_DIR. The docker-compose.yml + # is copied UNCONDITIONALLY so deploys pick up compose + # changes (the previous `[ -f ... ] ||` guard kept stale + # copies on the server and masked a broken `env_file` path). [ -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 + cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml cd $APP_DIR [ -f .env ] || cp .env.example .env # Append production secrets to .env (overriding .env.example defaults) From b63d0415ab84bb8cfa0e4a45fb1701e45a1245cf Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 21:29:21 +0000 Subject: [PATCH 15/18] fix(deploy): seed docker-compose.yml before any docker compose cmd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'cp -f deploy/docker-compose.yml ...' was sitting AFTER 'docker compose down' in the step. 'docker compose down' reads and validates the compose file on the server — so the old copy (with the dead nextjs service and its env_file: ../.env.production reference) was still being read, and docker compose bailed on the missing .env.production file before the copy could overwrite it. Moved the config-file seeding to the top of the step, right after 'mkdir -p $APP_DIR'. Now the new compose file is in place before either 'docker compose down' or 'docker compose up' runs. --- .gitea/workflows/deploy.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 75f1e85..8a65740 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -36,6 +36,19 @@ jobs: 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 "") @@ -86,12 +99,6 @@ jobs: export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" export POSTGREST_HOST_PORT - # Seed config files into APP_DIR. The docker-compose.yml - # is copied UNCONDITIONALLY so deploys pick up compose - # changes (the previous `[ -f ... ] ||` guard kept stale - # copies on the server and masked a broken `env_file` path). - [ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example - cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml cd $APP_DIR [ -f .env ] || cp .env.example .env # Append production secrets to .env (overriding .env.example defaults) From 1cecbce392a9e3d9bae05693ec93cf3796311537 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 21:52:19 +0000 Subject: [PATCH 16/18] fix(auth): port dev auto-login to Next.js 16 proxy.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js 16 renamed middleware.ts to proxy.ts and only allows one of the two. Delete src/middleware.ts and rewrite src/proxy.ts to do the dev auto-login + /login bounce + /admin gate explicitly (no NextAuth auth() wrapper — auth.handler() returns a NextMiddleware taking (req, event), which makes wrapping it awkward when we also need to set cookies on the response). Drop the now-dead 'authorized' callback from auth.config.ts (it was only fired by the NextAuth wrapper, which we no longer use). Migration 209: add a defensive unique constraint on admin_users.user_id so the ON CONFLICT (user_id) clause in the new RPC resolves regardless of whether the Supabase-dashboard-created table shipped with one. --- src/auth.config.ts | 26 ------ src/middleware.ts | 86 ------------------ src/proxy.ts | 87 +++++++++++++++---- .../209_authjs_auto_create_admin.sql | 29 +++++++ 4 files changed, 101 insertions(+), 127 deletions(-) delete mode 100644 src/middleware.ts diff --git a/src/auth.config.ts b/src/auth.config.ts index c737ccd..a421eb6 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -44,32 +44,6 @@ export const authConfig = { session: { strategy: "jwt" }, 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 * initial sign-in. With database sessions this is what populates diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 17c17c4..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server"; - -/** - * Middleware for /admin/* and /login routes. - * - * Recognises three auth sources at the edge: - * 1. Auth.js v5 JWT cookie (`authjs.session-token` or the `__Secure-` - * variant in prod) — set after Google sign-in. - * 2. `dev_session`, `rc_auth_uid`, `rc_uid` — legacy / dev cookies. - * 3. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by - * default in non-prod) and the user has no auth cookie, automatically - * issue `dev_session=platform_admin` so visiting /admin just works. - * No buttons, no demo page, no client-side cookie games. - * - * This is the single source of truth for "am I allowed in?" at the edge. - * The page-level `getAdminUser()` re-checks the same cookies (and reads - * the Auth.js JWT) to resolve the role. - */ -export function middleware(request: NextRequest) { - const { nextUrl } = request; - const isOnAdmin = nextUrl.pathname.startsWith("/admin"); - const isOnLogin = nextUrl.pathname === "/login"; - - // Read auth cookies once. - const dev = request.cookies.get("dev_session")?.value; - const rcAuthUid = request.cookies.get("rc_auth_uid")?.value; - const rcUid = request.cookies.get("rc_uid")?.value; - // Auth.js v5 sets an encrypted JWT cookie. Names differ by transport: - // `authjs.session-token` (dev / HTTP) and `__Secure-authjs.session-token` - // (prod / HTTPS). The presence of either cookie is sufficient to - // consider the request authenticated at the edge — the page-level - // `getAdminUser()` re-reads the JWT and resolves the role. - const authJsSessionToken = request.cookies.get("authjs.session-token")?.value; - const authJsSecureToken = request.cookies.get("__Secure-authjs.session-token")?.value; - const hasDevSession = - dev === "platform_admin" || - dev === "brand_admin" || - dev === "store_employee"; - const hasRealAuth = Boolean(rcAuthUid || rcUid); - const hasAuthJsSession = Boolean(authJsSessionToken || authJsSecureToken); - const isAuthenticated = hasDevSession || hasRealAuth || hasAuthJsSession; - - // ── /admin/* ────────────────────────────────────────────────────── - if (isOnAdmin) { - if (isAuthenticated) { - return NextResponse.next(); - } - - // No auth cookie — try demo auto-login. ALLOW_DEV_LOGIN is on by - // default; set it to "false" in prod env to disable. - 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; - } - - // No auth and dev disabled — bounce to /login. - const loginUrl = nextUrl.clone(); - loginUrl.pathname = "/login"; - loginUrl.search = ""; - return NextResponse.redirect(loginUrl); - } - - // ── /login ──────────────────────────────────────────────────────── - if (isOnLogin) { - // If already authenticated, skip the login page. - if (isAuthenticated) { - const adminUrl = nextUrl.clone(); - adminUrl.pathname = "/admin"; - adminUrl.search = ""; - return NextResponse.redirect(adminUrl); - } - return NextResponse.next(); - } - - return NextResponse.next(); -} - -export const config = { - matcher: ["/admin/:path*", "/login"], -}; 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 index 8359f3a..fd80621 100644 --- a/supabase/migrations/209_authjs_auto_create_admin.sql +++ b/supabase/migrations/209_authjs_auto_create_admin.sql @@ -11,6 +11,35 @@ 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. -- From 5654ebaecde0e2aeecd84a6dfd3c3532ebab057f Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 22:13:56 +0000 Subject: [PATCH 17/18] chore(auth): remove legacy rc_auth_uid/rc_uid/upsert_admin_user path; scope sign-in to ADMIN_ALLOWED_EMAILS; silence lockfile warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup after Auth.js v5 became the only sign-in path. The platform had three overlapping auth modes (dev cookie, legacy rc_auth_uid, Auth.js JWT) and a pile of dead-code pages/routes that only existed to support the legacy path. What changed: * getAdminUser() now has only two auth paths: 1. dev_session cookie (auto-issued by src/proxy.ts for /admin/* when ALLOW_DEV_LOGIN is enabled) 2. Auth.js v5 JWT (the encrypted cookie + auth() lookup) The legacy rc_auth_uid/rc_uid branch and the Supabase REST fetch against admin_users are gone. * The signIn callback in src/lib/auth.ts enforces ADMIN_ALLOWED_EMAILS when set. Unset = open mode (backward compatible with demo/dev). Dev credentials provider is exempt. The new env var is wired through .env.example and .gitea/workflows/deploy.yml (read from secrets.ADMIN_ALLOWED_EMAILS, written to the server .env file). * change-password/page.tsx now uses auth() server-side instead of fetching the deleted /api/auth/uid endpoint. The form is split into page.tsx (server component, auth check) + ChangePasswordForm.tsx (client component, form state). updatePasswordAction now reads the user id from auth() instead of the rc_auth_uid cookie. * Deleted 14 dead-code files: - Pages: login2, logout, auth/callback, admin/debug-auth, admin/test-auth - API routes: api/login, api/logout, api/auth/uid, api/force-admin, api/set-auth-cookie, api/debug-cookie, api/debug-me, api/debug-auth - Actions: src/actions/login.ts These were the old email/password login, the old Supabase OAuth callback, the old /api/auth/uid probe, and a pile of debug endpoints that have been superseded by the new proxy + the new /login page. * next.config.ts: set outputFileTracingRoot: '.' to silence the Next.js 16 lockfile-inference warning. Without this the build walked up from package.json looking for a lockfile, found the homelab runner's stale act cache at /home/tyler/.cache/act/.../package-lock.json, and warned on every build. '. resolves to the project root in both dev and CI, so it's the right answer. Out of scope (deferred): * src/actions/admin/users.ts still uses rc_auth_uid internally for its dev-bypass logic. It works (the rc_auth_uid branch is gated on NODE_ENV != 'production' and DEV_FORCE_UID), but it's now genuinely unreachable in production. Clean up in a follow-up. Pre-flight: * npx tsc --noEmit: clean * npm run lint (touched files): clean * npm run build: clean — proxy picked up, no lockfile warning, all 93 static pages generated. --- .env.example | 7 + .gitea/workflows/deploy.yml | 3 + next.config.ts | 11 ++ src/actions/admin/password.ts | 24 ++- src/actions/login.ts | 56 ------ src/app/admin/debug-auth/page.tsx | 60 ------- src/app/admin/test-auth/page.tsx | 90 ---------- src/app/api/auth/uid/route.ts | 11 -- src/app/api/debug-auth/route.ts | 48 ----- src/app/api/debug-cookie/route.ts | 18 -- src/app/api/debug-me/route.ts | 80 --------- src/app/api/force-admin/route.ts | 31 ---- src/app/api/login/route.ts | 47 ----- src/app/api/logout/route.ts | 12 -- src/app/api/set-auth-cookie/route.ts | 22 --- src/app/auth/callback/page.tsx | 54 ------ .../change-password/ChangePasswordForm.tsx | 120 +++++++++++++ src/app/change-password/page.tsx | 168 +++--------------- src/app/login2/page.tsx | 105 ----------- src/app/logout/page.tsx | 41 ----- src/lib/admin-permissions.ts | 95 +--------- src/lib/auth.ts | 42 +++++ 22 files changed, 229 insertions(+), 916 deletions(-) delete mode 100644 src/actions/login.ts delete mode 100644 src/app/admin/debug-auth/page.tsx delete mode 100644 src/app/admin/test-auth/page.tsx delete mode 100644 src/app/api/auth/uid/route.ts delete mode 100644 src/app/api/debug-auth/route.ts delete mode 100644 src/app/api/debug-cookie/route.ts delete mode 100644 src/app/api/debug-me/route.ts delete mode 100644 src/app/api/force-admin/route.ts delete mode 100644 src/app/api/login/route.ts delete mode 100644 src/app/api/logout/route.ts delete mode 100644 src/app/api/set-auth-cookie/route.ts delete mode 100644 src/app/auth/callback/page.tsx create mode 100644 src/app/change-password/ChangePasswordForm.tsx delete mode 100644 src/app/login2/page.tsx delete mode 100644 src/app/logout/page.tsx 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/deploy.yml b/.gitea/workflows/deploy.yml index 8a65740..fc78256 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -169,6 +169,7 @@ jobs: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} + ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }} # Supabase (legacy, still used by admin pages/server actions until # the Auth.js migration is finished) @@ -221,6 +222,7 @@ jobs: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} + ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }} # Storage STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} @@ -277,6 +279,7 @@ jobs: printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID" printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET" printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN" + printf "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS" printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT" printf "STORAGE_REGION=%s\n" "$STORAGE_REGION" printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY" 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/admin/password.ts b/src/actions/admin/password.ts index 24e317e..f677fd9 100644 --- a/src/actions/admin/password.ts +++ b/src/actions/admin/password.ts @@ -1,18 +1,24 @@ "use server"; -import { cookies } from "next/headers"; +import { auth } from "@/lib/auth"; 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( newPassword: string ): Promise<{ error?: string }> { - const cookieStore = await cookies(); - const uid = - cookieStore.get("rc_auth_uid")?.value ?? - cookieStore.get("rc_uid")?.value; + const session = await auth(); + const userId = session?.user?.id; - if (!uid) { - return { error: "Not authenticated. Please log in again." }; + if (!userId) { + return { error: "Not authenticated. Please sign in again." }; } const service = createServiceClient( @@ -21,10 +27,10 @@ export async function updatePasswordAction( ); const { error } = await service.rpc("update_user_password", { - p_user_id: uid, + p_user_id: userId, p_password: newPassword, }); if (error) return { error: error.message }; return {}; -} \ No newline at end of file +} diff --git a/src/actions/login.ts b/src/actions/login.ts deleted file mode 100644 index da40bbc..0000000 --- a/src/actions/login.ts +++ /dev/null @@ -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 { - 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 }; -} diff --git a/src/app/admin/debug-auth/page.tsx b/src/app/admin/debug-auth/page.tsx deleted file mode 100644 index f949329..0000000 --- a/src/app/admin/debug-auth/page.tsx +++ /dev/null @@ -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 ( -
-

Auth Debug

- -
-

Cookies ({allCookies.length})

-
-          {allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
-        
-
- -
-

Key Cookies

-

rc_auth_uid: {rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}

-

rc_uid: {rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}

-

rc_access_token: {rcAccessToken ? "PRESENT" : "NOT SET"}

-
- -
-

Admin Users Lookup

-

Status: {adminUsersStatus}

-

Result:

{adminUsersResult || "(none)"}

-
-
- ); -} \ No newline at end of file diff --git a/src/app/admin/test-auth/page.tsx b/src/app/admin/test-auth/page.tsx deleted file mode 100644 index cdbaafb..0000000 --- a/src/app/admin/test-auth/page.tsx +++ /dev/null @@ -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 ( -
-

Auth Debug

- -
-

All Cookies ({allCookies.length})

-
-          {allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
-        
-
- -
-

rc_auth_uid

-

- {allCookies.some(c => c.name === "rc_auth_uid") - ? SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value} - : NOT SET - } -

-
- -
-

rc_access_token

-

- {allCookies.some(c => c.name === "rc_access_token") - ? SET (not needed) - : NOT SET (OK) - } -

-
- -
-

getAdminUser() result

- {error ? ( -
-

ERROR

-
{error}
-
- ) : adminUser ? ( -
-

AUTHENTICATED

-
-              {JSON.stringify({
-                id: adminUser.id,
-                user_id: adminUser.user_id,
-                role: adminUser.role,
-                brand_id: adminUser.brand_id,
-                active: adminUser.active,
-              }, null, 2)}
-            
-
- ) : ( -

NOT AUTHENTICATED — null returned

- )} -
- -
-

Quick Actions

-
- - Go to Admin - -
- -
-
-
-
- ); -} \ No newline at end of file diff --git a/src/app/api/auth/uid/route.ts b/src/app/api/auth/uid/route.ts deleted file mode 100644 index 9a96310..0000000 --- a/src/app/api/auth/uid/route.ts +++ /dev/null @@ -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 }); -} \ No newline at end of file diff --git a/src/app/api/debug-auth/route.ts b/src/app/api/debug-auth/route.ts deleted file mode 100644 index 41ca183..0000000 --- a/src/app/api/debug-auth/route.ts +++ /dev/null @@ -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, - }, - }); -} \ No newline at end of file diff --git a/src/app/api/debug-cookie/route.ts b/src/app/api/debug-cookie/route.ts deleted file mode 100644 index eed9951..0000000 --- a/src/app/api/debug-cookie/route.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/app/api/debug-me/route.ts b/src/app/api/debug-me/route.ts deleted file mode 100644 index 72aa90b..0000000 --- a/src/app/api/debug-me/route.ts +++ /dev/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), - }); -} diff --git a/src/app/api/force-admin/route.ts b/src/app/api/force-admin/route.ts deleted file mode 100644 index 97b7de7..0000000 --- a/src/app/api/force-admin/route.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts deleted file mode 100644 index abfea1a..0000000 --- a/src/app/api/login/route.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts deleted file mode 100644 index 3846a1b..0000000 --- a/src/app/api/logout/route.ts +++ /dev/null @@ -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 }); -} diff --git a/src/app/api/set-auth-cookie/route.ts b/src/app/api/set-auth-cookie/route.ts deleted file mode 100644 index b281a89..0000000 --- a/src/app/api/set-auth-cookie/route.ts +++ /dev/null @@ -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 }); -} \ No newline at end of file diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx deleted file mode 100644 index 19ad3b4..0000000 --- a/src/app/auth/callback/page.tsx +++ /dev/null @@ -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 ( -
-
Verifying reset link...
-
- ); -} \ No newline at end of file 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/app/change-password/page.tsx b/src/app/change-password/page.tsx index ea8a5f4..0059fe1 100644 --- a/src/app/change-password/page.tsx +++ b/src/app/change-password/page.tsx @@ -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"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { updatePasswordAction } from "@/actions/admin/password"; -import { logUserActivity } from "@/actions/admin/audit"; +export const dynamic = "force-dynamic"; -export default function ChangePasswordPage() { - const router = useRouter(); - const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const [checkingSession, setCheckingSession] = useState(true); - const [userId, setUserId] = useState(null); +/** + * Forced password-change page. + * + * Linked from `src/app/admin/layout.tsx:64` when the admin user's row + * has `must_change_password = true`. Verifies the Auth.js session + * server-side, then renders the form with the user id passed as a prop. + * + * 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(() => { - fetch("/api/auth/uid") - .then((r) => r.json()) - .then((data) => { - if (!data.uid) { - router.push("/login"); - } else { - setUserId(data.uid); - setCheckingSession(false); - } - }) - .catch(() => router.push("/login")); - }, [router]); - - async function handleSubmit(e: React.FormEvent) { - 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 (!userId) { + redirect("/login"); } - if (checkingSession) { - return ( -
-
-
-

Checking session...

-
-
-
- ); - } - - 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" - /> -
- - -
- -
- - Sign out instead - -
-
-
-
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/login2/page.tsx b/src/app/login2/page.tsx deleted file mode 100644 index 2aaaf5d..0000000 --- a/src/app/login2/page.tsx +++ /dev/null @@ -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(null); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - 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 ( -
-
-
-

Admin Login

-

Sign in to your account.

- -
- {error && ( -
- {error} -
- )} - -
- - 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" - /> -
- -
- - 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="••••••••" - /> -
- - -
-
- - - ← Back to storefront - -
-
- ); -} \ No newline at end of file diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx deleted file mode 100644 index a2dea5f..0000000 --- a/src/app/logout/page.tsx +++ /dev/null @@ -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 ( -
-
-
-

Signing out...

-
-
-
- ); -} diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts index cd1ea9c..9b360af 100644 --- a/src/lib/admin-permissions.ts +++ b/src/lib/admin-permissions.ts @@ -12,13 +12,17 @@ export type { AdminUser } from "./admin-permissions-types"; * 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee). * 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). - * 4. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids. + * + * 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. * For dev sessions without a real DB, `brand_ids` is populated by: * - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table) - * - store_employee: `[]` if a brand exists, else `[]` - * - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev) + * - store_employee: `[]` + * - brand_admin: `[]` */ export async function getAdminUser(): Promise { const cookieStore = await cookies(); @@ -45,65 +49,7 @@ export async function getAdminUser(): Promise { if (admin) return admin; } - // ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─ - const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value; - if (!uid) return null; - - if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) { - return null; - } - - // Lookup admin_users by Supabase auth user id - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - let adminUsers: unknown[] = []; - 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, []); - } - } - } catch (e) { - // RPC failed silently - } - return null; - } - - const admin = adminUsers[0] as Record; - 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); + return null; } /** @@ -149,31 +95,6 @@ async function fetchAdminUserBrandIdsFromPool(adminRowId: string): Promise { - try { - const res = await fetch( - `${supabaseUrl}/rest/v1/admin_user_brands?admin_user_id=eq.${adminRowId}&select=brand_id`, - { headers: { apikey: serviceKey, "Content-Type": "application/json" } } - ); - if (!res.ok) return []; - const data = await res.json().catch(() => []); - if (!Array.isArray(data)) return []; - return data - .map((row: Record) => row.brand_id as string) - .filter((id): id is string => typeof id === "string"); - } catch { - return []; - } -} - function buildDevAdmin(role: string): AdminUser { // For dev sessions we don't have an admin_user_brands junction row to load. // - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands). diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d3f02f8..0f7bbcb 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -46,6 +46,22 @@ function buildDevCredentialsProvider() { * Note: when using a database adapter the session strategy is fixed to * "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 | 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({ ...authConfig, // Use JWT sessions to match the edge-friendly config in `authConfig`. @@ -62,6 +78,32 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig.providers, ...(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 ?? ""} not in ADMIN_ALLOWED_EMAILS` + ); + return false; + } + return true; + }, + }, events: { /** * First-time sign-in: auto-create a `platform_admin` row in From 3f731f7739126a59a3fe3000a45ff0b44b1327fb Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 22:35:24 +0000 Subject: [PATCH 18/18] fix(admin): wrap post-auth data loads in try/catch to surface and survive SC render errors The /admin page was throwing a Server Components render error (digest 4266906817) in production. The exact throw point was not visible from the browser (Next.js hides it for security) and server logs were not available at debugging time. The layout and page both call data-loading functions (getActiveBrandId, listBrandsForAdmin, supabase brands lookup) that could throw on a transient DB/network failure, and these calls had no try/catch. A single failed call would crash the entire admin shell. Wrap each call in try/catch with console.error so: 1. The page renders with sensible defaults if a call fails 2. The actual error is logged server-side (visible via the digest in the admin error boundary or PM2/Docker logs) 3. The admin shell stays functional even if a single data source is down No behavior change on the happy path. --- src/app/admin/layout.tsx | 24 ++++++++++++++++++------ src/app/admin/page.tsx | 21 +++++++++++++-------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index c275666..0d42720 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -64,13 +64,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN redirect("/change-password"); } - // Resolve the active brand (URL > cookie > legacy > first of brand_ids) - const activeBrandId = await getActiveBrandId(adminUser); + // Resolve the active brand (URL > cookie > legacy > first of brand_ids). + // Wrapped in try/catch so a transient brand-resolution failure can't + // crash the whole admin shell — we fall back to null (no active brand). + let activeBrandId: string | null = null; + try { + activeBrandId = await getActiveBrandId(adminUser); + } catch (err) { + console.error("[admin/layout] getActiveBrandId failed:", err); + } - // Fetch accessible brands for the sidebar BrandSelector. We do this - // unconditionally — `listBrandsForAdmin` is cheap and the sidebar - // decides whether to show the dropdown. - const brands = await listBrandsForAdmin(); + // Fetch accessible brands for the sidebar BrandSelector. Wrapped in + // try/catch so the sidebar renders empty rather than crashing the page + // if the brands query fails. + let brands: Awaited> = []; + 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); } }