diff --git a/.env.example b/.env.example index b353862..4674258 100644 --- a/.env.example +++ b/.env.example @@ -3,13 +3,12 @@ BETTER_AUTH_SECRET="replace-with-long-random-secret" BETTER_AUTH_URL="http://localhost:3001" CMS_ADMIN_ORIGIN="http://localhost:3001" CMS_WEB_ORIGIN="http://localhost:3000" -CMS_ADMIN_REGISTRATION_ENABLED="true" +CMS_ADMIN_SELF_REGISTRATION_ENABLED="false" # Bootstrap system users (used only when creating missing users) -CMS_OWNER_EMAIL="owner@cms.local" -CMS_OWNER_PASSWORD="change-me-owner-password" -CMS_OWNER_NAME="Owner" +CMS_SUPPORT_USERNAME="support" CMS_SUPPORT_EMAIL="support@cms.local" CMS_SUPPORT_PASSWORD="change-me-support-password" CMS_SUPPORT_NAME="Technical Support" +CMS_SUPPORT_LOGIN_KEY="support-access-change-me" # Optional dev bypass role for admin middleware. Leave empty to require auth login. # CMS_DEV_ROLE="admin" diff --git a/.env.production.example b/.env.production.example index f637751..0b1e5d7 100644 --- a/.env.production.example +++ b/.env.production.example @@ -3,4 +3,9 @@ BETTER_AUTH_SECRET="replace-with-production-secret" BETTER_AUTH_URL="https://admin.example.com" CMS_ADMIN_ORIGIN="https://admin.example.com" CMS_WEB_ORIGIN="https://www.example.com" -CMS_ADMIN_REGISTRATION_ENABLED="false" +CMS_ADMIN_SELF_REGISTRATION_ENABLED="false" +CMS_SUPPORT_USERNAME="support" +CMS_SUPPORT_EMAIL="support@admin.example.com" +CMS_SUPPORT_PASSWORD="replace-with-production-support-password" +CMS_SUPPORT_NAME="Technical Support" +CMS_SUPPORT_LOGIN_KEY="replace-with-production-support-login-key" diff --git a/.env.staging.example b/.env.staging.example index de13780..32456f4 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -3,4 +3,9 @@ BETTER_AUTH_SECRET="replace-with-staging-secret" BETTER_AUTH_URL="https://staging-admin.example.com" CMS_ADMIN_ORIGIN="https://staging-admin.example.com" CMS_WEB_ORIGIN="https://staging-web.example.com" -CMS_ADMIN_REGISTRATION_ENABLED="false" +CMS_ADMIN_SELF_REGISTRATION_ENABLED="false" +CMS_SUPPORT_USERNAME="support" +CMS_SUPPORT_EMAIL="support@staging-admin.example.com" +CMS_SUPPORT_PASSWORD="replace-with-staging-support-password" +CMS_SUPPORT_NAME="Technical Support" +CMS_SUPPORT_LOGIN_KEY="replace-with-staging-support-login-key" diff --git a/TODO.md b/TODO.md index 851ba46..5c72fe9 100644 --- a/TODO.md +++ b/TODO.md @@ -25,10 +25,13 @@ This file is the single source of truth for roadmap and delivery progress. - [ ] [P1] i18n runtime integration baseline for both apps (locale provider + message loading) - [ ] [P1] Locale persistence and switcher base component (cookie/header + UI) - [x] [P1] Integrate Better Auth core configuration and session wiring -- [x] [P1] Bootstrap first-run owner account creation when users table is empty +- [x] [P1] Bootstrap first-run owner account creation via initial registration flow - [ ] [P1] Enforce invariant: exactly one owner user must always exist - [x] [P1] Create hidden technical support user by default (non-demotable, non-deletable) - [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel) +- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`) +- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links +- [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access - [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository) - [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement) - [ ] [P1] Shared error and audit hooks for CRUD mutations @@ -187,6 +190,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-10] `bun test` conflicts with Playwright-style test files; keep e2e files on `*.pw.ts` and run e2e via Playwright. - [2026-02-10] Linux Playwright runtime depends on host packages; browser setup may require `playwright install --with-deps`. - [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`. +- [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers. ## How We Use This File diff --git a/apps/admin/package.json b/apps/admin/package.json index b2ea623..c61d95a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -7,6 +7,7 @@ "dev": "bun --env-file=../../.env next dev --port 3001", "build": "bun --env-file=../../.env next build", "start": "bun --env-file=../../.env next start --port 3001", + "auth:seed:support": "bun --env-file=../../.env ./scripts/seed-support-user.ts", "lint": "biome check src", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/apps/admin/scripts/seed-support-user.ts b/apps/admin/scripts/seed-support-user.ts new file mode 100644 index 0000000..b8afd98 --- /dev/null +++ b/apps/admin/scripts/seed-support-user.ts @@ -0,0 +1,11 @@ +import { ensureSupportUserBootstrap } from "../src/lib/auth/server" + +async function main() { + await ensureSupportUserBootstrap() + console.log("Support user bootstrap completed") +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/apps/admin/src/app/api/auth/[...all]/route.ts b/apps/admin/src/app/api/auth/[...all]/route.ts index 8a530ef..c42bdee 100644 --- a/apps/admin/src/app/api/auth/[...all]/route.ts +++ b/apps/admin/src/app/api/auth/[...all]/route.ts @@ -1,28 +1,106 @@ -import { authRouteHandlers, ensureAuthBootstrap } from "@/lib/auth/server" +import { + authRouteHandlers, + canUserSelfRegister, + ensureSupportUserBootstrap, + hasOwnerUser, + promoteFirstRegisteredUserToOwner, +} from "@/lib/auth/server" export const runtime = "nodejs" +type AuthPostResponse = { + user?: { + id?: string + role?: string + } + message?: string +} + +function jsonResponse(payload: unknown, status: number): Response { + return Response.json(payload, { status }) +} + +async function handleSignUpPost(request: Request): Promise { + await ensureSupportUserBootstrap() + + const hadOwnerBeforeSignUp = await hasOwnerUser() + const registrationEnabled = await canUserSelfRegister() + + if (!registrationEnabled) { + return jsonResponse( + { + message: "Registration is currently disabled.", + }, + 403, + ) + } + + const response = await authRouteHandlers.POST(request) + + if (!response.ok) { + return response + } + + const payload = (await response + .clone() + .json() + .catch(() => null)) as AuthPostResponse | null + const userId = payload?.user?.id + + if (!userId) { + return response + } + + if (hadOwnerBeforeSignUp || !payload?.user) { + return response + } + + const promoted = await promoteFirstRegisteredUserToOwner(userId) + + if (!promoted) { + return jsonResponse( + { + message: "Initial owner registration window has just closed. Please sign in instead.", + }, + 409, + ) + } + + payload.user.role = "owner" + + return new Response(JSON.stringify(payload), { + status: response.status, + headers: response.headers, + }) +} + export async function GET(request: Request): Promise { - await ensureAuthBootstrap() + await ensureSupportUserBootstrap() return authRouteHandlers.GET(request) } export async function POST(request: Request): Promise { - await ensureAuthBootstrap() + const pathname = new URL(request.url).pathname + + if (pathname.endsWith("/sign-up/email")) { + return handleSignUpPost(request) + } + + await ensureSupportUserBootstrap() return authRouteHandlers.POST(request) } export async function PATCH(request: Request): Promise { - await ensureAuthBootstrap() + await ensureSupportUserBootstrap() return authRouteHandlers.PATCH(request) } export async function PUT(request: Request): Promise { - await ensureAuthBootstrap() + await ensureSupportUserBootstrap() return authRouteHandlers.PUT(request) } export async function DELETE(request: Request): Promise { - await ensureAuthBootstrap() + await ensureSupportUserBootstrap() return authRouteHandlers.DELETE(request) } diff --git a/apps/admin/src/app/login/login-form.tsx b/apps/admin/src/app/login/login-form.tsx index fde44f3..7289cd6 100644 --- a/apps/admin/src/app/login/login-form.tsx +++ b/apps/admin/src/app/login/login-form.tsx @@ -1,10 +1,11 @@ "use client" +import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { type FormEvent, useMemo, useState } from "react" type LoginFormProps = { - allowRegistration: boolean + mode: "signin" | "signup-owner" | "signup-user" } type AuthResponse = { @@ -23,7 +24,7 @@ function persistRoleCookie(role: unknown) { document.cookie = `cms_role=${encodeURIComponent(role)}; Path=/; SameSite=Lax` } -export function LoginForm({ allowRegistration }: LoginFormProps) { +export function LoginForm({ mode }: LoginFormProps) { const router = useRouter() const searchParams = useSearchParams() @@ -72,7 +73,9 @@ export function LoginForm({ allowRegistration }: LoginFormProps) { } } - async function handleSignUp() { + async function handleSignUp(event: FormEvent) { + event.preventDefault() + if (!name.trim()) { setError("Name is required for account creation") return @@ -104,7 +107,11 @@ export function LoginForm({ allowRegistration }: LoginFormProps) { } persistRoleCookie(payload?.user?.role) - setSuccess("Account created. You can continue to the dashboard.") + setSuccess( + mode === "signup-owner" + ? "Owner account created. Registration is now disabled." + : "Account created.", + ) router.push(nextPath) router.refresh() } catch { @@ -118,87 +125,147 @@ export function LoginForm({ allowRegistration }: LoginFormProps) {

