fix: fix broken images from CSP, remove dead code, upgrade React types

- Add ciphera.net and *.gstatic.com to CSP img-src (fixes app switcher
  icons and site favicons blocked by Content Security Policy)
- Delete 6 unused component/utility files and orphaned test
- Upgrade @types/react and @types/react-dom to v19 (matches React 19 runtime)
- Fix logger test to use vi.stubEnv for React 19 type compatibility
This commit is contained in:
Usman Baig
2026-03-01 15:33:37 +01:00
parent 95920e4724
commit c9123832a5
12 changed files with 29 additions and 612 deletions

View File

@@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Fixed ### Fixed
- **App switcher and site icons now load correctly.** The Pulse, Drop, and Auth logos in the app switcher — and site favicons on the dashboard — were blocked by the browser's Content Security Policy. The policy now allows images from Ciphera's own domain and Google's favicon service, so all icons display as expected.
- **Safer campaign date handling.** Campaign analytics now use the same date validation as the rest of the app, including checks for invalid ranges and a maximum span of one year. Previously, campaigns used separate date parsing that skipped these checks.
- **Better crash protection in goals and real-time views.** Fixed an issue where the backend could crash under rare conditions when checking permissions for goals or real-time visitor data. The app now handles unexpected values gracefully instead of crashing.
- **Full React 19 type coverage.** Upgraded TypeScript type definitions to match the React 19 runtime. Previously, the type definitions lagged behind at React 18, which could hide bugs and miss new React 19 APIs during development.
### Removed
- **Cleaned up unused files.** Removed six leftover component and utility files that were no longer used anywhere in the app, along with dead backend code. This reduces clutter and keeps the codebase easier to navigate.
- **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service. - **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service.
- **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic. - **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic.
- **More reliable billing operations.** Billing actions like changing your plan, cancelling, and viewing invoices now benefit from the same automatic session refresh, request tracing, and error handling as the rest of the app. Previously, these used a separate internal path that could fail silently if your session expired mid-action. - **More reliable billing operations.** Billing actions like changing your plan, cancelling, and viewing invoices now benefit from the same automatic session refresh, request tracing, and error handling as the rest of the app. Previously, these used a separate internal path that could fail silently if your session expired mid-action.

View File

