Compare commits

...
3 Commits
Author SHA1 Message Date
LaurensBarteldsandCopilot 1a37209f72 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>
2026-05-02 21:03:56 +02:00
LaurensBartelds 25e590639a fix: update email verification endpoint and improve error handling in apiFetch 2026-05-02 20:58:31 +02:00
LaurensBartelds 7849c295ef feat: update password validation to accept hash as special character in registration and setup schemas
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 27s
2026-05-02 20:50:28 +02:00
12 changed files with 182 additions and 14 deletions

No files matched your search

+23 -2
View File
@@ -1,6 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import { validateBody } from '../middleware/validate.js' import { validateBody } from '../middleware/validate.js'
import { validateQuery } from '../middleware/validate.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
import { asyncHandler } from '../utils/asyncHandler.js' import { asyncHandler } from '../utils/asyncHandler.js'
import * as authService from '../services/authService.js' import * as authService from '../services/authService.js'
@@ -13,6 +14,7 @@ import {
updateProfileSchema, updateProfileSchema,
changePasswordSchema, changePasswordSchema,
verifyEmailSchema, verifyEmailSchema,
resendVerificationSchema,
resetPasswordSchema, resetPasswordSchema,
forgotPasswordSchema, forgotPasswordSchema,
} from '../validators/authSchemas.js' } from '../validators/authSchemas.js'
@@ -28,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',
@@ -86,13 +96,24 @@ router.post(
// GET /api/auth/verify // GET /api/auth/verify
router.get( router.get(
'/verify', '/verify',
validateBody(verifyEmailSchema), validateQuery(verifyEmailSchema),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
await authService.verifyEmail(req.body) await authService.verifyEmail(req.query as { token: string })
res.json({ message: 'Email verified successfully' }) res.json({ message: 'Email verified successfully' })
}) })
) )
// 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')
+17 -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', () => {
@@ -11,6 +11,15 @@ describe('registerSchema', () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
it('accepts passwords with hash as special character', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: 'Password123#',
displayName: 'Test User',
})
expect(result.success).toBe(true)
})
it('rejects invalid email', () => { it('rejects invalid email', () => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email: 'not-an-email', email: 'not-an-email',
@@ -63,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!' })
+9 -4
View File
@@ -1,12 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/
export const registerSchema = z.object({ export const registerSchema = z.object({
email: z.string().email().max(255), email: z.string().email().max(255),
password: z.string() password: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
displayName: z.string().min(1, 'Display name is required').max(100), displayName: z.string().min(1, 'Display name is required').max(100),
}) })
@@ -19,11 +19,15 @@ 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()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
}) })
export const updateProfileSchema = z.object({ export const updateProfileSchema = z.object({
@@ -34,7 +38,7 @@ export const changePasswordSchema = z.object({
currentPassword: z.string(), currentPassword: z.string(),
newPassword: z.string() newPassword: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
}) })
export const forgotPasswordSchema = z.object({ export const forgotPasswordSchema = z.object({
@@ -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>
@@ -13,6 +13,11 @@ describe('setupSchema', () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
it('accepts passwords with hash as special character', () => {
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass123#' })
expect(result.success).toBe(true)
})
it('accepts full setup including SMTP fields', () => { it('accepts full setup including SMTP fields', () => {
const result = setupSchema.safeParse({ const result = setupSchema.safeParse({
...baseValid, ...baseValid,
+2 -2
View File
@@ -1,12 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/
export const setupSchema = z.object({ export const setupSchema = z.object({
email: z.string().email().max(255), email: z.string().email().max(255),
password: z.string() password: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
displayName: z.string().min(1, 'Display name is required').max(100), displayName: z.string().min(1, 'Display name is required').max(100),
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(), siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
language: z.enum(['en', 'nl']).optional(), language: z.enum(['en', 'nl']).optional(),
+5 -1
View File
@@ -38,7 +38,11 @@ export function changePassword(data: { currentPassword: string; newPassword: str
} }
export function verifyEmailToken(token: string): Promise<{ message: string }> { export function verifyEmailToken(token: string): Promise<{ message: string }> {
return apiFetch(`/api/auth/verify-email?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 }> {
+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",
+22 -2
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi, afterEach } from 'vitest'
import { ApiError } from './api' import { apiFetch, ApiError } from './api'
afterEach(() => {
vi.restoreAllMocks()
})
describe('ApiError', () => { describe('ApiError', () => {
it('extends Error', () => { it('extends Error', () => {
@@ -18,3 +22,19 @@ describe('ApiError', () => {
expect(err.name).toBe('ApiError') expect(err.name).toBe('ApiError')
}) })
}) })
describe('apiFetch', () => {
it('uses message from error responses', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: vi.fn().mockResolvedValue({ message: 'Please verify your email before logging in' }),
}))
await expect(apiFetch('/api/auth/login')).rejects.toMatchObject({
status: 403,
message: 'Please verify your email before logging in',
})
})
})
+2 -1
View File
@@ -40,7 +40,8 @@ export async function apiFetch<T = unknown>(
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText })) const body = await res.json().catch(() => ({ error: res.statusText }))
throw new ApiError(res.status, (body as { error?: string }).error ?? 'Request failed') const parsedBody = body as { error?: string; message?: string }
throw new ApiError(res.status, parsedBody.error ?? parsedBody.message ?? 'Request failed')
} }
return res.json() as Promise<T> return res.json() as Promise<T>