diff --git a/prisma/migrations/20260305000000_add_password_reset_token/migration.sql b/prisma/migrations/20260305000000_add_password_reset_token/migration.sql new file mode 100644 index 0000000..dc2df28 --- /dev/null +++ b/prisma/migrations/20260305000000_add_password_reset_token/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "passwordResetToken" TEXT, +ADD COLUMN "passwordResetExpiresAt" TIMESTAMP(3); + +-- CreateIndex +CREATE UNIQUE INDEX "User_passwordResetToken_key" ON "User"("passwordResetToken"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b79fc7a..8a8231e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -335,6 +335,10 @@ model User { inviteToken String? @unique inviteTokenExpiresAt DateTime? + // Password reset token + passwordResetToken String? @unique + passwordResetExpiresAt DateTime? + // Digest & availability preferences digestFrequency String @default("none") // 'none' | 'daily' | 'weekly' preferredWorkload Int? diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index 64d0e58..6b13d81 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -62,10 +62,10 @@ import { ThumbsDown, Globe, Building2, - Flag, FileText, FolderOpen, } from 'lucide-react' +import { getCountryName, getCountryFlag } from '@/lib/countries' export default function MemberDetailPage() { const params = useParams() @@ -266,19 +266,19 @@ export default function MemberDetailPage() {
{user.nationality && (
- + {getCountryFlag(user.nationality)}

Nationality

-

{user.nationality}

+

{getCountryName(user.nationality)}

)} {user.country && (
- + {getCountryFlag(user.country)}

Country of Residence

-

{user.country}

+

{getCountryName(user.country)}

)} @@ -447,7 +447,8 @@ export default function MemberDetailPage() { - {/* Expertise & Capacity */} + {/* Expertise & Capacity — only for jury/mentor/observer/admin roles */} + {!['APPLICANT', 'AUDIENCE'].includes(user.role) && ( @@ -494,6 +495,7 @@ export default function MemberDetailPage() { )} + )}
{/* Mentor Assignments Section */} diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 8cc1cce..14fb27a 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -77,6 +77,7 @@ import { } from 'lucide-react' import { toast } from 'sonner' import { formatDateOnly } from '@/lib/utils' +import { getCountryName, getCountryFlag } from '@/lib/countries' interface PageProps { params: Promise<{ id: string }> @@ -517,7 +518,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { const isLastLead = member.role === 'LEAD' && project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1 - const details = [member.user.nationality, member.user.institution, member.user.country].filter(Boolean) + const details = [ + member.user.nationality ? `${getCountryFlag(member.user.nationality)} ${getCountryName(member.user.nationality)}` : null, + member.user.institution, + member.user.country && member.user.country !== member.user.nationality ? `${getCountryFlag(member.user.country)} ${getCountryName(member.user.country)}` : null, + ].filter(Boolean) return (
{member.role === 'LEAD' ? ( diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..a6775f9 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,144 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Mail, Loader2, CheckCircle2, AlertCircle, ArrowLeft } from 'lucide-react' +import { trpc } from '@/lib/trpc/client' +import { AnimatedCard } from '@/components/shared/animated-container' + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState('') + const [isSent, setIsSent] = useState(false) + const [error, setError] = useState(null) + + const requestReset = trpc.user.requestPasswordReset.useMutation({ + onSuccess: () => { + setIsSent(true) + }, + onError: (err) => { + setError(err.message || 'Something went wrong. Please try again.') + }, + }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + requestReset.mutate({ email: email.trim() }) + } + + if (isSent) { + return ( + + +
+ +
+ +
+ Check your email + + If an account exists for {email}, we've sent a password reset link. + +
+ +
+

Click the link in the email to reset your password. The link will expire in 30 minutes.

+

If you don't see it, check your spam folder.

+
+
+ +
+ + + Back to login + +
+
+
+ + + ) + } + + return ( + + +
+ +
+ +
+ Reset your password + + Enter your email address and we'll send you a link to reset your password. + +
+ +
+ {error && ( +
+ +

{error}

+
+ )} + +
+ + setEmail(e.target.value)} + required + disabled={requestReset.isPending} + autoComplete="email" + autoFocus + /> +
+ + + +
+ + + Back to login + +
+
+
+ + + ) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index aff2a85..4434c8c 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,8 +1,10 @@ 'use client' import { useState } from 'react' +import type { Route } from 'next' import { useSearchParams, useRouter } from 'next/navigation' import { signIn } from 'next-auth/react' +import Link from 'next/link' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -192,16 +194,12 @@ export default function LoginPage() {
- +
(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. + +
+ + +
+ + + Back to login + +
+
+ + + ) + } + + // Success state + if (isSuccess) { + return ( + + +
+ +
+ +
+ Password Reset Successfully + + Your password has been updated. You can now sign in with your new password. + +
+ + + + + + ) + } + + return ( + + +
+ + Choose a new password + + Create a secure password for your account. + + + +
+ {error && ( +
+ +

{error}

+
+ )} + +
+ +
+ setPassword(e.target.value)} + required + disabled={resetPassword.isPending} + autoComplete="new-password" + autoFocus + className="pr-10" + /> + +
+ + {password.length > 0 && ( +
+
+ + {strength.label} +
+
+ {[ + { label: '8+ characters', met: password.length >= 8 }, + { label: 'Uppercase', met: /[A-Z]/.test(password) }, + { label: 'Lowercase', met: /[a-z]/.test(password) }, + { label: 'Number', met: /[0-9]/.test(password) }, + ].map((req) => ( +
+ {req.met ? ( + + ) : ( +
+ )} + {req.label} +
+ ))} +
+
+ )} +
+ +
+ +
+ setConfirmPassword(e.target.value)} + required + disabled={resetPassword.isPending} + autoComplete="new-password" + className="pr-10" + /> + +
+ {confirmPassword.length > 0 && ( +

+ {doPasswordsMatch ? 'Passwords match' : 'Passwords do not match'} +

+ )} +
+ + + +
+ + + Back to login + +
+ + + + + ) +} 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 } }), /**