Admin Auth

-

Sign in to CMS Admin

+

+ {mode === "signin" + ? "Sign in to CMS Admin" + : mode === "signup-owner" + ? "Welcome to CMS Admin" + : "Create an admin account"} +

- Better Auth is active on this app via /api/auth. + {mode === "signin" ? ( + <> + Better Auth is active on this app via /api/auth. + + ) : mode === "signup-owner" ? ( + "Create the first owner account to initialize this admin instance." + ) : ( + "Self-registration is enabled for admin users." + )}

-
-
- - setEmail(event.target.value)} - className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" - /> -
- -
- - setPassword(event.target.value)} - className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" - /> -
- - +
+ + setEmail(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ +
+ + setPassword(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ + - {allowRegistration ? ( - <> -
- - setName(event.target.value)} - className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" - /> -
- - - ) : (

- Registration is disabled. Ask an owner or support user to create your account. + Need an account?{" "} + + Register +

- )} - {error ?

{error}

: null} - {success ?

{success}

: null} -
+ {error ?

{error}

: null} + + ) : ( +
+
+ + setName(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ +
+ + setEmail(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ +
+ + setPassword(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ + + +

+ Already have an account?{" "} + + Go to sign in + +

+ + {error ?

{error}

: null} + {success ?

{success}

: null} +
+ )}
) } diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx index 5d0bd87..09acf74 100644 --- a/apps/admin/src/app/login/page.tsx +++ b/apps/admin/src/app/login/page.tsx @@ -1,18 +1,36 @@ import { redirect } from "next/navigation" import { resolveRoleFromServerContext } from "@/lib/access-server" -import { isAdminRegistrationEnabled } from "@/lib/auth/server" +import { hasOwnerUser } from "@/lib/auth/server" import { LoginForm } from "./login-form" export const dynamic = "force-dynamic" -export default async function LoginPage() { +type SearchParams = Promise> + +function getSingleValue(input: string | string[] | undefined): string | undefined { + if (Array.isArray(input)) { + return input[0] + } + + return input +} + +export default async function LoginPage({ searchParams }: { searchParams: SearchParams }) { + const params = await searchParams + const nextPath = getSingleValue(params.next) ?? "/" const role = await resolveRoleFromServerContext() if (role) { redirect("/") } - return + const hasOwner = await hasOwnerUser() + + if (!hasOwner) { + redirect(`/welcome?next=${encodeURIComponent(nextPath)}`) + } + + return } diff --git a/apps/admin/src/app/register/page.tsx b/apps/admin/src/app/register/page.tsx new file mode 100644 index 0000000..648587b --- /dev/null +++ b/apps/admin/src/app/register/page.tsx @@ -0,0 +1,40 @@ +import { redirect } from "next/navigation" +import { LoginForm } from "@/app/login/login-form" +import { resolveRoleFromServerContext } from "@/lib/access-server" +import { hasOwnerUser, isSelfRegistrationEnabled } from "@/lib/auth/server" + +export const dynamic = "force-dynamic" + +type SearchParams = Promise> + +function getSingleValue(input: string | string[] | undefined): string | undefined { + if (Array.isArray(input)) { + return input[0] + } + + return input +} + +export default async function RegisterPage({ searchParams }: { searchParams: SearchParams }) { + const params = await searchParams + const nextPath = getSingleValue(params.next) ?? "/" + const role = await resolveRoleFromServerContext() + + if (role) { + redirect("/") + } + + const hasOwner = await hasOwnerUser() + + if (!hasOwner) { + redirect(`/welcome?next=${encodeURIComponent(nextPath)}`) + } + + const enabled = await isSelfRegistrationEnabled() + + if (!enabled) { + redirect(`/login?next=${encodeURIComponent(nextPath)}`) + } + + return +} diff --git a/apps/admin/src/app/support/[key]/page.tsx b/apps/admin/src/app/support/[key]/page.tsx new file mode 100644 index 0000000..461613c --- /dev/null +++ b/apps/admin/src/app/support/[key]/page.tsx @@ -0,0 +1,23 @@ +import { notFound, redirect } from "next/navigation" +import { LoginForm } from "@/app/login/login-form" +import { resolveRoleFromServerContext } from "@/lib/access-server" +import { resolveSupportLoginKey } from "@/lib/auth/server" + +export const dynamic = "force-dynamic" + +type Params = Promise<{ key: string }> + +export default async function SupportLoginPage({ params }: { params: Params }) { + const { key } = await params + const role = await resolveRoleFromServerContext() + + if (role) { + redirect("/") + } + + if (key !== resolveSupportLoginKey()) { + notFound() + } + + return +} diff --git a/apps/admin/src/app/welcome/page.tsx b/apps/admin/src/app/welcome/page.tsx new file mode 100644 index 0000000..2a120bd --- /dev/null +++ b/apps/admin/src/app/welcome/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from "next/navigation" +import { LoginForm } from "@/app/login/login-form" +import { resolveRoleFromServerContext } from "@/lib/access-server" +import { hasOwnerUser } from "@/lib/auth/server" + +export const dynamic = "force-dynamic" + +type SearchParams = Promise> + +function getSingleValue(input: string | string[] | undefined): string | undefined { + if (Array.isArray(input)) { + return input[0] + } + + return input +} + +export default async function WelcomePage({ searchParams }: { searchParams: SearchParams }) { + const params = await searchParams + const nextPath = getSingleValue(params.next) ?? "/" + const role = await resolveRoleFromServerContext() + + if (role) { + redirect("/") + } + + const hasOwner = await hasOwnerUser() + + if (hasOwner) { + redirect(`/login?next=${encodeURIComponent(nextPath)}`) + } + + return +} diff --git a/apps/admin/src/lib/access.ts b/apps/admin/src/lib/access.ts index 262254f..068ca14 100644 --- a/apps/admin/src/lib/access.ts +++ b/apps/admin/src/lib/access.ts @@ -24,6 +24,18 @@ const guardRules: GuardRule[] = [ route: /^\/login(?:\/|$)/, requirement: null, }, + { + route: /^\/register(?:\/|$)/, + requirement: null, + }, + { + route: /^\/welcome(?:\/|$)/, + requirement: null, + }, + { + route: /^\/support\/[^/]+(?:\/|$)/, + requirement: null, + }, { route: /^\/todo(?:\/|$)/, requirement: { diff --git a/apps/admin/src/lib/auth/server.ts b/apps/admin/src/lib/auth/server.ts index 62e49de..4dec17a 100644 --- a/apps/admin/src/lib/auth/server.ts +++ b/apps/admin/src/lib/auth/server.ts @@ -1,5 +1,3 @@ -import "server-only" - import { normalizeRole, type Role } from "@cms/content/rbac" import { db } from "@cms/db" import { betterAuth } from "better-auth" @@ -12,12 +10,10 @@ const isProduction = process.env.NODE_ENV === "production" const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001" const webOrigin = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000" -const DEFAULT_OWNER_EMAIL = "owner@cms.local" -const DEFAULT_OWNER_PASSWORD = "change-me-owner-password" -const DEFAULT_OWNER_NAME = "Owner" -const DEFAULT_SUPPORT_EMAIL = "support@cms.local" +const DEFAULT_SUPPORT_USERNAME = "support" const DEFAULT_SUPPORT_PASSWORD = "change-me-support-password" const DEFAULT_SUPPORT_NAME = "Technical Support" +const DEFAULT_SUPPORT_LOGIN_KEY = "support-access" function resolveAuthSecret(): string { const value = process.env.BETTER_AUTH_SECRET @@ -33,18 +29,43 @@ function resolveAuthSecret(): string { return FALLBACK_DEV_SECRET } -export function isAdminRegistrationEnabled(): boolean { - const value = process.env.CMS_ADMIN_REGISTRATION_ENABLED +export async function hasOwnerUser(): Promise { + const ownerCount = await db.user.count({ + where: { role: "owner" }, + }) - if (value === "true") { + return ownerCount > 0 +} + +export async function isInitialOwnerRegistrationOpen(): Promise { + return !(await hasOwnerUser()) +} + +export async function isSelfRegistrationEnabled(): Promise { + // Temporary fallback until registration policy is managed from admin settings. + return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true" +} + +export async function canUserSelfRegister(): Promise { + if (!(await hasOwnerUser())) { return true } - if (value === "false") { - return false + return isSelfRegistrationEnabled() +} + +export function resolveSupportLoginKey(): string { + const value = process.env.CMS_SUPPORT_LOGIN_KEY + + if (value) { + return value } - return !isProduction + if (isProduction) { + throw new Error("CMS_SUPPORT_LOGIN_KEY is required in production") + } + + return DEFAULT_SUPPORT_LOGIN_KEY } function resolveBootstrapValue( @@ -77,7 +98,9 @@ export const auth = betterAuth({ }), emailAndPassword: { enabled: true, - disableSignUp: !isAdminRegistrationEnabled(), + // Sign-up gating is handled in route layer so we can close registration + // automatically after the first owner account is created. + disableSignUp: false, }, user: { additionalFields: { @@ -119,7 +142,7 @@ export const authRouteHandlers = toNextJsHandler(auth) export type AuthSession = typeof auth.$Infer.Session -let bootstrapPromise: Promise | null = null +let supportBootstrapPromise: Promise | null = null type BootstrapUserConfig = { email: string @@ -188,7 +211,8 @@ async function ensureCredentialUser(config: BootstrapUserConfig): Promise } async function bootstrapSystemUsers(): Promise { - const supportEmail = resolveBootstrapValue("CMS_SUPPORT_EMAIL", DEFAULT_SUPPORT_EMAIL) + const supportUsername = resolveBootstrapValue("CMS_SUPPORT_USERNAME", DEFAULT_SUPPORT_USERNAME) + const supportEmail = resolveBootstrapValue("CMS_SUPPORT_EMAIL", `${supportUsername}@cms.local`) const supportPassword = resolveBootstrapValue("CMS_SUPPORT_PASSWORD", DEFAULT_SUPPORT_PASSWORD, { requiredInProduction: true, }) @@ -201,58 +225,50 @@ async function bootstrapSystemUsers(): Promise { role: "support", isHidden: true, }) - - const ownerCount = await db.user.count({ - where: { role: "owner" }, - }) - - if (ownerCount > 0) { - return - } - - const nonSupportUserCount = await db.user.count({ - where: { - role: { - not: "support", - }, - }, - }) - - if (nonSupportUserCount > 0) { - return - } - - const ownerEmail = resolveBootstrapValue("CMS_OWNER_EMAIL", DEFAULT_OWNER_EMAIL) - const ownerPassword = resolveBootstrapValue("CMS_OWNER_PASSWORD", DEFAULT_OWNER_PASSWORD, { - requiredInProduction: true, - }) - const ownerName = resolveBootstrapValue("CMS_OWNER_NAME", DEFAULT_OWNER_NAME) - - await ensureCredentialUser({ - email: ownerEmail, - name: ownerName, - password: ownerPassword, - role: "owner", - isHidden: false, - }) } -export async function ensureAuthBootstrap(): Promise { - if (bootstrapPromise) { - await bootstrapPromise +export async function ensureSupportUserBootstrap(): Promise { + if (supportBootstrapPromise) { + await supportBootstrapPromise return } - bootstrapPromise = bootstrapSystemUsers() + supportBootstrapPromise = bootstrapSystemUsers() try { - await bootstrapPromise + await supportBootstrapPromise } catch (error) { - bootstrapPromise = null + supportBootstrapPromise = null throw error } } +export async function promoteFirstRegisteredUserToOwner(userId: string): Promise { + return db.$transaction(async (tx) => { + const existingOwner = await tx.user.findFirst({ + where: { role: "owner" }, + select: { id: true }, + }) + + if (existingOwner) { + return false + } + + await tx.user.update({ + where: { id: userId }, + data: { + role: "owner", + isSystem: false, + isHidden: false, + isProtected: true, + isBanned: false, + }, + }) + + return true + }) +} + export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null { const sessionUserRole = session?.user?.role diff --git a/docs/getting-started.md b/docs/getting-started.md index 02caaab..1b50ede 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,6 +20,12 @@ bun run db:migrate bun run db:seed ``` +Reset local dev DB: + +```bash +bun run db:reset:dev +``` + ## Run apps ```bash @@ -28,7 +34,9 @@ bun run dev - Web: `http://localhost:3000` - Admin: `http://localhost:3001` +- Admin welcome (first start): `http://localhost:3001/welcome` - Admin login: `http://localhost:3001/login` +- Admin register (when enabled): `http://localhost:3001/register` ## Run docs diff --git a/docs/product-engineering/auth-baseline.md b/docs/product-engineering/auth-baseline.md index 458eca0..131e5f4 100644 --- a/docs/product-engineering/auth-baseline.md +++ b/docs/product-engineering/auth-baseline.md @@ -8,9 +8,10 @@ Implemented in MVP0: - Admin-local auth config: `apps/admin/src/lib/auth/server.ts` - Admin auth API routes: `apps/admin/src/app/api/auth/[...all]/route.ts` -- Admin login page: `/login` +- Admin auth pages: `/welcome`, `/login`, `/register` +- Support fallback sign-in page: `/support/` - Prisma auth models (`user`, `session`, `account`, `verification`) -- Registration toggle via `CMS_ADMIN_REGISTRATION_ENABLED` +- First registration creates owner; subsequent registrations are disabled ## Environment @@ -24,10 +25,18 @@ Required variables: Optional: -- `CMS_ADMIN_REGISTRATION_ENABLED` +- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` +- `CMS_SUPPORT_USERNAME` +- `CMS_SUPPORT_EMAIL` +- `CMS_SUPPORT_PASSWORD` +- `CMS_SUPPORT_NAME` +- `CMS_SUPPORT_LOGIN_KEY` - `CMS_DEV_ROLE` (development-only middleware bypass) ## Notes -- Owner bootstrap, hidden support user, and owner invariant are tracked as upcoming MVP0 tasks in `TODO.md`. +- Support user bootstrap is available via `bun run auth:seed:support`. +- Root `bun run db:seed` runs DB seed and support-user seed. +- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is temporary until admin settings UI manages this policy. +- Owner invariant hardening for all future user-management mutations remains tracked in `TODO.md`. - Email verification and forgot/reset password pipelines are tracked for MVP2. diff --git a/package.json b/package.json index d916599..a7c02f2 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,11 @@ "db:generate": "bun --filter @cms/db db:generate", "db:migrate": "bun --filter @cms/db db:migrate", "db:migrate:named": "bun --filter @cms/db db:migrate:named", + "db:reset:dev": "bun --filter @cms/db db:reset:dev && bun run db:generate && bun run db:seed", "db:push": "bun --filter @cms/db db:push", "db:studio": "bun --filter @cms/db db:studio", - "db:seed": "bun --filter @cms/db db:seed", + "db:seed": "bun --filter @cms/db db:seed && bun --filter @cms/admin auth:seed:support", + "auth:seed:support": "bun --filter @cms/admin auth:seed:support", "docker:staging:up": "docker compose -f docker-compose.staging.yml up -d --build", "docker:staging:down": "docker compose -f docker-compose.staging.yml down", "docker:production:up": "docker compose -f docker-compose.production.yml up -d --build", diff --git a/packages/db/package.json b/packages/db/package.json index 35f5ed2..5bd4c64 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -13,6 +13,7 @@ "db:generate": "bun --env-file=../../.env prisma generate", "db:migrate": "bun --env-file=../../.env prisma migrate dev --name init", "db:migrate:named": "bun --env-file=../../.env prisma migrate dev", + "db:reset:dev": "bun --env-file=../../.env prisma migrate reset --force --skip-generate --skip-seed", "db:push": "bun --env-file=../../.env prisma db push", "db:studio": "bun --env-file=../../.env prisma studio", "db:seed": "bun --env-file=../../.env prisma/seed.ts"