From c9123832a5cca05f7796ba8f5c0c701bf546ad65 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 15:33:37 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 9 ++ components/PasswordInput.tsx | 109 ------------------ components/WebsiteFooter.tsx | 0 components/WorkspaceSwitcher.tsx | 116 ------------------- components/dashboard/Countries.tsx | 140 ----------------------- components/dashboard/TopPages.tsx | 51 --------- lib/utils/__tests__/errorHandler.test.ts | 95 --------------- lib/utils/__tests__/logger.test.ts | 6 +- lib/utils/errorHandler.ts | 79 ------------- next.config.ts | 6 +- package-lock.json | 26 ++--- package.json | 4 +- 12 files changed, 29 insertions(+), 612 deletions(-) delete mode 100644 components/PasswordInput.tsx delete mode 100644 components/WebsiteFooter.tsx delete mode 100644 components/WorkspaceSwitcher.tsx delete mode 100644 components/dashboard/Countries.tsx delete mode 100644 components/dashboard/TopPages.tsx delete mode 100644 lib/utils/__tests__/errorHandler.test.ts delete mode 100644 lib/utils/errorHandler.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6d634..fb52c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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. - **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. diff --git a/components/PasswordInput.tsx b/components/PasswordInput.tsx deleted file mode 100644 index a92ef38..0000000 --- a/components/PasswordInput.tsx +++ /dev/null @@ -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 ( -
- {label && ( - - )} -
- 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) */} -
- -
- - {/* Toggle Visibility Button (Right) */} - -
- {error && ( - - )} -
- ) -} diff --git a/components/WebsiteFooter.tsx b/components/WebsiteFooter.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/components/WorkspaceSwitcher.tsx b/components/WorkspaceSwitcher.tsx deleted file mode 100644 index 6d8ff26..0000000 --- a/components/WorkspaceSwitcher.tsx +++ /dev/null @@ -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(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 ( -
- - - {/* Personal organization - HIDDEN IN PULSE (Strict Mode) */} - {/* - - */} - - {/* Organization list */} - {orgs.map((org) => ( - - ))} - - {/* Create New */} - - - Create Organization - -
- ) -} diff --git a/components/dashboard/Countries.tsx b/components/dashboard/Countries.tsx deleted file mode 100644 index 21688b9..0000000 --- a/components/dashboard/Countries.tsx +++ /dev/null @@ -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('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>)[countryCode] - return FlagComponent ? : 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 ( -
-
- -
-

- No location data yet -

-

- Visitor locations will appear here based on anonymous geographic data. -

-
- ) - } - return ( -
- -
- {countries.map((country, index) => ( -
-
- {getFlagComponent(country.country)} - {getCountryName(country.country)} -
-
- {formatNumber(country.pageviews)} -
-
- ))} -
-
- ) - } - - if (activeTab === 'cities') { - if (!cities || cities.length === 0) { - return ( -
-
- -
-

- No city data yet -

-

- City-level visitor data will appear as traffic grows. -

-
- ) - } - return ( -
- {cities.map((city, index) => ( -
-
- {getFlagComponent(city.country)} - {city.city === 'Unknown' ? 'Unknown' : city.city} -
-
- {formatNumber(city.pageviews)} -
-
- ))} -
- ) - } - } - - return ( -
-
-

- Locations -

-
- - -
-
- {renderContent()} -
- ) -} diff --git a/components/dashboard/TopPages.tsx b/components/dashboard/TopPages.tsx deleted file mode 100644 index 6976105..0000000 --- a/components/dashboard/TopPages.tsx +++ /dev/null @@ -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 ( -
-

- Top Pages -

-
-
- -
-

- No page data yet -

-

- Your most visited pages will appear here as traffic arrives. -

-
-
- ) - } - - return ( -
-

- Top Pages -

-
- {pages.map((page, index) => ( -
-
- {page.path} -
-
- {formatNumber(page.pageviews)} -
-
- ))} -
-
- ) -} diff --git a/lib/utils/__tests__/errorHandler.test.ts b/lib/utils/__tests__/errorHandler.test.ts deleted file mode 100644 index 0e5e4f6..0000000 --- a/lib/utils/__tests__/errorHandler.test.ts +++ /dev/null @@ -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.') - }) -}) diff --git a/lib/utils/__tests__/logger.test.ts b/lib/utils/__tests__/logger.test.ts index 4092a63..907b5b9 100644 --- a/lib/utils/__tests__/logger.test.ts +++ b/lib/utils/__tests__/logger.test.ts @@ -7,23 +7,25 @@ describe('logger', () => { it('calls console.error in development', async () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - process.env.NODE_ENV = 'development' + vi.stubEnv('NODE_ENV', 'development') const { logger } = await import('../logger') logger.error('test error') expect(spy).toHaveBeenCalledWith('test error') spy.mockRestore() + vi.unstubAllEnvs() }) it('calls console.warn in development', async () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - process.env.NODE_ENV = 'development' + vi.stubEnv('NODE_ENV', 'development') const { logger } = await import('../logger') logger.warn('test warning') expect(spy).toHaveBeenCalledWith('test warning') spy.mockRestore() + vi.unstubAllEnvs() }) }) diff --git a/lib/utils/errorHandler.ts b/lib/utils/errorHandler.ts deleted file mode 100644 index ee4a15b..0000000 --- a/lib/utils/errorHandler.ts +++ /dev/null @@ -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.' -} diff --git a/next.config.ts b/next.config.ts index c111fc8..694203d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,7 +12,7 @@ const cspDirectives = [ // 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'" : ''}`, "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'", `connect-src 'self' https://*.ciphera.net https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`, "worker-src 'self'", @@ -38,6 +38,10 @@ const nextConfig: NextConfig = { hostname: 'www.google.com', pathname: '/s2/favicons**', }, + { + protocol: 'https', + hostname: 'ciphera.net', + }, ], }, async headers() { diff --git a/package-lock.json b/package-lock.json index 7e8f495..2803960 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,8 +40,8 @@ "@testing-library/react": "^16.3.2", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/react-simple-maps": "^3.0.6", "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.19", @@ -4225,12 +4225,6 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "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": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -4239,25 +4233,24 @@ "optional": true }, "node_modules/@types/react": { - "version": "18.3.28", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", - "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "peer": true, "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peer": true, "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-simple-maps": { @@ -9115,7 +9108,6 @@ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", diff --git a/package.json b/package.json index 85611e6..992a20e 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "@testing-library/react": "^16.3.2", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/react-simple-maps": "^3.0.6", "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.19",