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

@@ -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");

View File

@@ -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?

View File

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

View File

@@ -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' ? (

View 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&apos;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&apos;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&apos;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>
)
}

View File

@@ -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"

View 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>
)
}

View File

@@ -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

View File

@@ -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
*/

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