feat: forgot password flow, member page fixes, country name display
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
Password reset: - /forgot-password page: enter email, receive reset link via email - /reset-password?token=xxx page: set new password with validation - user.requestPasswordReset: generates token, sends styled email - user.resetPassword: validates token, hashes new password - Does NOT trigger re-onboarding — only resets the password - 30-minute token expiry, cleared after use - Added passwordResetToken/passwordResetExpiresAt to User model Member detail page fixes: - Hide "Expertise & Capacity" card for applicants/audience roles - Show country names with flag emojis instead of raw ISO codes - Login "Forgot password?" now links to /forgot-password page Project detail page: - Team member details show full country names with flags Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { UserRole } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
|
||||
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
|
||||
import { sendInvitationEmail, sendMagicLinkEmail, sendPasswordResetEmail } from '@/lib/email'
|
||||
import { hashPassword, validatePassword } from '@/lib/password'
|
||||
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
@@ -1476,44 +1476,126 @@ export const userRouter = router({
|
||||
requestPasswordReset: publicProcedure
|
||||
.input(z.object({ email: z.string().email() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const email = input.email.toLowerCase().trim()
|
||||
|
||||
// Find user by email
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { email: input.email },
|
||||
select: { id: true, email: true, status: true },
|
||||
where: { email },
|
||||
select: { id: true, email: true, name: true, status: true },
|
||||
})
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user || user.status === 'SUSPENDED') {
|
||||
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Mark user for password reset
|
||||
// Generate reset token + expiry (30 minutes)
|
||||
const token = generateInviteToken()
|
||||
const expiryMinutes = 30
|
||||
const expiresAt = new Date(Date.now() + expiryMinutes * 60 * 1000)
|
||||
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { mustSetPassword: true },
|
||||
data: {
|
||||
passwordResetToken: token,
|
||||
passwordResetExpiresAt: expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
// Generate a callback URL for the magic link
|
||||
// Send password reset email
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||
const callbackUrl = `${baseUrl}/set-password`
|
||||
const resetUrl = `${baseUrl}/reset-password?token=${token}`
|
||||
|
||||
// We don't send the email here - the user will use the magic link form
|
||||
// This just marks them for password reset
|
||||
// The actual email is sent through NextAuth's email provider
|
||||
try {
|
||||
await sendPasswordResetEmail(user.email, resetUrl, expiryMinutes)
|
||||
} catch (e) {
|
||||
console.error('[auth] Failed to send password reset email:', e)
|
||||
// Don't reveal failure to prevent enumeration
|
||||
}
|
||||
|
||||
// Audit log (without user ID since this is public)
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: null, // No authenticated user
|
||||
userId: null,
|
||||
action: 'REQUEST_PASSWORD_RESET',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
|
||||
detailsJson: { email, timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}).catch(() => {})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reset password using a token (public — from the reset-password page)
|
||||
*/
|
||||
resetPassword: publicProcedure
|
||||
.input(z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string().min(8),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.password !== input.confirmPassword) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Passwords do not match' })
|
||||
}
|
||||
|
||||
const validation = validatePassword(input.password)
|
||||
if (!validation.valid) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: validation.errors.join('. ') })
|
||||
}
|
||||
|
||||
// Find user by reset token
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { passwordResetToken: input.token },
|
||||
select: { id: true, email: true, status: true, passwordResetExpiresAt: true },
|
||||
})
|
||||
|
||||
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid or expired reset link. Please request a new one.' })
|
||||
}
|
||||
|
||||
if (user.status === 'SUSPENDED') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'This account has been suspended.' })
|
||||
}
|
||||
|
||||
if (user.passwordResetExpiresAt && user.passwordResetExpiresAt < new Date()) {
|
||||
// Clear expired token
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordResetToken: null, passwordResetExpiresAt: null },
|
||||
})
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'This reset link has expired. Please request a new one.' })
|
||||
}
|
||||
|
||||
// Hash and save new password, clear reset token
|
||||
const passwordHash = await hashPassword(input.password)
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordSetAt: new Date(),
|
||||
mustSetPassword: false,
|
||||
passwordResetToken: null,
|
||||
passwordResetExpiresAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: user.id,
|
||||
action: 'PASSWORD_RESET',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: user.email },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}).catch(() => {})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user