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:
@@ -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");
|
||||
@@ -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?
|
||||
|
||||
@@ -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() {
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{user.nationality && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Flag className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-lg mt-0.5 shrink-0" role="img">{getCountryFlag(user.nationality)}</span>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Nationality</p>
|
||||
<p className="text-sm">{user.nationality}</p>
|
||||
<p className="text-sm">{getCountryName(user.nationality)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user.country && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Globe className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-lg mt-0.5 shrink-0" role="img">{getCountryFlag(user.country)}</span>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Country of Residence</p>
|
||||
<p className="text-sm">{user.country}</p>
|
||||
<p className="text-sm">{getCountryName(user.country)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -447,7 +447,8 @@ export default function MemberDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expertise & Capacity */}
|
||||
{/* Expertise & Capacity — only for jury/mentor/observer/admin roles */}
|
||||
{!['APPLICANT', 'AUDIENCE'].includes(user.role) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -494,6 +495,7 @@ export default function MemberDetailPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mentor Assignments Section */}
|
||||
|
||||
@@ -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 (
|
||||
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||
{member.role === 'LEAD' ? (
|
||||
|
||||
144
src/app/(auth)/forgot-password/page.tsx
Normal file
144
src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-50 animate-in zoom-in-50 duration-300">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
If an account exists for <strong>{email}</strong>, we've sent a password reset link.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/50 p-4 text-sm text-muted-foreground space-y-2">
|
||||
<p>Click the link in the email to reset your password. The link will expire in 30 minutes.</p>
|
||||
<p>If you don't see it, check your spam folder.</p>
|
||||
</div>
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setIsSent(false)
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Try a different email
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted">
|
||||
<Mail className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Reset your password</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={requestReset.isPending}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={requestReset.isPending || !email.trim()}>
|
||||
{requestReset.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send reset link
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center pt-2">
|
||||
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
href={'/forgot-password' as Route}
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => {
|
||||
setMode('magic-link')
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
|
||||
278
src/app/(auth)/reset-password/page.tsx
Normal file
278
src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Route } from 'next'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
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 { Progress } from '@/components/ui/progress'
|
||||
import { Lock, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ArrowLeft } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Invalid Reset Link</CardTitle>
|
||||
<CardDescription>
|
||||
This password reset link is invalid or has expired.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={'/forgot-password' as Route}>Request a new reset link</Link>
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Password Reset Successfully</CardTitle>
|
||||
<CardDescription>
|
||||
Your password has been updated. You can now sign in with your new password.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/login">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Choose a new password</CardTitle>
|
||||
<CardDescription>
|
||||
Create a secure password for your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter a secure password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={resetPassword.isPending}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{password.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={(strength.score / 4) * 100} className={`h-2 ${strength.color}`} />
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{strength.label}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-xs">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div
|
||||
key={req.label}
|
||||
className={`flex items-center gap-1 ${req.met ? 'text-green-600' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{req.met ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : (
|
||||
<div className="h-3 w-3 rounded-full border border-current" />
|
||||
)}
|
||||
{req.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
disabled={resetPassword.isPending}
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{confirmPassword.length > 0 && (
|
||||
<p className={`text-xs ${doPasswordsMatch ? 'text-green-600' : 'text-destructive'}`}>
|
||||
{doPasswordsMatch ? 'Passwords match' : 'Passwords do not match'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={resetPassword.isPending || !isPasswordValid || !doPasswordsMatch}
|
||||
>
|
||||
{resetPassword.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Reset Password
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center pt-2">
|
||||
<Link href="/login" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
<ArrowLeft className="inline h-3.5 w-3.5 mr-1" />
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(`<strong>This link expires in ${expiryMinutes} minutes</strong>`, 'warning')}
|
||||
${ctaButton(url, 'Reset Password')}
|
||||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
||||
If you didn't request a password reset, you can safely ignore this email. Your password will not change.
|
||||
</p>
|
||||
`
|
||||
|
||||
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<void> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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