327 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
});
|