seubert-claims/ondeck/src/lib/auth.ts

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