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
|
inviteToken String? @unique
|
||||||
inviteTokenExpiresAt DateTime?
|
inviteTokenExpiresAt DateTime?
|
||||||
|
|
||||||
|
// Password reset token
|
||||||
|
passwordResetToken String? @unique
|
||||||
|
passwordResetExpiresAt DateTime?
|
||||||
|
|
||||||
// Digest & availability preferences
|
// Digest & availability preferences
|
||||||
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
||||||
preferredWorkload Int?
|
preferredWorkload Int?
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ import {
|
|||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
Globe,
|
Globe,
|
||||||
Building2,
|
Building2,
|
||||||
Flag,
|
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
|
|
||||||
export default function MemberDetailPage() {
|
export default function MemberDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -266,19 +266,19 @@ export default function MemberDetailPage() {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{user.nationality && (
|
{user.nationality && (
|
||||||
<div className="flex items-start gap-2">
|
<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>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground">Nationality</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{user.country && (
|
{user.country && (
|
||||||
<div className="flex items-start gap-2">
|
<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>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground">Country of Residence</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -447,7 +447,8 @@ export default function MemberDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Expertise & Capacity */}
|
{/* Expertise & Capacity — only for jury/mentor/observer/admin roles */}
|
||||||
|
{!['APPLICANT', 'AUDIENCE'].includes(user.role) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -494,6 +495,7 @@ export default function MemberDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mentor Assignments Section */}
|
{/* Mentor Assignments Section */}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
@@ -517,7 +518,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
const isLastLead =
|
const isLastLead =
|
||||||
member.role === 'LEAD' &&
|
member.role === 'LEAD' &&
|
||||||
project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
|
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 (
|
return (
|
||||||
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||||
{member.role === 'LEAD' ? (
|
{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'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { signIn } from 'next-auth/react'
|
import { signIn } from 'next-auth/react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -192,16 +194,12 @@ export default function LoginPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={'/forgot-password' as Route}
|
||||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
onClick={() => {
|
|
||||||
setMode('magic-link')
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
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',
|
'/verify-email',
|
||||||
'/error',
|
'/error',
|
||||||
'/accept-invite',
|
'/accept-invite',
|
||||||
|
'/forgot-password',
|
||||||
|
'/reset-password',
|
||||||
'/apply',
|
'/apply',
|
||||||
'/api/auth',
|
'/api/auth',
|
||||||
'/api/trpc', // tRPC handles its own auth via procedures
|
'/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)
|
* Generate generic invitation email template (not round-specific)
|
||||||
*/
|
*/
|
||||||
@@ -2232,6 +2268,26 @@ export async function sendStyledNotificationEmail(
|
|||||||
// Email Sending Functions
|
// 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
|
* Send magic link email for authentication
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import { UserRole } from '@prisma/client'
|
import { UserRole } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
|
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 { hashPassword, validatePassword } from '@/lib/password'
|
||||||
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
|
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
@@ -1476,44 +1476,126 @@ export const userRouter = router({
|
|||||||
requestPasswordReset: publicProcedure
|
requestPasswordReset: publicProcedure
|
||||||
.input(z.object({ email: z.string().email() }))
|
.input(z.object({ email: z.string().email() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const email = input.email.toLowerCase().trim()
|
||||||
|
|
||||||
// Find user by email
|
// Find user by email
|
||||||
const user = await ctx.prisma.user.findUnique({
|
const user = await ctx.prisma.user.findUnique({
|
||||||
where: { email: input.email },
|
where: { email },
|
||||||
select: { id: true, email: true, status: true },
|
select: { id: true, email: true, name: true, status: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Always return success to prevent email enumeration
|
// Always return success to prevent email enumeration
|
||||||
if (!user || user.status === 'SUSPENDED') {
|
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({
|
await ctx.prisma.user.update({
|
||||||
where: { id: user.id },
|
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 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
|
try {
|
||||||
// This just marks them for password reset
|
await sendPasswordResetEmail(user.email, resetUrl, expiryMinutes)
|
||||||
// The actual email is sent through NextAuth's email provider
|
} 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({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
userId: null, // No authenticated user
|
userId: null,
|
||||||
action: 'REQUEST_PASSWORD_RESET',
|
action: 'REQUEST_PASSWORD_RESET',
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
|
detailsJson: { email, timestamp: new Date().toISOString() },
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
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