Files
old.cms.fellies.org/apps/admin/src/lib/auth/server.ts

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)
}