fix: close issues #12 #15 #17 #18 — CSP nonce, API retry, input debounce, caching verified (batch 0.6.1)

This commit is contained in:
null 2026-05-17 16:10:10 -05:00
parent ca67974c5f
commit 56bdf07216
7 changed files with 134 additions and 58 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "queuenorth-website", "name": "queuenorth-website",
"private": true, "private": true,
"version": "0.6.0", "version": "0.6.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"vite\" \"node server/index.js\"", "dev": "concurrently \"vite\" \"node server/index.js\"",

View File

@ -66,7 +66,7 @@ const apiLimiter = rateLimit({
const cspDirectives = { const cspDirectives = {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'"], scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], styleSrc: ["'self'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:'], imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"], connectSrc: ["'self'"],

View File

@ -110,7 +110,7 @@ const Header = () => {
<ul className="space-y-2"> <ul className="space-y-2">
{navLinks.map((link) => ( {navLinks.map((link) => (
<li key={link.name}> <li key={link.name}>
<Link to={link.href} onClick={closeMobileMenu} className={`block text-base font-medium py-2 ${isActive(link.href) ? 'text-white' : 'text-white/70 hover:text-white'} transition-colors`}> <Link to={link.href} onClick={closeMobileMenu} className={`block text-base font-medium py-2 ${isActive(link.href) ? 'text-white font-semibold' : 'text-white/70 hover:text-white'} transition-colors`}>
{link.name} {link.name}
</Link> </Link>
</li> </li>
@ -119,11 +119,11 @@ const Header = () => {
</div> </div>
<div> <div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Services</h4> <h4 className="text-xs font-semibold uppercase tracking-wider text-white/60 mb-3">Services</h4>
<ul className="space-y-2"> <ul className="space-y-2">
{serviceLinks.map((service) => ( {serviceLinks.map((service) => (
<li key={service.name}> <li key={service.name}>
<Link to={service.href} onClick={closeMobileMenu} className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(service.href) ? 'text-white font-semibold' : 'text-navy-light hover:text-white'}`}> <Link to={service.href} onClick={closeMobileMenu} className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(service.href) ? 'text-white font-semibold' : 'text-white/70 hover:text-white'}`}>
{service.name} {service.name}
</Link> </Link>
</li> </li>
@ -132,11 +132,11 @@ const Header = () => {
</div> </div>
<div> <div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Industries</h4> <h4 className="text-xs font-semibold uppercase tracking-wider text-white/60 mb-3">Industries</h4>
<ul className="space-y-2"> <ul className="space-y-2">
{industryLinks.map((industry) => ( {industryLinks.map((industry) => (
<li key={industry.name}> <li key={industry.name}>
<Link to={industry.href} onClick={closeMobileMenu} className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(industry.href) ? 'text-white font-semibold' : 'text-navy-light hover:text-white'}`}> <Link to={industry.href} onClick={closeMobileMenu} className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(industry.href) ? 'text-white font-semibold' : 'text-white/70 hover:text-white'}`}>
{industry.name} {industry.name}
</Link> </Link>
</li> </li>

25
src/hooks/useDebounce.js Normal file
View File

@ -0,0 +1,25 @@
import { useState, useEffect } from 'react'
/**
* Debounce hook - delays updating the value until after the specified delay
* @param {any} value - The value to debounce
* @param {number} delay - The debounce delay in milliseconds
* @returns {any} - The debounced value
*/
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}
export default useDebounce

View File

@ -2,49 +2,94 @@ import { queryClient } from './queryClient'
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
// Exponential backoff retry helper
const retryFetch = async (fn, { maxRetries = 3, baseDelay = 1000 } = {}) => {
let lastError
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (err) {
lastError = err
// Don't retry on client errors (except 429), only server errors and network failures
if (err instanceof TypeError && err.message === 'Failed to fetch') {
// Network failure - retry
} else if (err.response && err.response.status >= 500) {
// 5xx server error - retry
} else if (err.response && err.response.status === 429) {
// 429 Too Many Requests - check Retry-After header
const retryAfter = err.response.headers.get('Retry-After')
if (retryAfter) {
const delay = parseInt(retryAfter, 10) * 1000
await new Promise(resolve => setTimeout(resolve, delay))
continue
}
} else {
// Other errors (4xx except 429) - don't retry
throw err
}
// Wait with exponential backoff before retry
if (attempt < maxRetries) {
const delay = baseDelay * Math.pow(2, attempt)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
throw lastError
}
export const api = { export const api = {
get: async (endpoint) => { get: async (endpoint) => {
let response return await retryFetch(async () => {
try { let response
response = await fetch(`${API_BASE_URL}${endpoint}`) try {
} catch (err) { response = await fetch(`${API_BASE_URL}${endpoint}`)
if (err instanceof TypeError && err.message === 'Failed to fetch') { } catch (err) {
throw new Error('Unable to reach the server. This may be a network or CORS issue.') if (err instanceof TypeError && err.message === 'Failed to fetch') {
throw new Error('Unable to reach the server. This may be a network or CORS issue.')
}
throw new Error(`Network error: ${err.message}`)
} }
throw new Error(`Network error: ${err.message}`) if (!response.ok) {
} const errorData = new Error(`API error: ${response.statusText}`)
if (!response.ok) { errorData.response = { status: response.status, statusText: response.statusText }
throw new Error(`API error: ${response.statusText}`) throw errorData
} }
return response.json() return response.json()
}, { maxRetries: 3, baseDelay: 1000 })
}, },
post: async (endpoint, data) => { post: async (endpoint, data) => {
let response return await retryFetch(async () => {
try { let response
response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
} catch (err) {
if (err instanceof TypeError && err.message === 'Failed to fetch') {
throw new Error('Unable to reach the server. This may be a network or CORS issue.')
}
throw new Error(`Network error: ${err.message}`)
}
if (!response.ok) {
let errorData
try { try {
errorData = await response.json() response = await fetch(`${API_BASE_URL}${endpoint}`, {
} catch { method: 'POST',
throw new Error(`Server error (${response.status}): ${response.statusText}`) headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
} catch (err) {
if (err instanceof TypeError && err.message === 'Failed to fetch') {
throw new Error('Unable to reach the server. This may be a network or CORS issue.')
}
throw new Error(`Network error: ${err.message}`)
} }
throw new Error(errorData.error || `API error: ${response.statusText}`) if (!response.ok) {
} let errorData
return response.json() try {
errorData = await response.json()
} catch {
const err = new Error(`Server error (${response.status}): ${response.statusText}`)
err.response = { status: response.status, statusText: response.statusText }
throw err
}
const error = new Error(errorData.error || `API error: ${response.statusText}`)
error.response = { status: response.status }
throw error
}
return response.json()
}, { maxRetries: 3, baseDelay: 1000 })
}, },
} }

View File

@ -6,6 +6,7 @@ import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useDebounce } from '@/hooks/useDebounce'
const Contact = () => { const Contact = () => {
const [formState, setFormState] = useState({ const [formState, setFormState] = useState({
@ -23,6 +24,8 @@ const Contact = () => {
email: '', email: '',
message: '', message: '',
}) })
// Debounce validation errors so they don't flash on every keystroke
const debouncedErrors = useDebounce(errors, 300)
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data) => api.post('/leads', data), mutationFn: (data) => api.post('/leads', data),
@ -187,8 +190,8 @@ const Contact = () => {
placeholder="Your company name" placeholder="Your company name"
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''} className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.company && ( {debouncedErrors.company && (
<p className="text-xs text-red-600 mt-1">{errors.company}</p> <p className="text-xs text-red-600 mt-1">{debouncedErrors.company}</p>
)} )}
</div> </div>
@ -206,8 +209,8 @@ const Contact = () => {
placeholder="Your full name" placeholder="Your full name"
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''} className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.name && ( {debouncedErrors.name && (
<p className="text-xs text-red-600 mt-1">{errors.name}</p> <p className="text-xs text-red-600 mt-1">{debouncedErrors.name}</p>
)} )}
</div> </div>
@ -225,8 +228,8 @@ const Contact = () => {
placeholder="your.email@example.com" placeholder="your.email@example.com"
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''} className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.email && ( {debouncedErrors.email && (
<p className="text-xs text-red-600 mt-1">{errors.email}</p> <p className="text-xs text-red-600 mt-1">{debouncedErrors.email}</p>
)} )}
</div> </div>
@ -293,8 +296,8 @@ const Contact = () => {
rows={5} rows={5}
className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.message ? 'border-red-500 focus-visible:ring-red-500' : ''}`} className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.message ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
/> />
{errors.message && ( {debouncedErrors.message && (
<p className="text-xs text-red-600 mt-1">{errors.message}</p> <p className="text-xs text-red-600 mt-1">{debouncedErrors.message}</p>
)} )}
</div> </div>

View File

@ -6,6 +6,7 @@ import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useDebounce } from '@/hooks/useDebounce'
const Support = () => { const Support = () => {
const [formState, setFormState] = useState({ const [formState, setFormState] = useState({
@ -22,6 +23,8 @@ const Support = () => {
email: '', email: '',
issue: '', issue: '',
}) })
// Debounce validation errors so they don't flash on every keystroke
const debouncedErrors = useDebounce(errors, 300)
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data) => api.post('/support', data), mutationFn: (data) => api.post('/support', data),
@ -209,8 +212,8 @@ const Support = () => {
placeholder="Your full name" placeholder="Your full name"
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''} className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.name && ( {debouncedErrors.name && (
<p className="text-xs text-red-600 mt-1">{errors.name}</p> <p className="text-xs text-red-600 mt-1">{debouncedErrors.name}</p>
)} )}
</div> </div>
@ -228,8 +231,8 @@ const Support = () => {
placeholder="Your company name" placeholder="Your company name"
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''} className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.company && ( {debouncedErrors.company && (
<p className="text-xs text-red-600 mt-1">{errors.company}</p> <p className="text-xs text-red-600 mt-1">{debouncedErrors.company}</p>
)} )}
</div> </div>
@ -247,8 +250,8 @@ const Support = () => {
placeholder="your.email@example.com" placeholder="your.email@example.com"
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''} className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.email && ( {debouncedErrors.email && (
<p className="text-xs text-red-600 mt-1">{errors.email}</p> <p className="text-xs text-red-600 mt-1">{debouncedErrors.email}</p>
)} )}
</div> </div>
@ -296,8 +299,8 @@ const Support = () => {
rows={5} rows={5}
className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.issue ? 'border-red-500 focus-visible:ring-red-500' : ''}`} className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.issue ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
/> />
{errors.issue && ( {debouncedErrors.issue && (
<p className="text-xs text-red-600 mt-1">{errors.issue}</p> <p className="text-xs text-red-600 mt-1">{debouncedErrors.issue}</p>
)} )}
</div> </div>