import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaPg } from '@prisma/adapter-pg'; import bcrypt from 'bcrypt'; import { APIError, betterAuth } from 'better-auth'; import { prismaAdapter } from 'better-auth/adapters/prisma'; import { createAuthMiddleware } from 'better-auth/api'; import { bearer, emailOTP, openAPI } from 'better-auth/plugins'; import { jwt } from 'better-auth/plugins/jwt'; import 'dotenv/config'; import * as nodemailer from 'nodemailer'; import { SignUpBody } from './dto/sign-up.dto'; import { PrismaClient } from '~/generated/client'; const configService = new ConfigService(); const transporter = nodemailer.createTransport({ host: configService.get('SMTP_HOST'), port: configService.get('SMTP_PORT'), secure: false, auth: { user: configService.get('SMTP_USER'), pass: configService.get('SMTP_PASS'), }, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const adapter = new PrismaPg({ connectionString: configService.get('DATABASE_URL'), }); const prisma = new PrismaClient({ adapter }); const logger: Logger = new Logger(betterAuth.name); export const auth = betterAuth({ basePath: '/api/auth', secret: configService.get('BETTER_AUTH_SECRET'), baseURL: configService.get('BETTER_AUTH_URL'), logger: { disabled: false, disableColors: false, level: 'info', log: (level, message, ...args) => { // Custom logging implementation logger.log(`[${level}] ${message}`, ...args); }, }, database: prismaAdapter(prisma, { provider: 'postgresql', usePlural: true, }), user: { additionalFields: { role: { type: [ 'super_admin', 'admin_finance', 'admin_operational', 'admin_sales', 'mentor', 'student', ], required: false, defaultValue: 'student', input: false, }, phoneNumber: { type: 'string', required: false, input: true, }, }, }, hooks: { before: createAuthMiddleware(async (ctx) => { if (!ctx.path.endsWith('/sign-up/email')) { return; } const body = (ctx.body || {}) as SignUpBody; const { name, email, phoneNumber, password, confirmPassword, image } = body; // Is all field filled if (!name || !email || !phoneNumber || !password || !confirmPassword) { throw new APIError('BAD_REQUEST', { message: 'name, email, phoneNumber, password, and confirmPassword must be filled', }); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { throw new APIError('BAD_REQUEST', { message: 'Invalid email format', }); } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access const existingUser = await prisma.users.findUnique({ where: { email, }, }); // Is email already registered, but not verified if (existingUser && !existingUser?.emailVerified) { throw new APIError('CONFLICT', { message: 'Email already registered, but not verified', }); } // Is email already verified if (existingUser && existingUser?.emailVerified) { throw new APIError('CONFLICT', { message: 'Email already verified, please sign in', }); } // Validate phone number const phoneRegex = /^08\d{8,14}$/; if (!phoneRegex.test(phoneNumber)) { throw new APIError('BAD_REQUEST', { message: 'Invalid phone number', }); } // Validate password if (password !== confirmPassword) { throw new APIError('BAD_REQUEST', { message: 'Password and confirm password mismatch', }); } if (password.length < 8) { throw new APIError('BAD_REQUEST', { message: 'Password must be at least 8 characters', }); } // If image not provided if (!image) { (ctx.body as SignUpBody).image = `https://ui-avatars.com/api/?background=1565D8&color=fff&rounded=true&size=128&length=2&bold=true&font-size=0.33&name=${name}`; } }), }, emailAndPassword: { enabled: true, requireEmailVerification: true, password: { async hash(password) { return await bcrypt.hash(password, 10); }, async verify(data) { return await bcrypt.compare(data.password, data.hash); }, }, }, trustedOrigins: ['*'], advanced: { disableOriginCheck: true, disableCSRFCheck: true, }, plugins: [ bearer(), openAPI(), jwt({ jwt: { expiresIn: configService.get('JWT_EXPIRES_IN'), definePayload: ({ user }) => ({ sub: user.id, email: user.email, role: user.role as string, }), }, }), emailOTP({ sendVerificationOnSignUp: true, storeOTP: 'hashed', expiresIn: configService.get('OTP_EXPIRES_IN'), async sendVerificationOTP(data: { email: string; otp: string; type: 'sign-in' | 'email-verification' | 'forget-password'; }): Promise { const subject = `Kode Verifikasi TeraSkil: ${data.otp}`; const expiresInMinutes = Math.floor( (configService.get('OTP_EXPIRES_IN') ?? 300) / 60, ); const html = `

TeraSkill

Halo,

Gunakan kode di bawah ini untuk memverifikasi akun Anda. Kode ini berlaku selama ${expiresInMinutes} menit.

${data.otp}

Jika Anda tidak meminta kode ini, abaikan saja email ini.


© ${new Date().getFullYear()} PT Media Kreasi Abadi. All rights reserved.

`; try { transporter.sendMail({ from: configService.get('SMTP_FROM'), to: data.email, subject, html, }); console.log(`OTP sent to ${data.email}`); } catch (error) { console.error(`Failed to send OTP:`, error); } }, overrideDefaultEmailVerification: true, }), ], socialProviders: { google: { clientId: configService.get('GOOGLE_CLIENT_ID_WEB')!, clientSecret: configService.get('GOOGLE_CLIENT_SECRET_WEB')!, prompt: 'select_account', }, }, });