Files
alla-tms/src/auth.ts
2026-07-16 09:53:14 +07:00

327 lines
10 KiB
TypeScript

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