diff --git a/.env.example b/.env.example index b2159b5..6aa463d 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ # Quick start (Docker app + Postgres): # cp .env.example .env # # edit AUTH_SECRET + public URLs below -# docker compose up -d --build +# ./scripts/docker-deploy.sh # # ----------------------------------------------------------------------------- # Build-time vs runtime (Docker) @@ -31,7 +31,8 @@ # emails, and MCP links. In the browser, sign-in uses the current page origin # automatically so dev works when Next picks another port (e.g. 3002). # -# Updating production: git pull && docker compose up -d --build +# Updating production: git pull && ./scripts/docker-deploy.sh +# (or: docker compose up -d --build). Plain `docker compose up -d` does NOT rebuild. # Migrations run on every app start (idempotent — only pending SQL is applied). # ============================================================================= @@ -75,6 +76,10 @@ POSTGRES_PORT=5432 # Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000). WEB_PORT=3000 +# App image tag for docker-compose.yml (optional). docker-deploy.sh sets +# beenvoice: automatically; default without it is beenvoice:local. +# BEENVOICE_IMAGE=beenvoice:local + # Postgres credentials for docker-compose.yml `db` service. # DATABASE_URL inside the app container is set by compose (host `db`, not localhost). POSTGRES_USER=postgres @@ -112,8 +117,9 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Access control (optional) # ============================================================================= -# Block new email/password registrations. Use literal true or false. -# DISABLE_SIGNUPS=true +# Block new email/password registrations (default: true / signups off). +# Set DISABLE_SIGNUPS=false to allow new email/password signups. +# DISABLE_SIGNUPS=false # Bearer token for POST /api/cron/generate-recurring (recurring invoice cron). # CRON_SECRET= diff --git a/README.md b/README.md index cd1b052..d870f0c 100644 --- a/README.md +++ b/README.md @@ -132,10 +132,12 @@ docker compose build --no-cache app ### 2. First start (or after code changes) ```bash -docker compose up -d --build +./scripts/docker-deploy.sh +# or: bun run docker:deploy +# or: 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`. +`--build` is required after code changes. A plain `docker compose up -d` reuses the existing `beenvoice:local` image and **does not** pick up new code from `git pull`. The deploy script tags the image with the current git SHA (`beenvoice:`) so each deploy gets a distinct image. App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network. @@ -143,16 +145,19 @@ App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is alway ```bash git pull -docker compose up -d --build # rebuild image, restart app, run any new migrations +./scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart +# or: docker compose up -d --build ``` | 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 up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) | +| `./scripts/docker-deploy.sh` or `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) | +Prune old app images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` tags). + To verify migration files match the journal before deploy: `bun run db:verify-journal`. ### 4. Sign-ups @@ -213,12 +218,13 @@ bun run lint:fix bun run format:write bun run typecheck -# Docker helpers (Postgres only — uses Colima on macOS) -bun run docker:up # colima start + docker-compose.dev.yml up -d +# Docker helpers +bun run docker:up # dev Postgres only (Colima + docker-compose.dev.yml) bun run docker:down # stop dev Postgres + colima +bun run docker:deploy # production: rebuild app image + docker-compose.yml up -d ``` -Full-stack deploy uses `docker compose up` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`. +Full-stack deploy uses `bun run docker:deploy` or `./scripts/docker-deploy.sh` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`. ## API surface diff --git a/docker-compose.yml b/docker-compose.yml index c8bed66..956b3be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,9 @@ +# Production stack (app + Postgres). Local dev Postgres-only: docker-compose.dev.yml +# +# After git pull, rebuild before starting — a plain `docker compose up -d` reuses +# the existing local image and will NOT include new code. Use: +# ./scripts/docker-deploy.sh +# docker compose up -d --build services: app: build: @@ -5,7 +11,9 @@ services: args: NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000} - image: beenvoice:local + # Fixed default tag (beenvoice:local) is reused until you --build. docker-deploy.sh + # sets BEENVOICE_IMAGE=beenvoice: so each deploy gets a fresh tag. + image: ${BEENVOICE_IMAGE:-beenvoice:local} environment: NODE_ENV: production AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env} @@ -18,7 +26,7 @@ services: NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} - DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false} + DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a3e758d..c23bb36 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -190,6 +190,8 @@ App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run s Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars. +**Deploy / update:** `git pull && ./scripts/docker-deploy.sh` (or `docker compose up -d --build`). Plain `docker compose up -d` reuses the local `beenvoice:local` image and does not include pulled code. The deploy script tags images as `beenvoice:`. + ## Scripts ```bash diff --git a/package.json b/package.json index 3962c2f..987005e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d", "docker:down": "docker compose -f docker-compose.dev.yml down && colima stop", "docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop", + "docker:deploy": "./scripts/docker-deploy.sh", "deploy": "drizzle-kit push && next build", "dev": "next dev --turbo", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", diff --git a/scripts/docker-deploy.sh b/scripts/docker-deploy.sh new file mode 100755 index 0000000..ba8c035 --- /dev/null +++ b/scripts/docker-deploy.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml). +# Rebuilds the app image from the current working tree, then starts/restarts services. +# +# Plain `docker compose up -d` reuses the local image tag and does NOT pick up +# changes from `git pull`. Always pass --build or use this script after pulling. + +cd "$(dirname "$0")/.." + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +if [[ -z "${BEENVOICE_IMAGE:-}" ]] && command -v git >/dev/null 2>&1; then + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + BEENVOICE_IMAGE="beenvoice:$(git rev-parse --short HEAD)" + export BEENVOICE_IMAGE + fi +fi + +BEENVOICE_IMAGE="${BEENVOICE_IMAGE:-beenvoice:local}" +export BEENVOICE_IMAGE + +echo "Deploying ${BEENVOICE_IMAGE} (docker compose up -d --build)..." +exec docker compose up -d --build "$@" diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 88e3087..b62584a 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -1,5 +1,6 @@ +import { env } from "~/env"; import { RegisterForm } from "./register-form"; export default function RegisterPage() { - return ; + return ; } diff --git a/src/app/auth/register/register-form.tsx b/src/app/auth/register/register-form.tsx index 6840cca..3a124d0 100644 --- a/src/app/auth/register/register-form.tsx +++ b/src/app/auth/register/register-form.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { ArrowRight, Lock, Mail, User } from "lucide-react"; +import { ArrowRight, Lock, Mail, User, UserX } from "lucide-react"; import { AuthCard, AuthCardHeader, @@ -22,7 +22,11 @@ function formatAuthError(message: string | undefined, fallback: string): string return message; } -export function RegisterForm() { +interface RegisterFormProps { + signupsDisabled?: boolean; +} + +export function RegisterForm({ signupsDisabled = false }: RegisterFormProps) { const router = useRouter(); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); @@ -92,6 +96,35 @@ export function RegisterForm() { } } + if (signupsDisabled) { + return ( + + + + +
+ +

+ This workspace is not accepting new registrations. If you already + have an account, sign in below. Contact your administrator if you + need access. +

+
+ + +
+
+ ); + } + return ( diff --git a/src/app/auth/signin/signin-form.tsx b/src/app/auth/signin/signin-form.tsx index 0ee66f3..4f09924 100644 --- a/src/app/auth/signin/signin-form.tsx +++ b/src/app/auth/signin/signin-form.tsx @@ -26,7 +26,6 @@ export function SignInForm({ allowRegistration }: SignInFormProps) { const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; - const signupDisabled = searchParams.get("signup") === "disabled"; const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); @@ -74,7 +73,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) { description="Sign in to your workspace" /> - {signupDisabled && ( + {!allowRegistration && (

New account registration is currently disabled.

@@ -155,7 +154,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) { - {allowRegistration && !signupDisabled && ( + {allowRegistration && (

Don't have an account?{" "} item.id === step); } +const TRACK_GRID_COLUMNS = ONBOARDING_STEPS.map((_, index) => + index < ONBOARDING_STEPS.length - 1 ? "auto 1fr" : "auto", +).join(" "); + export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) { const currentIndex = stepIndex(step); return (