Default signups off, improve Docker deploy, fix onboarding step UI.

Show a disabled-registration state on the register page when DISABLE_SIGNUPS is true (default), document docker-deploy.sh with git-SHA image tags, and align onboarding progress circles and labels on a shared grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 13:14:25 -04:00
co-authored by Cursor
parent 6b73c32c25
commit 85df7c4627
13 changed files with 163 additions and 70 deletions
+10 -4
View File
@@ -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:<git-sha> 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=
+14 -8
View File
@@ -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:<sha>`) 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
+10 -2
View File
@@ -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:<git-sha> 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:-}
+2
View File
@@ -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:<git-sha>`.
## Scripts
```bash
+1
View File
@@ -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",
+30
View File
@@ -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 "$@"
+2 -1
View File
@@ -1,5 +1,6 @@
import { env } from "~/env";
import { RegisterForm } from "./register-form";
export default function RegisterPage() {
return <RegisterForm />;
return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />;
}
+35 -2
View File
@@ -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 (
<AuthPageShell>
<AuthCard>
<AuthCardHeader
title="Registration closed"
description="New account sign-ups are not available right now"
/>
<div className="bg-muted/50 text-muted-foreground mb-6 flex gap-3 rounded-xl border px-4 py-3 text-sm">
<UserX className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<p>
This workspace is not accepting new registrations. If you already
have an account, sign in below. Contact your administrator if you
need access.
</p>
</div>
<Button asChild className="h-11 w-full">
<Link href="/auth/signin">
Sign in to your account
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</AuthCard>
</AuthPageShell>
);
}
return (
<AuthPageShell>
<AuthCard>
+2 -3
View File
@@ -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 && (
<p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm">
New account registration is currently disabled.
</p>
@@ -155,7 +154,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
</Button>
</form>
{allowRegistration && !signupDisabled && (
{allowRegistration && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don&apos;t have an account?{" "}
<Link
@@ -14,72 +14,86 @@ function stepIndex(step: OnboardingStepId) {
return ONBOARDING_STEPS.findIndex((item) => 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 (
<nav aria-label="Setup progress" className="mb-8">
<ol className="mx-auto flex w-full max-w-md">
<ol className="sr-only">
{ONBOARDING_STEPS.map((item, index) => {
const isCurrent = currentIndex === index;
return (
<li key={item.id} aria-current={isCurrent ? "step" : undefined}>
{item.label}
{isCurrent ? " (current)" : ""}
</li>
);
})}
</ol>
{/* Row 1: circles + connectors. Row 2: labels (same columns as circles). */}
<div
className="mx-auto grid w-full max-w-md items-center gap-y-2"
style={{
gridTemplateColumns: TRACK_GRID_COLUMNS,
gridTemplateRows: "auto auto",
}}
aria-hidden
>
{ONBOARDING_STEPS.map((item, index) => {
const isComplete = currentIndex > index;
const isCurrent = currentIndex === index;
const isUpcoming = currentIndex < index;
const connectorComplete = currentIndex > index;
const circleCol = index * 2 + 1;
return (
<li key={item.id} className="flex flex-1 flex-col items-center">
<div className="flex w-full items-center">
{index > 0 && (
<div
className={cn(
"h-0.5 flex-1 rounded-full transition-colors",
connectorComplete || isCurrent
? "bg-primary"
: "bg-border/80",
)}
aria-hidden
/>
)}
<div key={item.id} className="contents">
{index > 0 && (
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
isComplete &&
"border-primary bg-primary text-primary-foreground",
isCurrent &&
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
isUpcoming &&
"border-border/80 bg-background/60 text-muted-foreground",
"h-0.5 self-center rounded-full transition-colors",
connectorComplete ? "bg-primary" : "bg-border/80",
)}
aria-current={isCurrent ? "step" : undefined}
>
{isComplete ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<span>{index + 1}</span>
)}
</div>
{index < ONBOARDING_STEPS.length - 1 && (
<div
className={cn(
"h-0.5 flex-1 rounded-full transition-colors",
connectorComplete ? "bg-primary" : "bg-border/80",
)}
aria-hidden
/>
style={{ gridColumn: index * 2, gridRow: 1 }}
/>
)}
<div
className={cn(
"flex h-9 w-9 items-center justify-center justify-self-center rounded-full border-2 text-sm font-medium transition-colors",
isComplete &&
"border-primary bg-primary text-primary-foreground",
isCurrent &&
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
isUpcoming &&
"border-border/80 bg-background/60 text-muted-foreground",
)}
style={{ gridColumn: circleCol, gridRow: 1 }}
>
{isComplete ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<span>{index + 1}</span>
)}
</div>
<span
className={cn(
"mt-2 hidden text-xs font-medium sm:block",
"hidden min-w-0 justify-self-center text-center text-xs leading-tight font-medium sm:block",
isCurrent ? "text-foreground" : "text-muted-foreground",
)}
style={{ gridColumn: circleCol, gridRow: 2 }}
>
{item.label}
</span>
</li>
</div>
);
})}
</ol>
</div>
<p className="text-muted-foreground mt-4 text-center text-sm sm:hidden">
Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "}
{ONBOARDING_STEPS.length}
+1 -1
View File
@@ -33,7 +33,7 @@ export const env = createEnv({
.enum(["development", "test", "production"])
.default("development"),
DB_DISABLE_SSL: optionalEnvBoolean(),
DISABLE_SIGNUPS: optionalEnvBoolean(),
DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
CRON_SECRET: z.string().optional(),
// SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(),
+2 -2
View File
@@ -3,7 +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 { env } from "~/env";
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
@@ -13,7 +13,7 @@ const authentikEnabled = Boolean(
process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET,
);
const signupsDisabled = envBoolean(process.env.DISABLE_SIGNUPS);
const signupsDisabled = 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.
-7
View File
@@ -1,17 +1,10 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { envBoolean } from "~/lib/env-boolean";
import { isPublicRoute } from "~/lib/public-routes";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
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);
}
// Define API routes that should be handled separately
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];