Spaces:
Runtime error
Runtime error
| 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<string>('SMTP_HOST'), | |
| port: configService.get<number>('SMTP_PORT'), | |
| secure: false, | |
| auth: { | |
| user: configService.get<string>('SMTP_USER'), | |
| pass: configService.get<string>('SMTP_PASS'), | |
| }, | |
| }); | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call | |
| const adapter = new PrismaPg({ | |
| connectionString: configService.get<string>('DATABASE_URL'), | |
| }); | |
| const prisma = new PrismaClient({ adapter }); | |
| const logger: Logger = new Logger(betterAuth.name); | |
| export const auth = betterAuth({ | |
| basePath: '/api/auth', | |
| secret: configService.get<string>('BETTER_AUTH_SECRET'), | |
| baseURL: configService.get<string>('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<string>('JWT_EXPIRES_IN'), | |
| definePayload: ({ user }) => ({ | |
| sub: user.id, | |
| email: user.email, | |
| role: user.role as string, | |
| }), | |
| }, | |
| }), | |
| emailOTP({ | |
| sendVerificationOnSignUp: true, | |
| storeOTP: 'hashed', | |
| expiresIn: configService.get<number>('OTP_EXPIRES_IN'), | |
| async sendVerificationOTP(data: { | |
| email: string; | |
| otp: string; | |
| type: 'sign-in' | 'email-verification' | 'forget-password'; | |
| }): Promise<void> { | |
| const subject = `Kode Verifikasi TeraSkil: ${data.otp}`; | |
| const expiresInMinutes = Math.floor( | |
| (configService.get<number>('OTP_EXPIRES_IN') ?? 300) / 60, | |
| ); | |
| const html = ` | |
| <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 10px;"> | |
| <h2 style="color: #2563EB; text-align: center;">TeraSkill</h2> | |
| <p style="color: #333;">Halo,</p> | |
| <p style="color: #333;">Gunakan kode di bawah ini untuk memverifikasi akun Anda. Kode ini berlaku selama ${expiresInMinutes} menit.</p> | |
| <div style="background-color: #f3f4f6; padding: 15px; text-align: center; border-radius: 5px; margin: 20px 0;"> | |
| <span style="font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #1f2937;">${data.otp}</span> | |
| </div> | |
| <p style="color: #666; font-size: 12px; text-align: center;"> | |
| Jika Anda tidak meminta kode ini, abaikan saja email ini. | |
| </p> | |
| <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;"> | |
| <p style="color: #999; font-size: 10px; text-align: center;">© ${new Date().getFullYear()} PT Media Kreasi Abadi. All rights reserved.</p> | |
| </div> | |
| `; | |
| try { | |
| transporter.sendMail({ | |
| from: configService.get<string>('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<string>('GOOGLE_CLIENT_ID_WEB')!, | |
| clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET_WEB')!, | |
| prompt: 'select_account', | |
| }, | |
| }, | |
| }); | |