mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
|||||||
faCalendarCheck,
|
faCalendarCheck,
|
||||||
faPhone,
|
faPhone,
|
||||||
faUsers,
|
faUsers,
|
||||||
|
faWifi,
|
||||||
|
faWifiSlash,
|
||||||
faArrowRightFromBracket,
|
faArrowRightFromBracket,
|
||||||
faTowerBroadcast,
|
faTowerBroadcast,
|
||||||
faChartLine,
|
faChartLine,
|
||||||
@@ -32,6 +34,7 @@ import { Avatar } from "@/components/base/avatar/avatar";
|
|||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { useNetworkStatus } from "@/hooks/use-network-status";
|
||||||
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
@@ -123,6 +126,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
const { logout, user } = useAuth();
|
const { logout, user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
||||||
|
const networkQuality = useNetworkStatus();
|
||||||
|
|
||||||
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||||
|
|
||||||
@@ -218,6 +222,25 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{/* Network indicator — only shows when network is degraded */}
|
||||||
|
{networkQuality !== 'good' && (
|
||||||
|
<div className={cx(
|
||||||
|
"mx-3 mb-2 flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium",
|
||||||
|
networkQuality === 'offline'
|
||||||
|
? "bg-error-secondary text-error-primary"
|
||||||
|
: "bg-warning-secondary text-warning-primary",
|
||||||
|
collapsed && "justify-center mx-2 px-2",
|
||||||
|
)}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
||||||
|
className="size-3.5 shrink-0"
|
||||||
|
/>
|
||||||
|
{!collapsed && (
|
||||||
|
<span>{networkQuality === 'offline' ? 'No connection' : 'Unstable network'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Account card */}
|
{/* Account card */}
|
||||||
<div className={cx("mt-auto py-4", collapsed ? "flex justify-center px-2" : "px-2 lg:px-4")}>
|
<div className={cx("mt-auto py-4", collapsed ? "flex justify-center px-2" : "px-2 lg:px-4")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
|
|||||||
46
src/hooks/use-network-status.ts
Normal file
46
src/hooks/use-network-status.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export type NetworkQuality = 'good' | 'unstable' | 'offline';
|
||||||
|
|
||||||
|
export const useNetworkStatus = (): NetworkQuality => {
|
||||||
|
const [quality, setQuality] = useState<NetworkQuality>(navigator.onLine ? 'good' : 'offline');
|
||||||
|
const dropCountRef = useRef(0);
|
||||||
|
const resetTimerRef = useRef<number | null>(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;
|
||||||
|
};
|
||||||
@@ -88,6 +88,21 @@ const handleResponse = async <T>(response: Response, silent = false, retryFn?: (
|
|||||||
|
|
||||||
const json = await response.json().catch(() => null);
|
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<T>(retryResponse, silent);
|
||||||
|
}
|
||||||
|
clearTokens();
|
||||||
|
if (!silent) notify.error('Session expired. Please log in again.');
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
|
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
|
||||||
if (!silent) notify.error(message);
|
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) {
|
if (json.errors) {
|
||||||
const message = json.errors[0]?.message ?? 'GraphQL error';
|
const message = json.errors[0]?.message ?? 'GraphQL error';
|
||||||
if (!options?.silent) notify.error('Query failed', message);
|
if (!options?.silent) notify.error('Query failed', message);
|
||||||
|
|||||||
@@ -14,27 +14,25 @@ import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOu
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import type { SIPConfig } from '@/types/sip';
|
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 {
|
try {
|
||||||
const stored = localStorage.getItem('helix_agent_config');
|
const stored = localStorage.getItem('helix_agent_config');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const config = JSON.parse(stored);
|
const config = JSON.parse(stored);
|
||||||
return {
|
if (config.sipUri && config.sipWsServer) {
|
||||||
displayName: 'Helix Agent',
|
return {
|
||||||
uri: config.sipUri,
|
displayName: 'Helix Agent',
|
||||||
password: config.sipPassword,
|
uri: config.sipUri,
|
||||||
wsServer: config.sipWsServer,
|
password: config.sipPassword,
|
||||||
stunServers: 'stun:stun.l.google.com:19302',
|
wsServer: config.sipWsServer,
|
||||||
};
|
stunServers: 'stun:stun.l.google.com:19302',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return {
|
return null;
|
||||||
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',
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SipProvider = ({ children }: PropsWithChildren) => {
|
export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||||
@@ -55,9 +53,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
});
|
});
|
||||||
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
|
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
|
||||||
|
|
||||||
// Auto-connect SIP on mount
|
// Auto-connect SIP on mount — only if Agent entity has SIP config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connectSip(getSipConfig());
|
const config = getSipConfig();
|
||||||
|
if (config) {
|
||||||
|
connectSip(config);
|
||||||
|
} else {
|
||||||
|
console.log('[SIP] No agent SIP config — skipping connection');
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Call duration timer
|
// Call duration timer
|
||||||
@@ -178,7 +181,7 @@ export const useSip = () => {
|
|||||||
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
|
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
|
||||||
ozonetelStatus: 'logged-in' as const,
|
ozonetelStatus: 'logged-in' as const,
|
||||||
ozonetelError: null as string | null,
|
ozonetelError: null as string | null,
|
||||||
connect: () => connectSip(getSipConfig()),
|
connect: () => { const c = getSipConfig(); if (c) connectSip(c); },
|
||||||
disconnect: disconnectSip,
|
disconnect: disconnectSip,
|
||||||
makeCall,
|
makeCall,
|
||||||
dialOutbound,
|
dialOutbound,
|
||||||
|
|||||||
Reference in New Issue
Block a user