275 lines
8.1 KiB
TypeScript
275 lines
8.1 KiB
TypeScript
import { NextAuthOptions } from 'next-auth'
|
|
import AzureADProvider from 'next-auth/providers/azure-ad'
|
|
import CredentialsProvider from 'next-auth/providers/credentials'
|
|
import { prisma } from '@/lib/db'
|
|
|
|
export const authOptions: NextAuthOptions = {
|
|
session: {
|
|
strategy: 'jwt',
|
|
},
|
|
pages: {
|
|
signIn: '/auth/signin',
|
|
error: '/auth/error',
|
|
},
|
|
providers: [
|
|
// Local credentials provider for development
|
|
CredentialsProvider({
|
|
id: 'credentials',
|
|
name: 'Local Account',
|
|
credentials: {
|
|
username: { label: "Username", type: "text", placeholder: "admin" },
|
|
password: { label: "Password", type: "password" }
|
|
},
|
|
async authorize(credentials) {
|
|
// Development accounts - DO NOT USE IN PRODUCTION
|
|
const devAccounts = [
|
|
{ username: 'admin', password: 'admin123', name: 'Admin User', email: 'admin@horizon.local', role: 'Admin' },
|
|
{ username: 'manager', password: 'manager123', name: 'Manager User', email: 'manager@horizon.local', role: 'Manager' },
|
|
{ username: 'ae', password: 'ae123', name: 'Account Executive', email: 'ae@horizon.local', role: 'Account Executive' },
|
|
{ username: 'dboland', password: 'Seubert2026!', name: 'Dawn Boland', email: 'dboland@seubert.com', role: 'Manager' },
|
|
]
|
|
|
|
const account = devAccounts.find(
|
|
acc => acc.username === credentials?.username && acc.password === credentials?.password
|
|
)
|
|
|
|
if (account) {
|
|
// Create or update user in database
|
|
const user = await prisma.user.upsert({
|
|
where: { email: account.email },
|
|
update: {
|
|
displayName: account.name,
|
|
lastLoginAt: new Date(),
|
|
},
|
|
create: {
|
|
entraOid: `local-${account.username}`,
|
|
email: account.email,
|
|
displayName: account.name,
|
|
isActive: true,
|
|
lastLoginAt: new Date(),
|
|
},
|
|
})
|
|
|
|
// Assign role if not already assigned
|
|
const role = await prisma.role.findUnique({
|
|
where: { name: account.role },
|
|
})
|
|
|
|
if (role) {
|
|
await prisma.userRole.upsert({
|
|
where: {
|
|
userId_roleId: {
|
|
userId: user.id,
|
|
roleId: role.id,
|
|
},
|
|
},
|
|
update: {},
|
|
create: {
|
|
userId: user.id,
|
|
roleId: role.id,
|
|
},
|
|
})
|
|
}
|
|
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
userId: user.id,
|
|
action: 'AUTH_SIGNIN',
|
|
entityType: 'User',
|
|
entityId: user.id,
|
|
newValues: { provider: 'credentials' },
|
|
},
|
|
}).catch(() => {})
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.displayName,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
}),
|
|
// Azure AD provider (optional, only if credentials are configured)
|
|
...(process.env.AZURE_AD_CLIENT_ID ? [
|
|
AzureADProvider({
|
|
clientId: process.env.AZURE_AD_CLIENT_ID,
|
|
clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
|
|
tenantId: process.env.AZURE_AD_TENANT_ID!,
|
|
authorization: {
|
|
params: {
|
|
scope: 'openid profile email User.Read',
|
|
},
|
|
},
|
|
})
|
|
] : []),
|
|
],
|
|
callbacks: {
|
|
async signIn({ user, account, profile }) {
|
|
// Allow credentials provider without profile
|
|
if (account?.provider === 'credentials') {
|
|
return true
|
|
}
|
|
|
|
// Azure AD requires profile
|
|
if (!profile) return false
|
|
|
|
// Upsert user on sign in
|
|
const azureProfile = profile as any
|
|
|
|
// First, check if user exists by email (for pre-created users without entraOid)
|
|
const existingUserByEmail = await prisma.user.findUnique({
|
|
where: { email: user.email! },
|
|
})
|
|
|
|
if (existingUserByEmail && !existingUserByEmail.entraOid) {
|
|
// Link the pre-created user to their Azure AD account
|
|
await prisma.user.update({
|
|
where: { email: user.email! },
|
|
data: {
|
|
entraOid: azureProfile.oid,
|
|
displayName: user.name || existingUserByEmail.displayName,
|
|
department: azureProfile.department || existingUserByEmail.department,
|
|
lastLoginAt: new Date(),
|
|
},
|
|
})
|
|
} else {
|
|
// Standard upsert by entraOid
|
|
await prisma.user.upsert({
|
|
where: { entraOid: azureProfile.oid },
|
|
update: {
|
|
email: user.email!,
|
|
displayName: user.name,
|
|
lastLoginAt: new Date(),
|
|
},
|
|
create: {
|
|
entraOid: azureProfile.oid,
|
|
email: user.email!,
|
|
displayName: user.name,
|
|
department: azureProfile.department || null,
|
|
isActive: true,
|
|
lastLoginAt: new Date(),
|
|
},
|
|
})
|
|
}
|
|
|
|
// Log sign-in for Azure AD users
|
|
try {
|
|
const signedInUser = await prisma.user.findUnique({
|
|
where: { email: user.email! },
|
|
select: { id: true },
|
|
})
|
|
if (signedInUser) {
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
userId: signedInUser.id,
|
|
action: 'AUTH_SIGNIN',
|
|
entityType: 'User',
|
|
entityId: signedInUser.id,
|
|
newValues: { provider: account?.provider || 'azure-ad' },
|
|
},
|
|
})
|
|
}
|
|
} catch {}
|
|
|
|
return true
|
|
},
|
|
async session({ session, token }) {
|
|
if (session.user && token.email) {
|
|
try {
|
|
// Find user by email for both credentials and Azure AD
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { email: token.email as string },
|
|
include: {
|
|
userRoles: {
|
|
include: {
|
|
role: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (dbUser && dbUser.id) {
|
|
const extendedUser = session.user as any
|
|
extendedUser.id = dbUser.id
|
|
extendedUser.entraOid = dbUser.entraOid
|
|
extendedUser.roles = dbUser.userRoles.map((ur) => ur.role.name)
|
|
extendedUser.permissions = mergePermissions(
|
|
dbUser.userRoles.map((ur) => ur.role.permissions as Record<string, boolean>)
|
|
)
|
|
}
|
|
} catch (error) {
|
|
console.error('Session callback error:', error)
|
|
}
|
|
}
|
|
|
|
return session
|
|
},
|
|
async jwt({ token, user, account }) {
|
|
// On sign in, add user info to token
|
|
if (user) {
|
|
token.email = user.email
|
|
token.name = user.name
|
|
|
|
// Fetch user roles and add to token
|
|
if (user.email) {
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { email: user.email },
|
|
include: {
|
|
userRoles: {
|
|
include: {
|
|
role: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (dbUser) {
|
|
token.roles = dbUser.userRoles.map((ur: any) => ur.role.name)
|
|
token.userId = dbUser.id
|
|
}
|
|
}
|
|
}
|
|
|
|
return token
|
|
},
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Merge permissions from multiple roles
|
|
*/
|
|
function mergePermissions(permissionSets: Record<string, boolean>[]): Record<string, boolean> {
|
|
const merged: Record<string, boolean> = {}
|
|
|
|
for (const permissions of permissionSets) {
|
|
for (const [key, value] of Object.entries(permissions)) {
|
|
// If any role grants a permission, it's granted
|
|
if (value) {
|
|
merged[key] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
/**
|
|
* Check if user has a specific permission
|
|
*/
|
|
export function hasPermission(
|
|
permissions: Record<string, boolean> | undefined,
|
|
permission: string
|
|
): boolean {
|
|
if (!permissions) return false
|
|
return permissions[permission] === true
|
|
}
|
|
|
|
/**
|
|
* Check if user has any of the specified roles
|
|
*/
|
|
export function hasRole(roles: string[] | undefined, ...requiredRoles: string[]): boolean {
|
|
if (!roles) return false
|
|
return requiredRoles.some(role => roles.includes(role))
|
|
}
|