From daa2fbb0c2b806ec771ffeed630cd89216692b86 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 25 Mar 2026 11:51:32 +0530 Subject: [PATCH] fix: SIP driven by Agent entity, token refresh, network indicator - SIP connection only for users with Agent entity (no env var fallback) - Supervisor no longer intercepts CC agent calls - Auth controller checks Agent entity for ALL roles, not just cc-agent - Token refresh handles GraphQL UNAUTHENTICATED errors (200 with error body) - Token refresh handles sidecar 400s from expired upstream tokens - Network quality indicator in sidebar (offline/unstable/good) - Ozonetel IDLE event mapped to ready state (fixes stuck calling after canceled call) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/sidebar.tsx | 23 ++++++++++++++++ src/hooks/use-network-status.ts | 46 +++++++++++++++++++++++++++++++ src/lib/api-client.ts | 32 ++++++++++++++++++++- src/providers/sip-provider.tsx | 39 ++++++++++++++------------ 4 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 src/hooks/use-network-status.ts diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 82f5be0..d7ee87b 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -13,6 +13,8 @@ import { faCalendarCheck, faPhone, faUsers, + faWifi, + faWifiSlash, faArrowRightFromBracket, faTowerBroadcast, faChartLine, @@ -32,6 +34,7 @@ import { Avatar } from "@/components/base/avatar/avatar"; import { apiClient } from "@/lib/api-client"; import { notify } from "@/lib/toast"; import { useAuth } from "@/providers/auth-provider"; +import { useNetworkStatus } from "@/hooks/use-network-status"; import { sidebarCollapsedAtom } from "@/state/sidebar-state"; import { cx } from "@/utils/cx"; @@ -123,6 +126,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => { const { logout, user } = useAuth(); const navigate = useNavigate(); const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom); + const networkQuality = useNetworkStatus(); const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; @@ -218,6 +222,25 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => { ))} + {/* Network indicator — only shows when network is degraded */} + {networkQuality !== 'good' && ( +
+ + {!collapsed && ( + {networkQuality === 'offline' ? 'No connection' : 'Unstable network'} + )} +
+ )} + {/* Account card */}
{collapsed ? ( diff --git a/src/hooks/use-network-status.ts b/src/hooks/use-network-status.ts new file mode 100644 index 0000000..e988f31 --- /dev/null +++ b/src/hooks/use-network-status.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useRef } from 'react'; + +export type NetworkQuality = 'good' | 'unstable' | 'offline'; + +export const useNetworkStatus = (): NetworkQuality => { + const [quality, setQuality] = useState(navigator.onLine ? 'good' : 'offline'); + const dropCountRef = useRef(0); + const resetTimerRef = useRef(null); + + useEffect(() => { + const handleOffline = () => { + console.log('[NETWORK] Offline'); + setQuality('offline'); + }; + + const handleOnline = () => { + console.log('[NETWORK] Back online'); + dropCountRef.current++; + + // 3+ drops in 2 minutes = unstable + if (dropCountRef.current >= 3) { + setQuality('unstable'); + } else { + setQuality('good'); + } + + // Reset drop counter after 2 minutes of stability + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + resetTimerRef.current = window.setTimeout(() => { + dropCountRef.current = 0; + if (navigator.onLine) setQuality('good'); + }, 120000); + }; + + window.addEventListener('offline', handleOffline); + window.addEventListener('online', handleOnline); + + return () => { + window.removeEventListener('offline', handleOffline); + window.removeEventListener('online', handleOnline); + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + }; + }, []); + + return quality; +}; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 3d44bee..83ec8c4 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -88,6 +88,21 @@ const handleResponse = async (response: Response, silent = false, retryFn?: ( const json = await response.json().catch(() => null); + // Sidecar may return 400 when the underlying platform token expired — retry with refreshed token + if (!response.ok && retryFn) { + const msg = (json?.message ?? '').toLowerCase(); + if (msg.includes('agent identity') || msg.includes('token') || msg.includes('unauthenticated')) { + const refreshed = await tryRefreshToken(); + if (refreshed) { + const retryResponse = await retryFn(); + return handleResponse(retryResponse, silent); + } + clearTokens(); + if (!silent) notify.error('Session expired. Please log in again.'); + throw new AuthError(); + } + } + if (!response.ok) { const message = json?.message ?? json?.error ?? `Request failed (${response.status})`; if (!silent) notify.error(message); @@ -152,7 +167,22 @@ export const apiClient = { } } - const json = await response.json(); + let json = await response.json(); + + // Platform returns 200 with UNAUTHENTICATED error when token expires — retry with refresh + const authError = json.errors?.find((e: any) => e.extensions?.code === 'UNAUTHENTICATED'); + if (authError) { + const refreshed = await tryRefreshToken(); + if (refreshed) { + const retryResponse = await doFetch(); + json = await retryResponse.json(); + } else { + clearTokens(); + if (!options?.silent) notify.error('Session expired', 'Please log in again.'); + throw new AuthError(); + } + } + if (json.errors) { const message = json.errors[0]?.message ?? 'GraphQL error'; if (!options?.silent) notify.error('Query failed', message); diff --git a/src/providers/sip-provider.tsx b/src/providers/sip-provider.tsx index f510748..9926898 100644 --- a/src/providers/sip-provider.tsx +++ b/src/providers/sip-provider.tsx @@ -14,27 +14,25 @@ import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOu import { apiClient } from '@/lib/api-client'; import type { SIPConfig } from '@/types/sip'; -const getSipConfig = (): SIPConfig => { +// SIP config comes exclusively from the Agent entity (stored on login). +// No env var fallback — users without an Agent entity don't connect SIP. +const getSipConfig = (): SIPConfig | null => { try { const stored = localStorage.getItem('helix_agent_config'); if (stored) { const config = JSON.parse(stored); - return { - displayName: 'Helix Agent', - uri: config.sipUri, - password: config.sipPassword, - wsServer: config.sipWsServer, - stunServers: 'stun:stun.l.google.com:19302', - }; + if (config.sipUri && config.sipWsServer) { + return { + displayName: 'Helix Agent', + uri: config.sipUri, + password: config.sipPassword, + wsServer: config.sipWsServer, + stunServers: 'stun:stun.l.google.com:19302', + }; + } } } catch {} - return { - displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent', - uri: import.meta.env.VITE_SIP_URI ?? '', - password: import.meta.env.VITE_SIP_PASSWORD ?? '', - wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '', - stunServers: 'stun:stun.l.google.com:19302', - }; + return null; }; export const SipProvider = ({ children }: PropsWithChildren) => { @@ -55,9 +53,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => { }); }, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]); - // Auto-connect SIP on mount + // Auto-connect SIP on mount — only if Agent entity has SIP config useEffect(() => { - connectSip(getSipConfig()); + const config = getSipConfig(); + if (config) { + connectSip(config); + } else { + console.log('[SIP] No agent SIP config — skipping connection'); + } }, []); // Call duration timer @@ -178,7 +181,7 @@ export const useSip = () => { isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState), ozonetelStatus: 'logged-in' as const, ozonetelError: null as string | null, - connect: () => connectSip(getSipConfig()), + connect: () => { const c = getSipConfig(); if (c) connectSip(c); }, disconnect: disconnectSip, makeCall, dialOutbound,