feat(admin-auth): add first-start onboarding flow and dev db reset command
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user