feat(admin-auth): add first-start onboarding flow and dev db reset command

This commit is contained in:
2026-02-10 18:14:47 +01:00
parent 411861419f
commit 7b665ae633
18 changed files with 485 additions and 152 deletions

View File

@@ -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<boolean> {
const ownerCount = await db.user.count({
where: { role: "owner" },
})
if (value === "true") {
return ownerCount > 0
}
export async function isInitialOwnerRegistrationOpen(): Promise<boolean> {
return !(await hasOwnerUser())
}
export async function isSelfRegistrationEnabled(): Promise<boolean> {
// Temporary fallback until registration policy is managed from admin settings.
return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
}
export async function canUserSelfRegister(): Promise<boolean> {
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<void> | null = null
let supportBootstrapPromise: Promise<void> | null = null
type BootstrapUserConfig = {
email: string
@@ -188,7 +211,8 @@ async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void>
}
async function bootstrapSystemUsers(): Promise<void> {
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<void> {
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<void> {
if (bootstrapPromise) {
await bootstrapPromise
export async function ensureSupportUserBootstrap(): Promise<void> {
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<boolean> {
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