2026-02-14 15:26:42 +01:00
|
|
|
import NextAuth from 'next-auth'
|
|
|
|
|
import EmailProvider from 'next-auth/providers/email'
|
|
|
|
|
import CredentialsProvider from 'next-auth/providers/credentials'
|
|
|
|
|
import { PrismaAdapter } from '@auth/prisma-adapter'
|
|
|
|
|
import { prisma } from './prisma'
|
|
|
|
|
import { sendMagicLinkEmail } from './email'
|
|
|
|
|
import { verifyPassword } from './password'
|
|
|
|
|
import type { UserRole } from '@prisma/client'
|
|
|
|
|
import { authConfig } from './auth.config'
|
|
|
|
|
|
|
|
|
|
// Failed login attempt tracking (in-memory)
|
|
|
|
|
const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
|
|
|
|
|
const MAX_LOGIN_ATTEMPTS = 5
|
|
|
|
|
const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes
|
|
|
|
|
|
|
|
|
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
|
|
|
|
...authConfig,
|
2026-03-04 14:31:01 +01:00
|
|
|
logger: {
|
|
|
|
|
error(error) {
|
|
|
|
|
// CredentialsSignin is expected (wrong password, bots) — already logged to AuditLog with detail
|
|
|
|
|
if (error?.name === 'CredentialsSignin') return
|
|
|
|
|
console.error('[auth][error]', error)
|
|
|
|
|
},
|
|
|
|
|
warn(code) {
|
|
|
|
|
console.warn('[auth][warn]', code)
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-03-04 13:29:39 +01:00
|
|
|
adapter: {
|
|
|
|
|
...PrismaAdapter(prisma),
|
|
|
|
|
async useVerificationToken({ identifier, token }: { identifier: string; token: string }) {
|
|
|
|
|
try {
|
|
|
|
|
return await prisma.verificationToken.delete({
|
|
|
|
|
where: { identifier_token: { identifier, token } },
|
|
|
|
|
})
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if ((e as { code?: string }).code === 'P2025') return null
|
|
|
|
|
throw e
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-02-14 15:26:42 +01:00
|
|
|
providers: [
|
|
|
|
|
// Email provider for magic links (used for first login and password reset)
|
|
|
|
|
EmailProvider({
|
|
|
|
|
// Server config required by NextAuth validation but not used —
|
|
|
|
|
// sendVerificationRequest below fully overrides email sending via getTransporter()
|
|
|
|
|
server: {
|
|
|
|
|
host: process.env.SMTP_HOST || 'localhost',
|
|
|
|
|
port: Number(process.env.SMTP_PORT || 587),
|
|
|
|
|
auth: {
|
|
|
|
|
user: process.env.SMTP_USER || '',
|
|
|
|
|
pass: process.env.SMTP_PASS || '',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
from: process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>',
|
|
|
|
|
maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes
|
|
|
|
|
sendVerificationRequest: async ({ identifier: email, url }) => {
|
|
|
|
|
await sendMagicLinkEmail(email, url)
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
// Credentials provider for email/password login and invite token auth
|
|
|
|
|
CredentialsProvider({
|
|
|
|
|
name: 'credentials',
|
|
|
|
|
credentials: {
|
|
|
|
|
email: { label: 'Email', type: 'email' },
|
|
|
|
|
password: { label: 'Password', type: 'password' },
|
|
|
|
|
inviteToken: { label: 'Invite Token', type: 'text' },
|
|
|
|
|
},
|
|
|
|
|
async authorize(credentials) {
|
|
|
|
|
// Handle invite token authentication
|
|
|
|
|
if (credentials?.inviteToken) {
|
|
|
|
|
const token = credentials.inviteToken as string
|
|
|
|
|
const user = await prisma.user.findUnique({
|
|
|
|
|
where: { inviteToken: token },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
email: true,
|
|
|
|
|
name: true,
|
|
|
|
|
role: true,
|
2026-02-24 17:44:55 +01:00
|
|
|
roles: true,
|
2026-02-14 15:26:42 +01:00
|
|
|
status: true,
|
|
|
|
|
inviteTokenExpiresAt: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!user || user.status !== 'INVITED') {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear token, activate user, mark as needing password
|
|
|
|
|
await prisma.user.update({
|
|
|
|
|
where: { id: user.id },
|
|
|
|
|
data: {
|
|
|
|
|
inviteToken: null,
|
|
|
|
|
inviteTokenExpiresAt: null,
|
|
|
|
|
status: 'ACTIVE',
|
|
|
|
|
mustSetPassword: true,
|
|
|
|
|
lastLoginAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Log invitation accepted
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
action: 'INVITATION_ACCEPTED',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: user.id,
|
|
|
|
|
detailsJson: { email: user.email, role: user.role },
|
|
|
|
|
},
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
email: user.email,
|
|
|
|
|
name: user.name,
|
|
|
|
|
role: user.role,
|
2026-02-24 17:44:55 +01:00
|
|
|
roles: user.roles.length ? user.roles : [user.role],
|
2026-02-14 15:26:42 +01:00
|
|
|
mustSetPassword: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!credentials?.email || !credentials?.password) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const email = (credentials.email as string).toLowerCase()
|
|
|
|
|
const password = credentials.password as string
|
|
|
|
|
|
|
|
|
|
// Check if account is temporarily locked
|
|
|
|
|
const attempts = failedAttempts.get(email)
|
|
|
|
|
if (attempts && Date.now() < attempts.lockedUntil) {
|
|
|
|
|
throw new Error('Account temporarily locked due to too many failed attempts. Try again later.')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find user by email
|
|
|
|
|
const user = await prisma.user.findUnique({
|
|
|
|
|
where: { email },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
email: true,
|
|
|
|
|
name: true,
|
|
|
|
|
role: true,
|
2026-02-24 17:44:55 +01:00
|
|
|
roles: true,
|
2026-02-14 15:26:42 +01:00
|
|
|
status: true,
|
|
|
|
|
passwordHash: true,
|
|
|
|
|
mustSetPassword: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
if (!user || user.status === 'SUSPENDED') {
|
2026-02-14 15:26:42 +01:00
|
|
|
// Track failed attempt (don't reveal whether user exists)
|
|
|
|
|
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
|
|
|
|
|
current.count++
|
|
|
|
|
if (current.count >= MAX_LOGIN_ATTEMPTS) {
|
|
|
|
|
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
|
|
|
|
|
current.count = 0
|
|
|
|
|
}
|
|
|
|
|
failedAttempts.set(email, current)
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Log failed login — real security event
|
2026-02-14 15:26:42 +01:00
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: null,
|
|
|
|
|
action: 'LOGIN_FAILED',
|
|
|
|
|
entityType: 'User',
|
2026-03-04 13:29:39 +01:00
|
|
|
detailsJson: { email, reason: !user ? 'user_not_found' : 'suspended' },
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
if (!user.passwordHash) {
|
|
|
|
|
// Magic-link user tried credentials form — expected, not a security event
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
// Verify password
|
|
|
|
|
const isValid = await verifyPassword(password, user.passwordHash)
|
|
|
|
|
if (!isValid) {
|
|
|
|
|
// Track failed attempt
|
|
|
|
|
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
|
|
|
|
|
current.count++
|
|
|
|
|
if (current.count >= MAX_LOGIN_ATTEMPTS) {
|
|
|
|
|
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
|
|
|
|
|
current.count = 0
|
|
|
|
|
}
|
|
|
|
|
failedAttempts.set(email, current)
|
|
|
|
|
|
|
|
|
|
// Log failed login
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
action: 'LOGIN_FAILED',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: user.id,
|
|
|
|
|
detailsJson: { email, reason: 'invalid_password' },
|
|
|
|
|
},
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear failed attempts on successful login
|
|
|
|
|
failedAttempts.delete(email)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
email: user.email,
|
|
|
|
|
name: user.name,
|
|
|
|
|
role: user.role,
|
2026-02-24 17:44:55 +01:00
|
|
|
roles: user.roles.length ? user.roles : [user.role],
|
2026-02-14 15:26:42 +01:00
|
|
|
mustSetPassword: user.mustSetPassword,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
callbacks: {
|
|
|
|
|
...authConfig.callbacks,
|
feat: impersonation system, semi-finalist detail page, tRPC resilience
- Add super-admin impersonation: "Login As" from user list, red banner
with "Return to Admin", audit logged start/end, nested impersonation
blocked, onboarding gate skipped during impersonation
- Fix semi-finalist stats: check latest terminal state (not any PASSED),
use passwordHash OR status=ACTIVE for activation check
- Add /admin/semi-finalists detail page with search, category/status filters
- Add account_reminder_days setting to notifications tab
- Add tRPC resilience: retry on 503/HTML responses, custom fetch detects
nginx error pages, exponential backoff (2s/4s/8s)
- Reduce dashboard polling intervals (60s stats, 30s activity, 120s semi)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:55:44 +01:00
|
|
|
async jwt({ token, user, trigger, session }) {
|
2026-02-14 15:26:42 +01:00
|
|
|
// Initial sign in
|
|
|
|
|
if (user) {
|
|
|
|
|
token.id = user.id as string
|
|
|
|
|
token.role = user.role as UserRole
|
2026-02-24 17:44:55 +01:00
|
|
|
token.roles = user.roles?.length ? user.roles : [user.role as UserRole]
|
2026-02-14 15:26:42 +01:00
|
|
|
token.mustSetPassword = user.mustSetPassword
|
|
|
|
|
}
|
|
|
|
|
|
feat: impersonation system, semi-finalist detail page, tRPC resilience
- Add super-admin impersonation: "Login As" from user list, red banner
with "Return to Admin", audit logged start/end, nested impersonation
blocked, onboarding gate skipped during impersonation
- Fix semi-finalist stats: check latest terminal state (not any PASSED),
use passwordHash OR status=ACTIVE for activation check
- Add /admin/semi-finalists detail page with search, category/status filters
- Add account_reminder_days setting to notifications tab
- Add tRPC resilience: retry on 503/HTML responses, custom fetch detects
nginx error pages, exponential backoff (2s/4s/8s)
- Reduce dashboard polling intervals (60s stats, 30s activity, 120s semi)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:55:44 +01:00
|
|
|
// On session update, handle impersonation or normal refresh
|
|
|
|
|
if (trigger === 'update' && session) {
|
|
|
|
|
// Start impersonation
|
|
|
|
|
if (session.impersonate && typeof session.impersonate === 'string') {
|
|
|
|
|
// Only SUPER_ADMIN can impersonate (defense-in-depth)
|
|
|
|
|
if (token.role === 'SUPER_ADMIN' && !token.impersonating) {
|
|
|
|
|
const targetUser = await prisma.user.findUnique({
|
|
|
|
|
where: { id: session.impersonate },
|
|
|
|
|
select: { id: true, email: true, name: true, role: true, roles: true, status: true },
|
|
|
|
|
})
|
|
|
|
|
if (targetUser && targetUser.status !== 'SUSPENDED' && targetUser.role !== 'SUPER_ADMIN') {
|
|
|
|
|
// Save original admin identity
|
|
|
|
|
token.impersonating = {
|
|
|
|
|
originalId: token.id as string,
|
|
|
|
|
originalRole: token.role as UserRole,
|
|
|
|
|
originalRoles: (token.roles as UserRole[]) ?? [token.role as UserRole],
|
|
|
|
|
originalEmail: token.email as string,
|
|
|
|
|
}
|
|
|
|
|
// Swap to target user
|
|
|
|
|
token.id = targetUser.id
|
|
|
|
|
token.email = targetUser.email
|
|
|
|
|
token.name = targetUser.name
|
|
|
|
|
token.role = targetUser.role
|
|
|
|
|
token.roles = targetUser.roles.length ? targetUser.roles : [targetUser.role]
|
|
|
|
|
token.mustSetPassword = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// End impersonation
|
|
|
|
|
else if (session.endImpersonation && token.impersonating) {
|
|
|
|
|
const original = token.impersonating as { originalId: string; originalRole: UserRole; originalRoles: UserRole[]; originalEmail: string }
|
|
|
|
|
token.id = original.originalId
|
|
|
|
|
token.role = original.originalRole
|
|
|
|
|
token.roles = original.originalRoles
|
|
|
|
|
token.email = original.originalEmail
|
|
|
|
|
token.impersonating = undefined
|
|
|
|
|
token.mustSetPassword = false
|
|
|
|
|
// Refresh original admin's name
|
|
|
|
|
const adminUser = await prisma.user.findUnique({
|
|
|
|
|
where: { id: original.originalId },
|
|
|
|
|
select: { name: true },
|
|
|
|
|
})
|
|
|
|
|
if (adminUser) {
|
|
|
|
|
token.name = adminUser.name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Normal session refresh
|
|
|
|
|
else {
|
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
|
|
|
where: { id: token.id as string },
|
|
|
|
|
select: { role: true, roles: true, mustSetPassword: true },
|
|
|
|
|
})
|
|
|
|
|
if (dbUser) {
|
|
|
|
|
token.role = dbUser.role
|
|
|
|
|
token.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
|
|
|
|
|
// Don't override mustSetPassword=false during impersonation
|
|
|
|
|
if (!token.impersonating) {
|
|
|
|
|
token.mustSetPassword = dbUser.mustSetPassword
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return token
|
|
|
|
|
},
|
|
|
|
|
async session({ session, token }) {
|
|
|
|
|
if (token && session.user) {
|
|
|
|
|
session.user.id = token.id as string
|
|
|
|
|
session.user.role = token.role as UserRole
|
2026-02-24 17:44:55 +01:00
|
|
|
session.user.roles = (token.roles as UserRole[]) ?? [token.role as UserRole]
|
2026-02-14 15:26:42 +01:00
|
|
|
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
|
feat: impersonation system, semi-finalist detail page, tRPC resilience
- Add super-admin impersonation: "Login As" from user list, red banner
with "Return to Admin", audit logged start/end, nested impersonation
blocked, onboarding gate skipped during impersonation
- Fix semi-finalist stats: check latest terminal state (not any PASSED),
use passwordHash OR status=ACTIVE for activation check
- Add /admin/semi-finalists detail page with search, category/status filters
- Add account_reminder_days setting to notifications tab
- Add tRPC resilience: retry on 503/HTML responses, custom fetch detects
nginx error pages, exponential backoff (2s/4s/8s)
- Reduce dashboard polling intervals (60s stats, 30s activity, 120s semi)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:55:44 +01:00
|
|
|
session.user.impersonating = token.impersonating as typeof session.user.impersonating
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
return session
|
|
|
|
|
},
|
|
|
|
|
async signIn({ user, account }) {
|
|
|
|
|
// For email provider (magic link), check user status and get password info
|
|
|
|
|
if (account?.provider === 'email') {
|
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
|
|
|
where: { email: user.email! },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
status: true,
|
|
|
|
|
passwordHash: true,
|
|
|
|
|
mustSetPassword: true,
|
|
|
|
|
role: true,
|
2026-02-24 17:44:55 +01:00
|
|
|
roles: true,
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (dbUser?.status === 'SUSPENDED') {
|
|
|
|
|
return false // Block suspended users
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 17:00:19 +01:00
|
|
|
// Note: status stays INVITED/NONE until onboarding completes.
|
|
|
|
|
// The completeOnboarding mutation sets status to ACTIVE.
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
// Add user data for JWT callback
|
|
|
|
|
if (dbUser) {
|
|
|
|
|
user.id = dbUser.id
|
|
|
|
|
user.role = dbUser.role
|
2026-02-24 17:44:55 +01:00
|
|
|
user.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
|
2026-02-14 15:26:42 +01:00
|
|
|
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update last login time on actual sign-in
|
|
|
|
|
if (user.email) {
|
|
|
|
|
await prisma.user.update({
|
|
|
|
|
where: { email: user.email },
|
|
|
|
|
data: { lastLoginAt: new Date() },
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
// Ignore errors from updating last login
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Log successful login
|
|
|
|
|
await prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id as string,
|
|
|
|
|
action: 'LOGIN_SUCCESS',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: user.id as string,
|
|
|
|
|
detailsJson: { method: account?.provider || 'unknown', email: user.email },
|
|
|
|
|
},
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
async redirect({ url, baseUrl }) {
|
|
|
|
|
// Check if user needs to set password and redirect accordingly
|
|
|
|
|
// This is called after successful authentication
|
|
|
|
|
if (url.startsWith(baseUrl)) {
|
|
|
|
|
return url
|
|
|
|
|
}
|
|
|
|
|
// Allow relative redirects
|
|
|
|
|
if (url.startsWith('/')) {
|
|
|
|
|
return `${baseUrl}${url}`
|
|
|
|
|
}
|
|
|
|
|
return baseUrl
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Helper to get session in server components
|
|
|
|
|
export async function getServerSession() {
|
|
|
|
|
return auth()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper to require authentication
|
|
|
|
|
export async function requireAuth() {
|
|
|
|
|
const session = await auth()
|
|
|
|
|
if (!session?.user) {
|
|
|
|
|
throw new Error('Unauthorized')
|
|
|
|
|
}
|
|
|
|
|
return session
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper to require specific role(s)
|
|
|
|
|
export async function requireRole(...roles: UserRole[]) {
|
|
|
|
|
const session = await requireAuth()
|
2026-02-24 17:44:55 +01:00
|
|
|
// Use roles array, fallback to [role] for stale JWT tokens
|
|
|
|
|
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
|
|
|
|
|
if (!roles.some(r => userRoles.includes(r))) {
|
2026-02-14 15:26:42 +01:00
|
|
|
throw new Error('Forbidden')
|
|
|
|
|
}
|
|
|
|
|
return session
|
|
|
|
|
}
|