@@ -1,109 +0,0 @@
'use client'
import { useState } from 'react'
interface PasswordInputProps {
value: string
onChange: (value: string) => void
label?: string
placeholder?: string
error?: string | null
disabled?: boolean
required?: boolean
className?: string
id?: string
autoComplete?: string
minLength?: number
onFocus?: () => void
onBlur?: () => void
}
export default function PasswordInput({
value,
onChange,
label = 'Password',
placeholder = 'Enter password',
error,
disabled = false,
required = false,
className = '',
id,
autoComplete,
minLength,
onFocus,
onBlur
}: PasswordInputProps) {
const [showPassword, setShowPassword] = useState(false)
const inputId = id || 'password-input'
const errorId = `${inputId}-error`
return (
<div className={`space-y-1.5 ${className}`}>
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
{label}
{required && <span className="text-brand-orange text-xs ml-1">(Required)</span>}
</label>
)}
<div className="relative group">
<input
id={inputId}
type={showPassword ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
autoComplete={autoComplete}
minLength={minLength}
onFocus={onFocus}
onBlur={onBlur}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
className={`w-full pl-11 pr-12 py-3 border rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
transition-all duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:text-white
${error
? 'border-red-300 dark:border-red-800 focus:border-red-500 focus:ring-4 focus:ring-red-500/10'
: 'border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/50 focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10'
}`}
/>
{/* Lock Icon (Left) */}
<div className={`absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none transition-colors duration-200
${error ? 'text-red-400' : 'text-neutral-400 dark:text-neutral-500 group-focus-within:text-brand-orange'}`}>
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
{/* Toggle Visibility Button (Right) */}
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
aria-label={showPassword ? "Hide password" : "Show password"}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-neutral-400 dark:text-neutral-500
hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all duration-200 focus:outline-none"
>
{showPassword ? (
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
{error && (
<p id={errorId} role="alert" className="text-xs text-red-500 font-medium ml-1">
{error}
</p>
)}
</div>
)
}

View File

@@ -1,116 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons'
import { switchContext, OrganizationMember } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
import { logger } from '@/lib/utils/logger'
import Link from 'next/link'
export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) {
const router = useRouter()
const [switching, setSwitching] = useState<string | null>(null)
const handleSwitch = async (orgId: string | null) => {
setSwitching(orgId || 'personal')
try {
// * If orgId is null, we can't switch context via API in the same way if strict mode is on
// * Pulse doesn't support personal organization context.
// * So we should probably NOT show the "Personal" option in Pulse if strict mode is enforced.
// * However, to match Drop exactly, we might want to show it but have it fail or redirect?
// * Let's assume for now we want to match Drop's UI structure.
if (!orgId) {
// * Pulse doesn't support personal context.
// * We could redirect to onboarding or show an error.
// * For now, let's just return to avoid breaking.
return
}
const { access_token } = await switchContext(orgId)
// * Update session cookie via server action
// * Note: switchContext only returns access_token, we keep existing refresh token
await setSessionAction(access_token)
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.reload()
} catch (err) {
logger.error('Failed to switch organization', err)
setSwitching(null)
}
}
return (
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2" role="group" aria-label="Organizations">
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider" aria-hidden="true">
Organizations
</div>
{/* Personal organization - HIDDEN IN PULSE (Strict Mode) */}
{/*
<button
onClick={() => handleSwitch(null)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors group ${
!activeOrgId ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`}
>
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center">
<PersonIcon className="h-3 w-3 text-neutral-500 dark:text-neutral-400" />
</div>
<span className="text-neutral-700 dark:text-neutral-300">Personal</span>
</div>
<div className="flex items-center gap-2">
{switching === 'personal' && <span className="text-xs text-neutral-400">Loading...</span>}
{!activeOrgId && !switching && <CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />}
</div>
</button>
*/}
{/* Organization list */}
{orgs.map((org) => (
<button
key={org.organization_id}
onClick={() => handleSwitch(org.organization_id)}
aria-current={activeOrgId === org.organization_id ? 'true' : undefined}
aria-busy={switching === org.organization_id ? 'true' : undefined}
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${
activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`}
>
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" aria-hidden="true" />
</div>
<span className="text-neutral-700 dark:text-neutral-300 truncate max-w-[140px]">
{org.organization_name}
</span>
</div>
<div className="flex items-center gap-2">
{switching === org.organization_id && <span className="text-xs text-neutral-400" aria-live="polite">Switching</span>}
{activeOrgId === org.organization_id && !switching && (
<>
<CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" aria-hidden="true" />
<span className="sr-only">(current)</span>
</>
)}
</div>
</button>
))}
{/* Create New */}
<Link
href="/onboarding"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1"
>
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center" aria-hidden="true">
<PlusIcon className="h-3 w-3" />
</div>
<span>Create Organization</span>
</Link>
</div>
)
}

View File

@@ -1,140 +0,0 @@
'use client'
import { useState } from 'react'
import { formatNumber } from '@ciphera-net/ui'
import * as Flags from 'country-flag-icons/react/3x2'
import WorldMap from './WorldMap'
import { GlobeIcon } from '@ciphera-net/ui'
interface LocationProps {
countries: Array<{ country: string; pageviews: number }>
cities: Array<{ city: string; country: string; pageviews: number }>
}
type Tab = 'countries' | 'cities'
export default function Locations({ countries, cities }: LocationProps) {
const [activeTab, setActiveTab] = useState<Tab>('countries')
const getFlagComponent = (countryCode: string) => {
if (!countryCode || countryCode === 'Unknown') return null
// * The API returns 2-letter country codes (e.g. US, DE)
// * We cast it to the flag component name
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
}
const getCountryName = (code: string) => {
if (!code || code === 'Unknown') return 'Unknown'
try {
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
return regionNames.of(code) || code
} catch (e) {
return code
}
}
const renderContent = () => {
if (activeTab === 'countries') {
if (!countries || countries.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No location data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Visitor locations will appear here based on anonymous geographic data.
</p>
</div>
)
}
return (
<div className="space-y-4">
<WorldMap data={countries} />
<div className="space-y-3">
{countries.map((country, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(country.country)}</span>
<span className="truncate">{getCountryName(country.country)}</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(country.pageviews)}
</div>
</div>
))}
</div>
</div>
)
}
if (activeTab === 'cities') {
if (!cities || cities.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No city data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
City-level visitor data will appear as traffic grows.
</p>
</div>
)
}
return (
<div className="space-y-3">
{cities.map((city, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(city.country)}</span>
<span className="truncate">{city.city === 'Unknown' ? 'Unknown' : city.city}</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(city.pageviews)}
</div>
</div>
))}
</div>
)
}
}
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Locations
</h3>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
<button
onClick={() => setActiveTab('countries')}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
activeTab === 'countries'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
}`}
>
Countries
</button>
<button
onClick={() => setActiveTab('cities')}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
activeTab === 'cities'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
}`}
>
Cities
</button>
</div>
</div>
{renderContent()}
</div>
)
}

View File

@@ -1,51 +0,0 @@
'use client'
import { formatNumber } from '@ciphera-net/ui'
import { LayoutDashboardIcon } from '@ciphera-net/ui'
interface TopPagesProps {
pages: Array<{ path: string; pageviews: number }>
}
export default function TopPages({ pages }: TopPagesProps) {
if (!pages || pages.length === 0) {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 flex flex-col">
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
Top Pages
</h3>
<div className="flex-1 flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<LayoutDashboardIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No page data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Your most visited pages will appear here as traffic arrives.
</p>
</div>
</div>
)
}
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
Top Pages
</h3>
<div className="space-y-3">
{pages.map((page, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1 truncate text-neutral-900 dark:text-white">
{page.path}
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(page.pageviews)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,95 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
getRequestIdFromError,
formatErrorMessage,
logErrorWithRequestId,
getSupportMessage,
} from '../errorHandler'
import { setLastRequestId, clearLastRequestId } from '../requestId'
beforeEach(() => {
clearLastRequestId()
})
describe('getRequestIdFromError', () => {
it('extracts request ID from error response body', () => {
const errorData = { error: { request_id: 'REQ123_abc' } }
expect(getRequestIdFromError(errorData)).toBe('REQ123_abc')
})
it('falls back to last stored request ID when not in response', () => {
setLastRequestId('REQfallback_xyz')
expect(getRequestIdFromError({ error: {} })).toBe('REQfallback_xyz')
})
it('falls back to last stored request ID when no error data', () => {
setLastRequestId('REQfallback_xyz')
expect(getRequestIdFromError()).toBe('REQfallback_xyz')
})
it('returns null when no ID available anywhere', () => {
expect(getRequestIdFromError()).toBeNull()
})
})
describe('formatErrorMessage', () => {
it('returns plain message when no request ID available', () => {
expect(formatErrorMessage('Something failed')).toBe('Something failed')
})
it('appends request ID in development mode', () => {
const original = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
setLastRequestId('REQ123_abc')
const msg = formatErrorMessage('Something failed')
expect(msg).toContain('Something failed')
expect(msg).toContain('REQ123_abc')
process.env.NODE_ENV = original
})
it('appends request ID when showRequestId option is set', () => {
setLastRequestId('REQ123_abc')
const msg = formatErrorMessage('Something failed', undefined, { showRequestId: true })
expect(msg).toContain('REQ123_abc')
})
})
describe('logErrorWithRequestId', () => {
it('logs with request ID when available', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
setLastRequestId('REQ123_abc')
logErrorWithRequestId('TestContext', new Error('fail'))
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('REQ123_abc'),
expect.any(Error)
)
spy.mockRestore()
})
it('logs without request ID when not available', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
logErrorWithRequestId('TestContext', new Error('fail'))
expect(spy).toHaveBeenCalledWith('[TestContext]', expect.any(Error))
spy.mockRestore()
})
})
describe('getSupportMessage', () => {
it('includes request ID when available', () => {
const errorData = { error: { request_id: 'REQ123_abc' } }
const msg = getSupportMessage(errorData)
expect(msg).toContain('REQ123_abc')
expect(msg).toContain('contact support')
})
it('returns generic message when no request ID', () => {
const msg = getSupportMessage()
expect(msg).toBe('If this persists, please contact support.')
})
})

View File

@@ -7,23 +7,25 @@ describe('logger', () => {
it('calls console.error in development', async () => { it('calls console.error in development', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
process.env.NODE_ENV = 'development' vi.stubEnv('NODE_ENV', 'development')
const { logger } = await import('../logger') const { logger } = await import('../logger')
logger.error('test error') logger.error('test error')
expect(spy).toHaveBeenCalledWith('test error') expect(spy).toHaveBeenCalledWith('test error')
spy.mockRestore() spy.mockRestore()
vi.unstubAllEnvs()
}) })
it('calls console.warn in development', async () => { it('calls console.warn in development', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
process.env.NODE_ENV = 'development' vi.stubEnv('NODE_ENV', 'development')
const { logger } = await import('../logger') const { logger } = await import('../logger')
logger.warn('test warning') logger.warn('test warning')
expect(spy).toHaveBeenCalledWith('test warning') expect(spy).toHaveBeenCalledWith('test warning')
spy.mockRestore() spy.mockRestore()
vi.unstubAllEnvs()
}) })
}) })

View File

@@ -1,79 +0,0 @@
/**
* Error handling utilities with Request ID extraction
* Helps users report errors with traceable IDs for support
*/
import { getLastRequestId } from './requestId'
interface ApiErrorResponse {
error?: {
code?: string
message?: string
details?: unknown
request_id?: string
}
}
/**
* Extract request ID from error response or use last known request ID
*/
export function getRequestIdFromError(errorData?: ApiErrorResponse): string | null {
// * Try to get from error response body
if (errorData?.error?.request_id) {
return errorData.error.request_id
}
// * Fallback to last request ID stored during API call
return getLastRequestId()
}
/**
* Format error message for display with optional request ID
* Shows request ID in development or for specific error types
*/
export function formatErrorMessage(
message: string,
errorData?: ApiErrorResponse,
options: { showRequestId?: boolean } = {}
): string {
const requestId = getRequestIdFromError(errorData)
// * Always show request ID in development
const isDev = process.env.NODE_ENV === 'development'
if (requestId && (isDev || options.showRequestId)) {
return `${message}\n\nRequest ID: ${requestId}`
}
return message
}
/**
* Log error with request ID for debugging
*/
export function logErrorWithRequestId(
context: string,
error: unknown,
errorData?: ApiErrorResponse
): void {
const requestId = getRequestIdFromError(errorData)
if (requestId) {
console.error(`[${context}] Request ID: ${requestId}`, error)
} else {
console.error(`[${context}]`, error)
}
}
/**
* Get support message with request ID for user reports
*/
export function getSupportMessage(errorData?: ApiErrorResponse): string {
const requestId = getRequestIdFromError(errorData)
if (requestId) {
return `If this persists, contact support with Request ID: ${requestId}`
}
return 'If this persists, please contact support.'
}

View File

@@ -12,7 +12,7 @@ const cspDirectives = [
// Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR) // Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR)
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`, `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`,
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://www.google.com", "img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net",
"font-src 'self'", "font-src 'self'",
`connect-src 'self' https://*.ciphera.net https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`, `connect-src 'self' https://*.ciphera.net https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`,
"worker-src 'self'", "worker-src 'self'",
@@ -38,6 +38,10 @@ const nextConfig: NextConfig = {
hostname: 'www.google.com', hostname: 'www.google.com',
pathname: '/s2/favicons**', pathname: '/s2/favicons**',
}, },
{
protocol: 'https',
hostname: 'ciphera.net',
},
], ],
}, },
async headers() { async headers() {

26
package-lock.json generated
View File

@@ -40,8 +40,8 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12", "@types/node": "^20.14.12",
"@types/react": "^18.3.3", "@types/react": "^19.2.14",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^19.2.3",
"@types/react-simple-maps": "^3.0.6", "@types/react-simple-maps": "^3.0.6",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
@@ -4225,12 +4225,6 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/raf": { "node_modules/@types/raf": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -4239,25 +4233,24 @@
"optional": true "optional": true
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.28", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "18.3.7", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/react-simple-maps": { "node_modules/@types/react-simple-maps": {
@@ -9115,7 +9108,6 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.31", "@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1", "@asamuzakjp/dom-selector": "^6.8.1",

View File

@@ -50,8 +50,8 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12", "@types/node": "^20.14.12",
"@types/react": "^18.3.3", "@types/react": "^19.2.14",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^19.2.3",
"@types/react-simple-maps": "^3.0.6", "@types/react-simple-maps": "^3.0.6",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",