import { compare } from 'bcryptjs'; import { and, eq, isNull, or } from 'drizzle-orm'; import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; import { z } from 'zod'; import { memberships, organizations, users } from '@/db/schema'; import { AUDIT_ACTION, AUDIT_MODULE, getRequestMetadata, logAuditEvent, toAuditValue } from '@/features/audit-logs/server/audit-service'; import { syncUserEmployeeLink } from '@/features/employees/server/employee-identity-linking'; import { getBusinessRole, getDefaultPermissionsForRole, isHRD, isSuperAdmin, normalizeOrganizationRole } from '@/lib/auth/roles'; import { resolveEffectivePermissions } from '@/lib/auth/authorization.server'; import { db } from '@/lib/db'; const signInSchema = z.object({ identifier: z.string().trim().min(3), password: z.string().min(8) }); const authSecret = process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET ?? (process.env.NODE_ENV !== 'production' ? 'dev-only-auth-secret-change-me' : undefined); export const { handlers, auth, signIn, signOut } = NextAuth({ trustHost: true, secret: authSecret, pages: { signIn: '/auth/sign-in' }, session: { strategy: 'jwt' }, providers: [ Credentials({ credentials: { identifier: { label: 'Username or Email', type: 'text' }, password: { label: 'Password', type: 'password' } }, authorize: async (credentials, request) => { const { ipAddress, userAgent } = getRequestMetadata(request); const parsed = signInSchema.safeParse(credentials); if (!parsed.success) { await logAuditEvent({ module: AUDIT_MODULE.AUTHENTICATION, action: AUDIT_ACTION.LOGIN_FAILED, newValue: toAuditValue({ identifier: typeof credentials?.identifier === 'string' ? credentials.identifier : null, reason: 'VALIDATION_FAILED' }), ipAddress, userAgent }); return null; } const identifier = parsed.data.identifier.trim().toLowerCase(); const [user] = await db .select() .from(users) .where(or(eq(users.email, identifier), eq(users.username, identifier))) .limit(1); if (!user || !user.isActive || !user.passwordHash) { await logAuditEvent({ organizationId: user?.activeOrganizationId ?? null, userId: user?.id ?? null, module: AUDIT_MODULE.AUTHENTICATION, action: AUDIT_ACTION.LOGIN_FAILED, newValue: toAuditValue({ identifier, reason: !user ? 'USER_NOT_FOUND' : !user.isActive ? 'USER_INACTIVE' : 'PASSWORD_NOT_SET' }), ipAddress, userAgent }); return null; } const isValid = await compare(parsed.data.password, user.passwordHash); if (!isValid) { await logAuditEvent({ organizationId: user.activeOrganizationId, userId: user.id, module: AUDIT_MODULE.AUTHENTICATION, action: AUDIT_ACTION.LOGIN_FAILED, newValue: toAuditValue({ email: user.email, identifier, reason: 'INVALID_PASSWORD' }), ipAddress, userAgent }); return null; } await db .update(users) .set({ provider: 'credentials', lastLoginAt: new Date(), updatedAt: new Date() }) .where(eq(users.id, user.id)); const linkedEmployee = user.activeOrganizationId && user.employeeCode ? await syncUserEmployeeLink({ organizationId: user.activeOrganizationId, userId: user.id, employeeCode: user.employeeCode }) : null; await logAuditEvent({ organizationId: user.activeOrganizationId, userId: user.id, module: AUDIT_MODULE.AUTHENTICATION, action: AUDIT_ACTION.LOGIN_SUCCESS, newValue: toAuditValue({ email: user.email, identifier }), ipAddress, userAgent }); return { id: user.id, name: user.name, email: user.email, username: user.username, image: user.image, systemRole: user.systemRole, activeOrganizationId: user.activeOrganizationId, employeeCode: user.employeeCode, employeeId: linkedEmployee?.id ?? user.employeeId ?? null, provider: 'credentials' }; } }) ], callbacks: { jwt: async ({ token, user }) => { if (user) { token.sub = user.id; } return token; }, session: async ({ session, token }) => { if (!token.sub || !session.user) { return session; } const [dbUser] = await db.select().from(users).where(eq(users.id, token.sub)).limit(1); if (!dbUser) { return session; } const adminMembership = await db.query.memberships.findFirst({ where: eq(memberships.userId, dbUser.id), columns: { role: true } }); const canAccessAllOrganizations = isSuperAdmin(dbUser.systemRole) || isHRD({ systemRole: dbUser.systemRole, membershipRole: adminMembership?.role ?? null }); const rows = canAccessAllOrganizations ? await db .select({ organizationId: organizations.id, membershipRole: memberships.role, permissions: memberships.permissions, name: organizations.name, slug: organizations.slug, plan: organizations.plan, imageUrl: organizations.imageUrl }) .from(organizations) .leftJoin( memberships, and( eq(memberships.organizationId, organizations.id), eq(memberships.userId, dbUser.id) ) ) .where(and(eq(organizations.isActive, true), isNull(organizations.deletedAt))) : await db .select({ organizationId: memberships.organizationId, membershipRole: memberships.role, permissions: memberships.permissions, name: organizations.name, slug: organizations.slug, plan: organizations.plan, imageUrl: organizations.imageUrl }) .from(memberships) .innerJoin(organizations, eq(organizations.id, memberships.organizationId)) .where( and( eq(memberships.userId, dbUser.id), eq(organizations.isActive, true), isNull(organizations.deletedAt) ) ); const scopedRows = rows .filter((row) => canAccessAllOrganizations || row.membershipRole) .map((row) => { const membershipRole = row.membershipRole ?? 'admin'; return { organizationId: row.organizationId, membershipRole, role: normalizeOrganizationRole(membershipRole) ?? 'user', fallbackPermissions: row.permissions && row.permissions.length > 0 ? row.permissions : getDefaultPermissionsForRole(membershipRole), name: row.name, slug: row.slug, plan: row.plan, imageUrl: row.imageUrl }; }); const scopedRowsWithPermissions = await Promise.all( scopedRows.map(async (row) => { const effectivePermissions = await resolveEffectivePermissions({ userId: dbUser.id, organizationId: row.organizationId, fallbackPermissions: row.fallbackPermissions, fallbackRole: row.membershipRole }); return { ...row, permissions: effectivePermissions.permissions, effectivePermissions: effectivePermissions.permissions, permissionTemplateIds: effectivePermissions.permissionTemplateIds }; }) ); const activeMembership = scopedRowsWithPermissions.find( (row) => row.organizationId === dbUser.activeOrganizationId ) ?? scopedRowsWithPermissions[0] ?? null; session.user.id = dbUser.id; session.user.name = dbUser.name; session.user.email = dbUser.email; session.user.image = dbUser.image; session.user.systemRole = dbUser.systemRole; session.user.employeeCode = dbUser.employeeCode; session.user.employeeId = dbUser.employeeId; session.user.provider = dbUser.provider; session.user.activeOrganizationId = activeMembership?.organizationId ?? null; session.user.activeOrganizationName = activeMembership?.name ?? null; session.user.activeOrganizationPlan = activeMembership?.plan ?? null; session.user.membershipRole = activeMembership?.membershipRole ?? null; session.user.role = activeMembership?.role ?? null; session.user.businessRole = getBusinessRole({ systemRole: dbUser.systemRole, membershipRole: activeMembership?.membershipRole ?? null, role: activeMembership?.role ?? null }); session.user.permissions = activeMembership?.permissions ?? []; session.user.effectivePermissions = activeMembership?.effectivePermissions ?? []; session.user.permissionTemplateIds = activeMembership?.permissionTemplateIds ?? []; session.user.organizations = scopedRowsWithPermissions.map((row) => ({ id: row.organizationId, name: row.name, slug: row.slug, membershipRole: row.membershipRole, role: row.role, plan: row.plan, imageUrl: row.imageUrl, permissionTemplateIds: row.permissionTemplateIds })); // Keep the persisted active organization aligned with the filtered session scope. if ( (!dbUser.activeOrganizationId && activeMembership) || (dbUser.activeOrganizationId !== activeMembership?.organizationId && activeMembership) || (dbUser.activeOrganizationId && !activeMembership) ) { await db .update(users) .set({ activeOrganizationId: activeMembership?.organizationId ?? null }) .where(eq(users.id, dbUser.id)); } return session; } } });