From 158b9416bf52119d20d253f85ad8ff95585802cb Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 26 Jun 2026 01:06:10 -0400 Subject: [PATCH] Fix Docker signup and document deploy, migrate, and rebuild flow. Use FormData and server-side sign-in after register, trust proxy origins for reverse-proxy deploys, pass public URL build args in Docker, and clarify that git pull plus up -d --build is required to ship code and run pending migrations. Co-authored-by: Cursor --- .env.example | 15 +++++++- Dockerfile | 6 ++- README.md | 34 +++++++++++++--- docker-compose.yml | 3 ++ src/app/api/auth/register/route.ts | 38 ++++++++++-------- src/app/auth/register/page.tsx | 60 ++++++++++++++++------------- src/app/auth/signin/signin-form.tsx | 6 ++- src/lib/auth-client.ts | 1 + src/lib/auth.ts | 42 +++++++++++++++----- src/lib/env-boolean.ts | 6 +++ src/proxy.ts | 3 +- 11 files changed, 151 insertions(+), 63 deletions(-) create mode 100644 src/lib/env-boolean.ts diff --git a/.env.example b/.env.example index 6df5acb..481a6c9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,17 @@ # beenvoice-web environment +# # Local dev: cp .env.example .env.local # Docker: cp .env.example .env +# +# Docker deploy checklist: +# 1. Set AUTH_SECRET, BETTER_AUTH_URL, NEXT_PUBLIC_APP_URL to your public URL +# 2. docker compose build --no-cache app # NEXT_PUBLIC_* is baked in at build +# 3. docker compose up -d --build # migrations run on app container start +# +# Updating: git pull && docker compose up -d --build +# (git pull alone does not rebuild; up -d without --build keeps the old image) +# +# Migrations are idempotent — only pending SQL files are applied on each start. # Runtime NODE_ENV=production @@ -9,9 +20,11 @@ WEB_PORT=3000 # Auth # Generate with: openssl rand -base64 32 AUTH_SECRET=change-me-generate-a-real-secret +# Must match the URL users open in the browser (include https:// and port if non-standard). BETTER_AUTH_URL=http://localhost:3000 -# Public app URL +# Public app URL — baked into the client bundle at Docker build time. +# Set this to the same value as BETTER_AUTH_URL before `docker compose build`. NEXT_PUBLIC_APP_URL=http://localhost:3000 # Postgres used by docker-compose.yml diff --git a/Dockerfile b/Dockerfile index 543bb2f..9905f7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,14 @@ FROM base AS build COPY --from=install /usr/src/app/node_modules node_modules COPY . . +ARG NEXT_PUBLIC_APP_URL=http://localhost:3000 +ARG BETTER_AUTH_URL=http://localhost:3000 + ENV NODE_ENV=production \ SKIP_ENV_VALIDATION=1 \ NODE_OPTIONS=--max-old-space-size=4096 \ - BETTER_AUTH_URL=http://localhost:3000 \ + BETTER_AUTH_URL=${BETTER_AUTH_URL} \ + NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \ AUTH_SECRET=docker-build-placeholder-secret-do-not-use \ DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres RUN bun run build diff --git a/README.md b/README.md index 92fe2f4..3b9c7c9 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,9 @@ Open [http://localhost:3000](http://localhost:3000), register at `/auth/register ## Docker deployment (app + database) -The production compose file runs the Next.js app and PostgreSQL. Migrations run automatically on container start (`bun migrate.ts` in the image `CMD`). +The production compose file runs the Next.js app and PostgreSQL. + +**Container startup** runs `bun migrate.ts && bun run start` (see `Dockerfile`). Drizzle only applies **pending** migrations — safe to run on every restart; already-applied migrations are skipped. ### 1. Configure @@ -106,23 +108,43 @@ BETTER_AUTH_URL=https://your-public-hostname NEXT_PUBLIC_APP_URL=https://your-public-hostname ``` -`BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` must match the URL users actually use in the browser. If they point at `localhost` but you access the app via another hostname, auth (sign-in / sign-up) will fail. +`BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` must match the URL users actually use in the browser (scheme + host + port). If they point at `localhost` but you access the app via another hostname, sign-up and sign-in will fail (often with a vague **"REQUIRED"** toast). -`NEXT_PUBLIC_*` values are embedded at **image build** time. Rebuild after changing white-label or Authentik client flags: +`NEXT_PUBLIC_*` values are embedded at **image build** time. Rebuild after changing `NEXT_PUBLIC_APP_URL`, white-label defaults, or `NEXT_PUBLIC_AUTHENTIK_ENABLED`: ```bash docker compose build --no-cache app ``` -### 2. Start +`BETTER_AUTH_URL` and `AUTH_SECRET` are read at **container runtime** from `.env` — you can change them without rebuilding, then restart the app container. + +### 2. First start (or after code changes) ```bash docker compose up -d --build ``` +`--build` is important. A plain `docker compose up -d` reuses the existing image and **does not** pick up new code from `git pull`. + App listens on `${WEB_PORT:-3000}`. Postgres stays on the internal compose network. -### 3. Sign-ups +### 3. Updating an existing deploy + +```bash +git pull +docker compose up -d --build # rebuild image, restart app, run any new migrations +``` + +| Command | New code? | Migrations run? | +|---------|-----------|-----------------| +| `git pull` only | No | No | +| `docker compose up -d` (no `--build`) | No — old image | Only if the app container restarts (same image) | +| `docker compose up -d --build` | Yes | Yes — on app container start | +| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) | + +To verify migration files match the journal before deploy: `bun run db:verify-journal`. + +### 4. Sign-ups Registration is **enabled** by default. To block new email/password accounts: @@ -132,7 +154,7 @@ DISABLE_SIGNUPS=true Use the literal strings `true` or `false` (or omit the variable). Do not rely on bare boolean coercion from shell/compose — the app parses these explicitly. -### 4. Optional services +### 5. Optional services | Variable | Purpose | |----------|---------| diff --git a/docker-compose.yml b/docker-compose.yml index 5a95ebb..f77a5aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,9 @@ services: app: build: context: . + args: + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000} image: beenvoice:local environment: NODE_ENV: production diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 8953fe2..17ce844 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -2,31 +2,19 @@ import bcrypt from "bcryptjs"; import { eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { auth } from "~/lib/auth"; import { env } from "~/env"; import { db } from "~/server/db"; import { accounts, users } from "~/server/db/schema"; const registerSchema = z .object({ - firstName: z.string().trim().optional(), - lastName: z.string().trim().optional(), + firstName: z.string().trim().min(1, "First name is required"), + lastName: z.string().trim().min(1, "Last name is required"), name: z.string().trim().optional(), email: z.string().email("Invalid email address"), password: z.string().min(8, "Password must be at least 8 characters"), }) - .superRefine((data, ctx) => { - const hasSplitName = - Boolean(data.firstName?.length) && Boolean(data.lastName?.length); - const hasFullName = Boolean(data.name?.length); - - if (!hasSplitName && !hasFullName) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "First and last name are required", - path: ["firstName"], - }); - } - }) .transform((data) => { if (data.name?.length) { const parts = data.name.trim().split(/\s+/); @@ -41,8 +29,8 @@ const registerSchema = z } return { - firstName: data.firstName!.trim(), - lastName: data.lastName!.trim(), + firstName: data.firstName, + lastName: data.lastName, email: data.email, password: data.password, }; @@ -151,6 +139,22 @@ export async function POST(request: NextRequest) { }); }); + try { + await auth.api.signInEmail({ + body: { + email: normalizedEmail, + password, + }, + headers: request.headers, + }); + } catch (signInError) { + console.error("Post-register sign-in failed:", signInError); + return NextResponse.json( + { message: "User created successfully", signInRequired: true }, + { status: 201 }, + ); + } + return NextResponse.json( { message: "User created successfully" }, { status: 201 }, diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 6fab12d..3740304 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -9,24 +9,27 @@ import { Label } from "~/components/ui/label"; import { toast } from "sonner"; import { Logo } from "~/components/branding/logo"; import { LegalAgreementNotice } from "~/components/legal/legal-links"; -import { authClient } from "~/lib/auth-client"; import { Mail, Lock, ArrowRight, User } from "lucide-react"; +function formatAuthError(message: string | undefined, fallback: string): string { + if (!message || message === "Required") { + return fallback; + } + return message; +} + function RegisterForm() { const router = useRouter(); - - const [firstName, setFirstName] = useState(""); - const [lastName, setLastName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); - async function handleRegister(e: React.FormEvent) { + async function handleRegister(e: React.FormEvent) { e.preventDefault(); - const trimmedFirstName = firstName.trim(); - const trimmedLastName = lastName.trim(); - const trimmedEmail = email.trim(); + const formData = new FormData(e.currentTarget); + const trimmedFirstName = String(formData.get("firstName") ?? "").trim(); + const trimmedLastName = String(formData.get("lastName") ?? "").trim(); + const trimmedEmail = String(formData.get("email") ?? "").trim(); + const password = String(formData.get("password") ?? ""); if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) { toast.error("Please enter your first name, last name, and email."); @@ -52,19 +55,22 @@ function RegisterForm() { }), }); - const data = (await res.json()) as { error?: string }; - - if (!res.ok) { - toast.error(data.error ?? "Registration failed"); + let data: { error?: string; signInRequired?: boolean } = {}; + try { + data = (await res.json()) as typeof data; + } catch { + toast.error("Registration failed. Please try again."); return; } - const { error: signInError } = await authClient.signIn.email({ - email: trimmedEmail, - password, - }); + if (!res.ok) { + toast.error( + formatAuthError(data.error, "Registration failed. Please check the form."), + ); + return; + } - if (signInError) { + if (data.signInRequired) { toast.success("Account created! Please sign in."); router.push("/auth/signin"); return; @@ -106,11 +112,11 @@ function RegisterForm() { setFirstName(e.target.value)} required autoFocus + autoComplete="given-name" className="h-10 pl-10" placeholder="John" /> @@ -123,10 +129,10 @@ function RegisterForm() { setLastName(e.target.value)} required + autoComplete="family-name" className="h-10 pl-10" placeholder="Doe" /> @@ -140,10 +146,10 @@ function RegisterForm() { setEmail(e.target.value)} required + autoComplete="email" className="h-10 pl-10" placeholder="you@example.com" /> @@ -156,11 +162,11 @@ function RegisterForm() { setPassword(e.target.value)} required minLength={8} + autoComplete="new-password" className="h-10 pl-10" placeholder="••••••••" /> diff --git a/src/app/auth/signin/signin-form.tsx b/src/app/auth/signin/signin-form.tsx index d2425a2..ae9040f 100644 --- a/src/app/auth/signin/signin-form.tsx +++ b/src/app/auth/signin/signin-form.tsx @@ -36,7 +36,11 @@ export function SignInForm({ allowRegistration }: SignInFormProps) { setLoading(false); if (error) { - toast.error(error.message ?? "Invalid email or password"); + toast.error( + error.message && error.message !== "Required" + ? error.message + : "Invalid email or password", + ); } else { toast.success("Signed in successfully!"); router.push(callbackUrl); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 3b7bde8..8146bcd 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -7,5 +7,6 @@ import { genericOAuthClient } from "better-auth/client/plugins"; * Auth client configuration */ export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_APP_URL, plugins: [genericOAuthClient()], }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 298090a..5920e0d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,6 +3,7 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; import { genericOAuth } from "better-auth/plugins"; +import { envBoolean } from "~/lib/env-boolean"; import { db } from "~/server/db"; import * as schema from "~/server/db/schema"; @@ -11,7 +12,7 @@ const authentikEnabled = Boolean( process.env.AUTHENTIK_CLIENT_ID && process.env.AUTHENTIK_CLIENT_SECRET, ); -const signupsDisabled = process.env.DISABLE_SIGNUPS === "true"; +const signupsDisabled = envBoolean(process.env.DISABLE_SIGNUPS); // Derive the authentik origin from the issuer URL so the OAuth callback is // automatically trusted without needing a separate AUTHENTIK_ORIGIN env var. @@ -20,9 +21,21 @@ const authentikOrigin = ? new URL(process.env.AUTHENTIK_ISSUER).origin : null; +const staticTrustedOrigins = [ + ...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []), + ...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []), + "beenvoice://", + "exp://", + ...(authentikOrigin ? [authentikOrigin] : []), + ...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []), +]; + export const auth = betterAuth({ baseURL: process.env.BETTER_AUTH_URL, secret: process.env.AUTH_SECRET, + advanced: { + trustedProxyHeaders: true, + }, experimental: { joins: true, }, @@ -35,14 +48,25 @@ export const auth = betterAuth({ verification: schema.verificationTokens, }, }), - trustedOrigins: [ - ...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []), - ...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []), - "beenvoice://", - "exp://", - ...(authentikOrigin ? [authentikOrigin] : []), - ...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []), - ], + trustedOrigins: async (request) => { + const origins = [...staticTrustedOrigins]; + + if (!request) return origins; + + const origin = request.headers.get("origin"); + if (origin) origins.push(origin); + + const forwardedHost = request.headers.get("x-forwarded-host"); + const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https"; + if (forwardedHost) { + for (const host of forwardedHost.split(",")) { + const trimmed = host.trim(); + if (trimmed) origins.push(`${forwardedProto}://${trimmed}`); + } + } + + return origins; + }, ...(authentikEnabled && { accountLinking: { enabled: true, diff --git a/src/lib/env-boolean.ts b/src/lib/env-boolean.ts new file mode 100644 index 0000000..3a20d8b --- /dev/null +++ b/src/lib/env-boolean.ts @@ -0,0 +1,6 @@ +/** Parse env vars that Docker Compose passes as strings ("true" / "false"). */ +export function envBoolean(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; +} diff --git a/src/proxy.ts b/src/proxy.ts index 5c68e95..cd82a78 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,10 +1,11 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { envBoolean } from "~/lib/env-boolean"; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; - if (pathname === "/auth/register" && process.env.DISABLE_SIGNUPS === "true") { + if (pathname === "/auth/register" && envBoolean(process.env.DISABLE_SIGNUPS)) { const signInUrl = new URL("/auth/signin", request.url); signInUrl.searchParams.set("signup", "disabled"); return NextResponse.redirect(signInUrl);