Password
- {
- setMode('magic-link')
- setError(null)
- }}
>
Forgot password?
-
+
(null)
+ const [isSuccess, setIsSuccess] = useState(false)
+
+ const resetPassword = trpc.user.resetPassword.useMutation({
+ onSuccess: () => {
+ setIsSuccess(true)
+ },
+ onError: (err) => {
+ setError(err.message || 'Failed to reset password. Please try again.')
+ },
+ })
+
+ // Password validation
+ const validatePassword = (pwd: string) => {
+ const errors: string[] = []
+ if (pwd.length < 8) errors.push('At least 8 characters')
+ if (!/[A-Z]/.test(pwd)) errors.push('One uppercase letter')
+ if (!/[a-z]/.test(pwd)) errors.push('One lowercase letter')
+ if (!/[0-9]/.test(pwd)) errors.push('One number')
+ return errors
+ }
+
+ const passwordErrors = validatePassword(password)
+ const isPasswordValid = passwordErrors.length === 0
+ const doPasswordsMatch = password === confirmPassword && password.length > 0
+
+ const getPasswordStrength = (pwd: string) => {
+ let score = 0
+ if (pwd.length >= 8) score++
+ if (pwd.length >= 12) score++
+ if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
+ if (/[0-9]/.test(pwd)) score++
+ if (/[^a-zA-Z0-9]/.test(pwd)) score++
+ const normalizedScore = Math.min(4, score)
+ const labels = ['Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong']
+ const colors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-green-600']
+ return { score: normalizedScore, label: labels[normalizedScore], color: colors[normalizedScore] }
+ }
+
+ const strength = getPasswordStrength(password)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError(null)
+
+ if (!isPasswordValid) {
+ setError('Password does not meet requirements.')
+ return
+ }
+ if (!doPasswordsMatch) {
+ setError('Passwords do not match.')
+ return
+ }
+ if (!token) {
+ setError('Invalid reset link. Please request a new one.')
+ return
+ }
+
+ resetPassword.mutate({ token, password, confirmPassword })
+ }
+
+ // No token in URL
+ if (!token) {
+ return (
+
+
+
+
+
+ Invalid Reset Link
+
+ This password reset link is invalid or has expired.
+
+
+
+
+ Request a new reset link
+
+
+
+
+
+ )
+ }
+
+ // Success state
+ if (isSuccess) {
+ return (
+
+
+
+
+
+
+
+ Password Reset Successfully
+
+ Your password has been updated. You can now sign in with your new password.
+
+
+
+
+ Sign in
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ Choose a new password
+
+ Create a secure password for your account.
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/lib/auth.config.ts b/src/lib/auth.config.ts
index e46281e..2cf8d49 100644
--- a/src/lib/auth.config.ts
+++ b/src/lib/auth.config.ts
@@ -55,6 +55,8 @@ export const authConfig: NextAuthConfig = {
'/verify-email',
'/error',
'/accept-invite',
+ '/forgot-password',
+ '/reset-password',
'/apply',
'/api/auth',
'/api/trpc', // tRPC handles its own auth via procedures
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 1c2758e..ddb9d34 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -346,6 +346,42 @@ Together for a healthier ocean.
}
}
+/**
+ * Generate password reset email template
+ */
+function getPasswordResetTemplate(url: string, expiryMinutes: number = 30): EmailTemplate {
+ const content = `
+ ${sectionTitle('Reset your password')}
+ ${paragraph('We received a request to reset your password for the MOPC Portal. Click the button below to choose a new password.')}
+ ${infoBox(`
This link expires in ${expiryMinutes} minutes `, 'warning')}
+ ${ctaButton(url, 'Reset Password')}
+
+ If you didn't request a password reset, you can safely ignore this email. Your password will not change.
+
+ `
+
+ return {
+ subject: 'Reset your password — MOPC Portal',
+ html: getEmailWrapper(content),
+ text: `
+Reset your password
+=========================
+
+Click the link below to reset your password:
+
+${url}
+
+This link will expire in ${expiryMinutes} minutes.
+
+If you didn't request this, you can safely ignore this email.
+
+---
+Monaco Ocean Protection Challenge
+Together for a healthier ocean.
+ `,
+ }
+}
+
/**
* Generate generic invitation email template (not round-specific)
*/
@@ -2232,6 +2268,26 @@ export async function sendStyledNotificationEmail(
// Email Sending Functions
// =============================================================================
+/**
+ * Send password reset email
+ */
+export async function sendPasswordResetEmail(
+ email: string,
+ url: string,
+ expiryMinutes: number = 30
+): Promise
{
+ const template = getPasswordResetTemplate(url, expiryMinutes)
+ const { transporter, from } = await getTransporter()
+
+ await transporter.sendMail({
+ from,
+ to: email,
+ subject: template.subject,
+ text: template.text,
+ html: template.html,
+ })
+}
+
/**
* Send magic link email for authentication
*/
diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts
index 220935e..c1db451 100644
--- a/src/server/routers/user.ts
+++ b/src/server/routers/user.ts
@@ -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 }
}),
/**