From 9e2fc050bed902b0051ecd054bd034a2523af601 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 00:48:32 -0400 Subject: [PATCH] fix production login issue --- .../src/components/layout/TopBar.tsx | 9 +++--- apps/dashboard/src/lib/api.test.ts | 28 +++++++++++++++++++ apps/dashboard/src/lib/api.ts | 14 ++++++++++ apps/dashboard/src/next-config.test.ts | 12 ++++++++ config/nextSecurityHeaders.js | 3 ++ docker-compose.production.yml | 8 ++++++ production/docker-compose.production.yml | 8 ++++++ 7 files changed, 78 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/components/layout/TopBar.tsx b/apps/dashboard/src/components/layout/TopBar.tsx index 2c49cb1..4833449 100644 --- a/apps/dashboard/src/components/layout/TopBar.tsx +++ b/apps/dashboard/src/components/layout/TopBar.tsx @@ -5,7 +5,7 @@ import { Bell, Search, Settings } from 'lucide-react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useState, useEffect } from 'react' import { io } from 'socket.io-client' -import { EMPLOYEE_PROFILE_KEY, apiFetch, resolveApiOrigin } from '@/lib/api' +import { EMPLOYEE_PROFILE_KEY, apiFetch, resolveRealtimeSocketTarget } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' import { toDashboardAppPath } from '@/lib/dashboardPaths' @@ -76,11 +76,12 @@ export default function TopBar() { useEffect(() => { if (!socketEnabled) return - const socketOrigin = resolveApiOrigin() - if (!socketOrigin) return + const socketTarget = resolveRealtimeSocketTarget() + if (!socketTarget) return - const socket = io(socketOrigin, { + const socket = io(socketTarget.origin, { autoConnect: false, + path: socketTarget.path, withCredentials: true, reconnectionAttempts: 3, timeout: 5000, diff --git a/apps/dashboard/src/lib/api.test.ts b/apps/dashboard/src/lib/api.test.ts index abc3082..8193ffc 100644 --- a/apps/dashboard/src/lib/api.test.ts +++ b/apps/dashboard/src/lib/api.test.ts @@ -167,6 +167,34 @@ describe('dashboard apiFetch', () => { expect(api.resolveApiOrigin()).toBe('http://localhost:4000') }) + it('routes proxied realtime connections through the dashboard socket path', async () => { + installBrowser() + setBrowserHostname('rentaldrivego.ma') + ;(globalThis.window as any).location.origin = 'https://rentaldrivego.ma' + process.env.NEXT_PUBLIC_API_URL = 'https://api.rentaldrivego.ma/api/v1' + + const api = await import('./api') + + expect(api.resolveRealtimeSocketTarget()).toEqual({ + origin: 'https://rentaldrivego.ma', + path: '/dashboard/socket.io', + }) + }) + + it('keeps direct realtime connections on the default socket path for same-host API bases', async () => { + installBrowser() + setBrowserHostname('rentaldrivego.ma') + ;(globalThis.window as any).location.origin = 'https://rentaldrivego.ma' + process.env.NEXT_PUBLIC_API_URL = 'https://rentaldrivego.ma/api/v1' + + const api = await import('./api') + + expect(api.resolveRealtimeSocketTarget()).toEqual({ + origin: 'https://rentaldrivego.ma', + path: '/socket.io', + }) + }) + it('does not force JSON content type for FormData payloads', async () => { installBrowser() const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) })) diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 9327bf4..892aee2 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -44,6 +44,20 @@ export function resolveApiOrigin(): string | null { } } +export function resolveRealtimeSocketTarget(): { origin: string; path: string } | null { + if (typeof window === 'undefined') return null + + const apiBase = resolveApiBase() + const isDashboardProxy = apiBase === DASHBOARD_PROXY_API_BASE || apiBase.startsWith(`${DASHBOARD_PROXY_API_BASE}/`) + const origin = isDashboardProxy ? window.location.origin : resolveApiOrigin() + if (!origin) return null + + return { + origin, + path: isDashboardProxy ? '/dashboard/socket.io' : '/socket.io', + } +} + export const API_BASE = resolveApiBase() export const EMPLOYEE_PROFILE_KEY = 'employee_profile' diff --git a/apps/dashboard/src/next-config.test.ts b/apps/dashboard/src/next-config.test.ts index a2d4ccc..57a9b84 100644 --- a/apps/dashboard/src/next-config.test.ts +++ b/apps/dashboard/src/next-config.test.ts @@ -39,4 +39,16 @@ describe('dashboard next config', () => { ]), ) }) + + it('allows non-production websocket connections for local Next runtimes', async () => { + const nextConfig = require('../next.config.js') + + const headers = await nextConfig.headers() + const csp = headers + .flatMap((entry: { headers: Array<{ key: string; value: string }> }) => entry.headers) + .find((header: { key: string }) => header.key === 'Content-Security-Policy') + + expect(csp?.value).toContain('connect-src') + expect(csp?.value).toContain('ws:') + }) }) diff --git a/config/nextSecurityHeaders.js b/config/nextSecurityHeaders.js index fb25ea8..5c194a2 100644 --- a/config/nextSecurityHeaders.js +++ b/config/nextSecurityHeaders.js @@ -76,6 +76,9 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows const imgSrc = [...new Set(["'self'", 'data:', 'blob:', 'https:', ...assetOrigins, ...connectOrigins])] const fontSrc = ["'self'", 'data:', ...assetOrigins] const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])] + if (process.env.NODE_ENV !== 'production') { + connectSrc.push('ws:') + } return [ { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 54b7175..99b546d 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -128,6 +128,14 @@ services: - traefik.http.routers.api.tls.certresolver=letsencrypt - traefik.http.services.api.loadbalancer.server.port=4000 - traefik.http.routers.api.middlewares=rdg-security-headers@docker + - traefik.http.routers.dashboard-socket.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/dashboard/socket.io`) && !HeaderRegexp(`x-middleware-subrequest`, `.+`) + - traefik.http.routers.dashboard-socket.entrypoints=websecure + - traefik.http.routers.dashboard-socket.tls=true + - traefik.http.routers.dashboard-socket.tls.certresolver=letsencrypt + - traefik.http.routers.dashboard-socket.service=api + - traefik.http.routers.dashboard-socket.priority=30 + - traefik.http.routers.dashboard-socket.middlewares=dashboard-socket-strip@docker,rdg-security-headers@docker + - traefik.http.middlewares.dashboard-socket-strip.stripprefix.prefixes=/dashboard - traefik.http.middlewares.rdg-security-headers.headers.stsSeconds=31536000 - traefik.http.middlewares.rdg-security-headers.headers.stsIncludeSubdomains=true - traefik.http.middlewares.rdg-security-headers.headers.contentTypeNosniff=true diff --git a/production/docker-compose.production.yml b/production/docker-compose.production.yml index 54b7175..99b546d 100644 --- a/production/docker-compose.production.yml +++ b/production/docker-compose.production.yml @@ -128,6 +128,14 @@ services: - traefik.http.routers.api.tls.certresolver=letsencrypt - traefik.http.services.api.loadbalancer.server.port=4000 - traefik.http.routers.api.middlewares=rdg-security-headers@docker + - traefik.http.routers.dashboard-socket.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/dashboard/socket.io`) && !HeaderRegexp(`x-middleware-subrequest`, `.+`) + - traefik.http.routers.dashboard-socket.entrypoints=websecure + - traefik.http.routers.dashboard-socket.tls=true + - traefik.http.routers.dashboard-socket.tls.certresolver=letsencrypt + - traefik.http.routers.dashboard-socket.service=api + - traefik.http.routers.dashboard-socket.priority=30 + - traefik.http.routers.dashboard-socket.middlewares=dashboard-socket-strip@docker,rdg-security-headers@docker + - traefik.http.middlewares.dashboard-socket-strip.stripprefix.prefixes=/dashboard - traefik.http.middlewares.rdg-security-headers.headers.stsSeconds=31536000 - traefik.http.middlewares.rdg-security-headers.headers.stsIncludeSubdomains=true - traefik.http.middlewares.rdg-security-headers.headers.contentTypeNosniff=true