fix: case-insensitive email matching in auth and password reset
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m10s

Email lookups used findUnique (case-sensitive on PostgreSQL) but user
input was lowercased, causing login failures for users with mixed-case
emails stored in the DB (e.g. Laurent_Faure@dietsmann.com). Also
normalized 7 affected emails to lowercase on the production DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-14 11:04:31 -04:00
parent ec69706bc7
commit eb1e8a7870
3 changed files with 29 additions and 27 deletions

View File

@@ -24,8 +24,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ exists: false }, { status: 400 })
}
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase().trim() },
const user = await prisma.user.findFirst({
where: { email: { equals: email.toLowerCase().trim(), mode: 'insensitive' } },
select: { status: true },
})

View File

@@ -62,8 +62,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// Send magic links to any existing, non-suspended user.
// This is the primary first-login path for applicants seeded from CSV
// (status NONE) who have no password yet.
const existingUser = await prisma.user.findUnique({
where: { email: email.toLowerCase().trim() },
const existingUser = await prisma.user.findFirst({
where: { email: { equals: email.toLowerCase().trim(), mode: 'insensitive' } },
select: { id: true, status: true },
})
if (!existingUser || existingUser.status === 'SUSPENDED') {
@@ -164,9 +164,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
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 },
// Find user by email (case-insensitive — DB may store mixed-case emails)
const user = await prisma.user.findFirst({
where: { email: { equals: email, mode: 'insensitive' } },
select: {
id: true,
email: true,
@@ -371,8 +371,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
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! },
const dbUser = await prisma.user.findFirst({
where: { email: { equals: user.email!, mode: 'insensitive' } },
select: {
id: true,
status: true,
@@ -404,22 +404,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// Update last login time on actual sign-in, and activate INVITED users on login
if (user.email) {
const loginUser = await prisma.user.findUnique({
where: { email: user.email },
select: { status: true },
const loginUser = await prisma.user.findFirst({
where: { email: { equals: user.email, mode: 'insensitive' } },
select: { id: true, status: true },
})
if (loginUser) {
await prisma.user.update({
where: { email: user.email },
where: { id: loginUser.id },
data: {
lastLoginAt: new Date(),
// If user is still INVITED but successfully logged in, activate them
...(loginUser && loginUser.status === 'INVITED'
...(loginUser.status === 'INVITED'
? { status: 'ACTIVE' }
: {}),
},
}).catch(() => {
// Ignore errors from updating last login
})
}
// Log successful login
await prisma.auditLog.create({

View File

@@ -1595,9 +1595,9 @@ export const userRouter = router({
.mutation(async ({ ctx, input }) => {
const email = input.email.toLowerCase().trim()
// Find user by email
const user = await ctx.prisma.user.findUnique({
where: { email },
// Find user by email (case-insensitive — DB may store mixed-case emails)
const user = await ctx.prisma.user.findFirst({
where: { email: { equals: email, mode: 'insensitive' } },
select: { id: true, email: true, name: true, status: true },
})