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): # Quick start (Docker app + Postgres):
# cp .env.example .env # cp .env.example .env
# # edit AUTH_SECRET + public URLs below # # edit AUTH_SECRET + public URLs below
# docker compose up -d --build # ./scripts/docker-deploy.sh
# #
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Build-time vs runtime (Docker) # Build-time vs runtime (Docker)
@@ -31,7 +31,8 @@
# emails, and MCP links. In the browser, sign-in uses the current page origin # 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). # 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). # 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). # Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000).
WEB_PORT=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. # Postgres credentials for docker-compose.yml `db` service.
# DATABASE_URL inside the app container is set by compose (host `db`, not localhost). # DATABASE_URL inside the app container is set by compose (host `db`, not localhost).
POSTGRES_USER=postgres POSTGRES_USER=postgres
@@ -112,8 +117,9 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# Access control (optional) # Access control (optional)
# ============================================================================= # =============================================================================
# Block new email/password registrations. Use literal true or false. # Block new email/password registrations (default: true / signups off).
# DISABLE_SIGNUPS=true # Set DISABLE_SIGNUPS=false to allow new email/password signups.
# DISABLE_SIGNUPS=false
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron). # Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
# CRON_SECRET= # CRON_SECRET=
+14 -8
View File
@@ -132,10 +132,12 @@ docker compose build --no-cache app
### 2. First start (or after code changes) ### 2. First start (or after code changes)
```bash ```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. 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 ```bash
git pull 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? | | Command | New code? | Migrations run? |
|---------|-----------|-----------------| |---------|-----------|-----------------|
| `git pull` only | No | No | | `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` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
| `docker compose up -d --build` | Yes | Yes — on app container start | | `./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) | | `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`. To verify migration files match the journal before deploy: `bun run db:verify-journal`.
### 4. Sign-ups ### 4. Sign-ups
@@ -213,12 +218,13 @@ bun run lint:fix
bun run format:write bun run format:write
bun run typecheck bun run typecheck
# Docker helpers (Postgres only — uses Colima on macOS) # Docker helpers
bun run docker:up # colima start + docker-compose.dev.yml up -d bun run docker:up # dev Postgres only (Colima + docker-compose.dev.yml)
bun run docker:down # stop dev Postgres + colima 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 ## 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: services:
app: app:
build: build:
@@ -5,7 +11,9 @@ services:
args: args:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
BETTER_AUTH_URL: ${BETTER_AUTH_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: environment:
NODE_ENV: production NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env} 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_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_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false} DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} 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. 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 ## Scripts
```bash ```bash
+1
View File
@@ -15,6 +15,7 @@
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d", "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: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:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:deploy": "./scripts/docker-deploy.sh",
"deploy": "drizzle-kit push && next build", "deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "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"; import { RegisterForm } from "./register-form";
export default function RegisterPage() { 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 { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, Lock, Mail, User } from "lucide-react"; import { ArrowRight, Lock, Mail, User, UserX } from "lucide-react";
import { import {
AuthCard, AuthCard,
AuthCardHeader, AuthCardHeader,
@@ -22,7 +22,11 @@ function formatAuthError(message: string | undefined, fallback: string): string
return message; return message;
} }
export function RegisterForm() { interface RegisterFormProps {
signupsDisabled?: boolean;
}
export function RegisterForm({ signupsDisabled = false }: RegisterFormProps) {
const router = useRouter(); const router = useRouter();
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = 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 ( return (
<AuthPageShell> <AuthPageShell>
<AuthCard> <AuthCard>
+2 -3
View File
@@ -26,7 +26,6 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const signupDisabled = searchParams.get("signup") === "disabled";
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -74,7 +73,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
description="Sign in to your workspace" 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"> <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. New account registration is currently disabled.
</p> </p>
@@ -155,7 +154,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
</Button> </Button>
</form> </form>
{allowRegistration && !signupDisabled && ( {allowRegistration && (
<p className="text-muted-foreground mt-6 text-center text-sm"> <p className="text-muted-foreground mt-6 text-center text-sm">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link <Link
@@ -14,72 +14,86 @@ function stepIndex(step: OnboardingStepId) {
return ONBOARDING_STEPS.findIndex((item) => item.id === step); 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 }) { export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
const currentIndex = stepIndex(step); const currentIndex = stepIndex(step);
return ( return (
<nav aria-label="Setup progress" className="mb-8"> <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) => { {ONBOARDING_STEPS.map((item, index) => {
const isComplete = currentIndex > index; const isComplete = currentIndex > index;
const isCurrent = currentIndex === index; const isCurrent = currentIndex === index;
const isUpcoming = currentIndex < index; const isUpcoming = currentIndex < index;
const connectorComplete = currentIndex > index; const connectorComplete = currentIndex > index;
const circleCol = index * 2 + 1;
return ( return (
<li key={item.id} className="flex flex-1 flex-col items-center"> <div key={item.id} className="contents">
<div className="flex w-full items-center"> {index > 0 && (
{index > 0 && (
<div
className={cn(
"h-0.5 flex-1 rounded-full transition-colors",
connectorComplete || isCurrent
? "bg-primary"
: "bg-border/80",
)}
aria-hidden
/>
)}
<div <div
className={cn( className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors", "h-0.5 self-center rounded-full transition-colors",
isComplete && connectorComplete ? "bg-primary" : "bg-border/80",
"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",
)} )}
aria-current={isCurrent ? "step" : undefined} style={{ gridColumn: index * 2, gridRow: 1 }}
> />
{isComplete ? ( )}
<Check className="h-4 w-4" aria-hidden /> <div
) : ( className={cn(
<span>{index + 1}</span> "flex h-9 w-9 items-center justify-center justify-self-center rounded-full border-2 text-sm font-medium transition-colors",
)} isComplete &&
</div> "border-primary bg-primary text-primary-foreground",
{index < ONBOARDING_STEPS.length - 1 && ( isCurrent &&
<div "border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
className={cn( isUpcoming &&
"h-0.5 flex-1 rounded-full transition-colors", "border-border/80 bg-background/60 text-muted-foreground",
connectorComplete ? "bg-primary" : "bg-border/80", )}
)} style={{ gridColumn: circleCol, gridRow: 1 }}
aria-hidden >
/> {isComplete ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<span>{index + 1}</span>
)} )}
</div> </div>
<span <span
className={cn( 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", isCurrent ? "text-foreground" : "text-muted-foreground",
)} )}
style={{ gridColumn: circleCol, gridRow: 2 }}
> >
{item.label} {item.label}
</span> </span>
</li> </div>
); );
})} })}
</ol> </div>
<p className="text-muted-foreground mt-4 text-center text-sm sm:hidden"> <p className="text-muted-foreground mt-4 text-center text-sm sm:hidden">
Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "} Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "}
{ONBOARDING_STEPS.length} {ONBOARDING_STEPS.length}
+1 -1
View File
@@ -33,7 +33,7 @@ export const env = createEnv({
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
DB_DISABLE_SSL: optionalEnvBoolean(), DB_DISABLE_SSL: optionalEnvBoolean(),
DISABLE_SIGNUPS: optionalEnvBoolean(), DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
CRON_SECRET: z.string().optional(), CRON_SECRET: z.string().optional(),
// SSO / Authentik (optional) // SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().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 { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js"; import { nextCookies } from "better-auth/next-js";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { envBoolean } from "~/lib/env-boolean"; import { env } from "~/env";
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin"; import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
import { db } from "~/server/db"; import { db } from "~/server/db";
import * as schema from "~/server/db/schema"; import * as schema from "~/server/db/schema";
@@ -13,7 +13,7 @@ const authentikEnabled = Boolean(
process.env.AUTHENTIK_CLIENT_ID && process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET, 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 // Derive the authentik origin from the issuer URL so the OAuth callback is
// automatically trusted without needing a separate AUTHENTIK_ORIGIN env var. // automatically trusted without needing a separate AUTHENTIK_ORIGIN env var.
-7
View File
@@ -1,17 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { envBoolean } from "~/lib/env-boolean";
import { isPublicRoute } from "~/lib/public-routes"; import { isPublicRoute } from "~/lib/public-routes";
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; 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 // Define API routes that should be handled separately
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"]; const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];