feat(auth): bootstrap protected support and first owner users
This commit is contained in:
@@ -12,6 +12,12 @@ 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_PASSWORD = "change-me-support-password"
|
||||
const DEFAULT_SUPPORT_NAME = "Technical Support"
|
||||
|
||||
function resolveAuthSecret(): string {
|
||||
const value = process.env.BETTER_AUTH_SECRET
|
||||
@@ -41,6 +47,26 @@ export function isAdminRegistrationEnabled(): boolean {
|
||||
return !isProduction
|
||||
}
|
||||
|
||||
function resolveBootstrapValue(
|
||||
envKey: string,
|
||||
fallback: string,
|
||||
options: {
|
||||
requiredInProduction?: boolean
|
||||
} = {},
|
||||
): string {
|
||||
const value = process.env[envKey]
|
||||
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (isProduction && options.requiredInProduction) {
|
||||
throw new Error(`${envKey} is required in production`)
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "CMS Admin",
|
||||
baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin,
|
||||
@@ -67,6 +93,24 @@ export const auth = betterAuth({
|
||||
defaultValue: false,
|
||||
input: false,
|
||||
},
|
||||
isSystem: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
defaultValue: false,
|
||||
input: false,
|
||||
},
|
||||
isHidden: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
defaultValue: false,
|
||||
input: false,
|
||||
},
|
||||
isProtected: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
defaultValue: false,
|
||||
input: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -75,6 +119,140 @@ export const authRouteHandlers = toNextJsHandler(auth)
|
||||
|
||||
export type AuthSession = typeof auth.$Infer.Session
|
||||
|
||||
let bootstrapPromise: Promise<void> | null = null
|
||||
|
||||
type BootstrapUserConfig = {
|
||||
email: string
|
||||
name: string
|
||||
password: string
|
||||
role: Role
|
||||
isHidden: boolean
|
||||
}
|
||||
|
||||
async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void> {
|
||||
const ctx = await auth.$context
|
||||
const normalizedEmail = config.email.toLowerCase()
|
||||
const existing = await ctx.internalAdapter.findUserByEmail(normalizedEmail, {
|
||||
includeAccounts: true,
|
||||
})
|
||||
|
||||
if (existing?.user) {
|
||||
await db.user.update({
|
||||
where: { id: existing.user.id },
|
||||
data: {
|
||||
name: config.name,
|
||||
role: config.role,
|
||||
isBanned: false,
|
||||
isSystem: true,
|
||||
isHidden: config.isHidden,
|
||||
isProtected: true,
|
||||
},
|
||||
})
|
||||
|
||||
const hasCredentialAccount = existing.accounts.some(
|
||||
(account) => account.providerId === "credential",
|
||||
)
|
||||
|
||||
if (!hasCredentialAccount) {
|
||||
const passwordHash = await ctx.password.hash(config.password)
|
||||
|
||||
await ctx.internalAdapter.linkAccount({
|
||||
userId: existing.user.id,
|
||||
providerId: "credential",
|
||||
accountId: existing.user.id,
|
||||
password: passwordHash,
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await ctx.password.hash(config.password)
|
||||
const createdUser = await ctx.internalAdapter.createUser({
|
||||
name: config.name,
|
||||
email: normalizedEmail,
|
||||
emailVerified: true,
|
||||
role: config.role,
|
||||
isBanned: false,
|
||||
isSystem: true,
|
||||
isHidden: config.isHidden,
|
||||
isProtected: true,
|
||||
})
|
||||
|
||||
await ctx.internalAdapter.linkAccount({
|
||||
userId: createdUser.id,
|
||||
providerId: "credential",
|
||||
accountId: createdUser.id,
|
||||
password: passwordHash,
|
||||
})
|
||||
}
|
||||
|
||||
async function bootstrapSystemUsers(): Promise<void> {
|
||||
const supportEmail = resolveBootstrapValue("CMS_SUPPORT_EMAIL", DEFAULT_SUPPORT_EMAIL)
|
||||
const supportPassword = resolveBootstrapValue("CMS_SUPPORT_PASSWORD", DEFAULT_SUPPORT_PASSWORD, {
|
||||
requiredInProduction: true,
|
||||
})
|
||||
const supportName = resolveBootstrapValue("CMS_SUPPORT_NAME", DEFAULT_SUPPORT_NAME)
|
||||
|
||||
await ensureCredentialUser({
|
||||
email: supportEmail,
|
||||
name: supportName,
|
||||
password: supportPassword,
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
bootstrapPromise = bootstrapSystemUsers()
|
||||
|
||||
try {
|
||||
await bootstrapPromise
|
||||
} catch (error) {
|
||||
bootstrapPromise = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null {
|
||||
const sessionUserRole = session?.user?.role
|
||||
|
||||
|
||||
Reference in New Issue
Block a user