281 lines
6.8 KiB
TypeScript
281 lines
6.8 KiB
TypeScript
import { normalizeRole, type Role } from "@cms/content/rbac"
|
|
import { db } from "@cms/db"
|
|
import { betterAuth } from "better-auth"
|
|
import { prismaAdapter } from "better-auth/adapters/prisma"
|
|
import { toNextJsHandler } from "better-auth/next-js"
|
|
|
|
const FALLBACK_DEV_SECRET = "dev-only-change-me-for-production"
|
|
|
|
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_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
|
|
|
|
if (value) {
|
|
return value
|
|
}
|
|
|
|
if (isProduction) {
|
|
throw new Error("BETTER_AUTH_SECRET is required in production")
|
|
}
|
|
|
|
return FALLBACK_DEV_SECRET
|
|
}
|
|
|
|
export async function hasOwnerUser(): Promise<boolean> {
|
|
const ownerCount = await db.user.count({
|
|
where: { role: "owner" },
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
return isSelfRegistrationEnabled()
|
|
}
|
|
|
|
export function resolveSupportLoginKey(): string {
|
|
const value = process.env.CMS_SUPPORT_LOGIN_KEY
|
|
|
|
if (value) {
|
|
return value
|
|
}
|
|
|
|
if (isProduction) {
|
|
throw new Error("CMS_SUPPORT_LOGIN_KEY is required in production")
|
|
}
|
|
|
|
return DEFAULT_SUPPORT_LOGIN_KEY
|
|
}
|
|
|
|
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,
|
|
secret: resolveAuthSecret(),
|
|
trustedOrigins: [adminOrigin, webOrigin],
|
|
database: prismaAdapter(db, {
|
|
provider: "postgresql",
|
|
}),
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
// 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: {
|
|
role: {
|
|
type: "string",
|
|
required: true,
|
|
defaultValue: "editor",
|
|
input: false,
|
|
},
|
|
isBanned: {
|
|
type: "boolean",
|
|
required: true,
|
|
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,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
export const authRouteHandlers = toNextJsHandler(auth)
|
|
|
|
export type AuthSession = typeof auth.$Infer.Session
|
|
|
|
let supportBootstrapPromise: 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 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,
|
|
})
|
|
const supportName = resolveBootstrapValue("CMS_SUPPORT_NAME", DEFAULT_SUPPORT_NAME)
|
|
|
|
await ensureCredentialUser({
|
|
email: supportEmail,
|
|
name: supportName,
|
|
password: supportPassword,
|
|
role: "support",
|
|
isHidden: true,
|
|
})
|
|
}
|
|
|
|
export async function ensureSupportUserBootstrap(): Promise<void> {
|
|
if (supportBootstrapPromise) {
|
|
await supportBootstrapPromise
|
|
return
|
|
}
|
|
|
|
supportBootstrapPromise = bootstrapSystemUsers()
|
|
|
|
try {
|
|
await supportBootstrapPromise
|
|
} catch (error) {
|
|
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
|
|
|
|
if (typeof sessionUserRole !== "string") {
|
|
return null
|
|
}
|
|
|
|
return normalizeRole(sessionUserRole)
|
|
}
|