commit
This commit is contained in:
326
src/auth.ts
Normal file
326
src/auth.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user