feat: add resend verification email functionality and related schemas
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 27s
Release Pipeline / build-and-release (push) Successful in 5m55s

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
LaurensBarteldsandCopilot committed 2026-05-02 21:03:56 +02:00
1 parent 25e590639a
commit 1a37209f72
8 files changed
+134 -2

No files matched your search

+20
View File
@@ -14,6 +14,7 @@ import {
updateProfileSchema, updateProfileSchema,
changePasswordSchema, changePasswordSchema,
verifyEmailSchema, verifyEmailSchema,
resendVerificationSchema,
resetPasswordSchema, resetPasswordSchema,
forgotPasswordSchema, forgotPasswordSchema,
} from '../validators/authSchemas.js' } from '../validators/authSchemas.js'
@@ -29,6 +30,14 @@ const authLimiter = rateLimit({
skipSuccessfulRequests: false, skipSuccessfulRequests: false,
}) })
const resendVerificationLimiter = rateLimit({
windowMs: 60 * 1000,
max: 1,
message: { error: 'Please wait a minute before requesting another verification email' },
standardHeaders: true,
skipSuccessfulRequests: false,
})
// POST /api/auth/register // POST /api/auth/register
router.post( router.post(
'/register', '/register',
@@ -94,6 +103,17 @@ router.get(
}) })
) )
// POST /api/auth/resend-verification
router.post(
'/resend-verification',
resendVerificationLimiter,
validateBody(resendVerificationSchema),
asyncHandler(async (req, res) => {
await authService.resendVerification(req.body.email)
res.json({ message: 'If the account exists, a new verification link has been sent' })
})
)
// POST /api/auth/forgot-password // POST /api/auth/forgot-password
router.post( router.post(
'/forgot-password', '/forgot-password',
+18
View File
@@ -145,6 +145,24 @@ export async function verifyEmail(input: VerifyEmailInput) {
}) })
} }
export async function resendVerification(email: string) {
const user = userRepo.getUserByEmail(email)
if (!user || user.email_verified) {
return
}
const rawVerifyToken = generateVerificationToken()
const verifyTokenHash = hashToken(rawVerifyToken)
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
userRepo.updateUser(user.id, {
verifyToken: verifyTokenHash,
verifyExpires,
})
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
}
export async function forgotPassword(email?: string) { export async function forgotPassword(email?: string) {
if (!email) { if (!email) {
throw new AppError(400, 'Email is required') throw new AppError(400, 'Email is required')
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { registerSchema, loginSchema, verifyEmailSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas' import { registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas'
describe('registerSchema', () => { describe('registerSchema', () => {
it('accepts valid registration', () => { it('accepts valid registration', () => {
@@ -72,6 +72,13 @@ describe('verifyEmailSchema', () => {
}) })
}) })
describe('resendVerificationSchema', () => {
it('accepts valid email', () => {
const result = resendVerificationSchema.safeParse({ email: 'test@example.com' })
expect(result.success).toBe(true)
})
})
describe('resetPasswordSchema', () => { describe('resetPasswordSchema', () => {
it('accepts valid reset', () => { it('accepts valid reset', () => {
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' }) const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
+5
View File
@@ -19,6 +19,10 @@ export const verifyEmailSchema = z.object({
token: z.string().min(1, 'Token is required'), token: z.string().min(1, 'Token is required'),
}) })
export const resendVerificationSchema = z.object({
email: z.string().email(),
})
export const resetPasswordSchema = z.object({ export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Token is required'), token: z.string().min(1, 'Token is required'),
newPassword: z.string() newPassword: z.string()
@@ -44,6 +48,7 @@ export const forgotPasswordSchema = z.object({
export type RegisterInput = z.infer<typeof registerSchema> export type RegisterInput = z.infer<typeof registerSchema>
export type LoginInput = z.infer<typeof loginSchema> export type LoginInput = z.infer<typeof loginSchema>
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema> export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>
export type ResendVerificationInput = z.infer<typeof resendVerificationSchema>
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema> export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
export type UpdateProfileInput = z.infer<typeof updateProfileSchema> export type UpdateProfileInput = z.infer<typeof updateProfileSchema>
export type ChangePasswordInput = z.infer<typeof changePasswordSchema> export type ChangePasswordInput = z.infer<typeof changePasswordSchema>
+4
View File
@@ -41,6 +41,10 @@ export function verifyEmailToken(token: string): Promise<{ message: string }> {
return apiFetch(`/api/auth/verify?token=${encodeURIComponent(token)}`) return apiFetch(`/api/auth/verify?token=${encodeURIComponent(token)}`)
} }
export function resendVerification(email: string): Promise<{ message: string }> {
return apiFetch('/api/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) })
}
export function forgotPassword(email?: string): Promise<{ message: string }> { export function forgotPassword(email?: string): Promise<{ message: string }> {
return apiFetch('/api/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) }) return apiFetch('/api/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) })
} }
+71 -1
View File
@@ -1,7 +1,8 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { ApiError } from '../utils/api' import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
interface Props { interface Props {
onClose: () => void onClose: () => void
@@ -10,6 +11,9 @@ interface Props {
type Tab = 'login' | 'register' type Tab = 'login' | 'register'
const VERIFICATION_RESEND_DELAY_MS = 60_000
const VERIFICATION_REQUIRED_MESSAGE = 'Please verify your email before logging in'
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) { export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
const { t } = useTranslation() const { t } = useTranslation()
const { login, register } = useAuth() const { login, register } = useAuth()
@@ -20,22 +24,50 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
const [displayName, setDisplayName] = useState('') const [displayName, setDisplayName] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [resendingVerification, setResendingVerification] = useState(false)
const [verificationEmail, setVerificationEmail] = useState('')
const [verificationResendAvailableAt, setVerificationResendAvailableAt] = useState<number | null>(null)
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!verificationResendAvailableAt) return undefined
const timer = window.setInterval(() => {
setNow(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [verificationResendAvailableAt])
const verificationResendSeconds = verificationResendAvailableAt
? Math.max(0, Math.ceil((verificationResendAvailableAt - now) / 1000))
: 0
const canResendVerification = verificationEmail.length > 0 && verificationResendSeconds === 0 && !resendingVerification
const switchTab = (t: Tab) => { const switchTab = (t: Tab) => {
setTab(t) setTab(t)
setError(null) setError(null)
setSuccess(null) setSuccess(null)
setNotice(null)
setVerificationEmail('')
setVerificationResendAvailableAt(null)
} }
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
setNotice(null)
setSubmitting(true) setSubmitting(true)
try { try {
await login(email, password) await login(email, password)
onClose() onClose()
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 403 && err.message === VERIFICATION_REQUIRED_MESSAGE) {
setVerificationEmail(email)
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
}
setError(err instanceof ApiError ? err.message : t('auth.loginFailed')) setError(err instanceof ApiError ? err.message : t('auth.loginFailed'))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
@@ -45,6 +77,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
const handleRegister = async (e: React.FormEvent) => { const handleRegister = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
setNotice(null)
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError(t('auth.passwordsDoNotMatch')) setError(t('auth.passwordsDoNotMatch'))
return return
@@ -60,6 +93,23 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
} }
} }
const handleResendVerification = async () => {
if (!canResendVerification) return
setResendingVerification(true)
setNotice(null)
setError(null)
try {
const response = await authApi.resendVerification(verificationEmail)
setNotice(response.message)
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
} catch (err) {
setError(err instanceof ApiError ? err.message : t('auth.requestFailed'))
} finally {
setResendingVerification(false)
}
}
return ( return (
<div className="auth-overlay" onClick={onClose}> <div className="auth-overlay" onClick={onClose}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}> <div className="auth-modal" onClick={(e) => e.stopPropagation()}>
@@ -94,6 +144,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
{error && <div className="auth-error">{error}</div>} {error && <div className="auth-error">{error}</div>}
{success && <div className="auth-success">{success}</div>} {success && <div className="auth-success">{success}</div>}
{notice && <div className="auth-success">{notice}</div>}
{!success && tab === 'login' && ( {!success && tab === 'login' && (
<form onSubmit={(e) => void handleLogin(e)} className="auth-form"> <form onSubmit={(e) => void handleLogin(e)} className="auth-form">
@@ -133,6 +184,25 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
> >
{t('auth.forgotPassword')} {t('auth.forgotPassword')}
</button> </button>
{verificationEmail && error === VERIFICATION_REQUIRED_MESSAGE && (
<div className="auth-field" style={{ marginTop: 8 }}>
<p className="auth-subtitle" style={{ marginBottom: 8 }}>
{t('auth.verificationResendHint')}
</p>
<button
type="button"
className="btn"
onClick={() => void handleResendVerification()}
disabled={!canResendVerification}
>
{resendingVerification
? t('auth.sending')
: verificationResendSeconds > 0
? t('auth.resendVerificationLinkIn', { seconds: verificationResendSeconds })
: t('auth.resendVerificationLink')}
</button>
</div>
)}
</form> </form>
)} )}
+4
View File
@@ -62,6 +62,10 @@
"loginFailed": "Login failed", "loginFailed": "Login failed",
"registrationFailed": "Registration failed", "registrationFailed": "Registration failed",
"passwordsDoNotMatch": "Passwords do not match", "passwordsDoNotMatch": "Passwords do not match",
"verificationResendHint": "Didn’t get the email? You can resend the verification link after the countdown finishes.",
"resendVerificationLink": "Resend verification link",
"resendVerificationLinkIn": "Resend verification link in {{seconds}}s",
"verificationLinkSent": "Verification link sent. Please check your email.",
"myWorkspace": "My Workspace", "myWorkspace": "My Workspace",
"adminDashboard": "Admin Dashboard", "adminDashboard": "Admin Dashboard",
"userMenu": "User menu", "userMenu": "User menu",
+4
View File
@@ -62,6 +62,10 @@
"loginFailed": "Inloggen mislukt", "loginFailed": "Inloggen mislukt",
"registrationFailed": "Registratie mislukt", "registrationFailed": "Registratie mislukt",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen", "passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"verificationResendHint": "Geen e-mail ontvangen? Je kunt de verificatielink opnieuw versturen zodra de aftelling voorbij is.",
"resendVerificationLink": "Verificatielink opnieuw versturen",
"resendVerificationLinkIn": "Verificatielink opnieuw versturen over {{seconds}}s",
"verificationLinkSent": "Verificatielink verzonden. Controleer je e-mail.",
"myWorkspace": "Mijn Werkruimte", "myWorkspace": "Mijn Werkruimte",
"adminDashboard": "Admin Dashboard", "adminDashboard": "Admin Dashboard",
"userMenu": "Gebruikersmenu", "userMenu": "Gebruikersmenu",