feat: forgot password flow, member page fixes, country name display
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:
2026-03-05 13:49:43 +01:00
parent b6ba5d7145
commit ee8e90132e
10 changed files with 606 additions and 29 deletions

View File

@@ -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 }
}),
/**