Files
helix-engage/src/lib/api-client.ts
saridsa2 daa2fbb0c2 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>
2026-03-30 14:44:48 +05:30

232 lines
7.9 KiB
TypeScript

import { notify } from './toast';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
class AuthError extends Error {
constructor(message = 'Authentication required') {
super(message);
this.name = 'AuthError';
}
}
const getStoredToken = (): string | null => localStorage.getItem('helix_access_token');
const getRefreshToken = (): string | null => localStorage.getItem('helix_refresh_token');
const storeTokens = (accessToken: string, refreshToken: string) => {
localStorage.setItem('helix_access_token', accessToken);
localStorage.setItem('helix_refresh_token', refreshToken);
};
const clearTokens = () => {
localStorage.removeItem('helix_access_token');
localStorage.removeItem('helix_refresh_token');
};
// Shared auth headers
const authHeaders = (): Record<string, string> => {
const token = getStoredToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
};
// Token refresh — attempts to get a new access token using the refresh token
let refreshPromise: Promise<boolean> | null = null;
const tryRefreshToken = async (): Promise<boolean> => {
// Deduplicate concurrent refresh attempts
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) return false;
const data = await response.json();
if (data.accessToken && data.refreshToken) {
storeTokens(data.accessToken, data.refreshToken);
return true;
}
return false;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
};
// Shared response handler — extracts error message, handles 401 with auto-refresh, toasts on failure
const handleResponse = async <T>(response: Response, silent = false, retryFn?: () => Promise<Response>): Promise<T> => {
if (response.status === 401 && retryFn) {
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.status === 401) {
clearTokens();
if (!silent) notify.error('Session expired. Please log in again.');
throw new AuthError();
}
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) {
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
if (!silent) notify.error(message);
throw new Error(message);
}
return json as T;
};
export const apiClient = {
// Auth — no token needed, no toast on failure (login page shows inline errors)
async login(email: string, password: string) {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message ?? 'Login failed');
}
const data = await response.json();
storeTokens(data.accessToken, data.refreshToken);
return data as {
accessToken: string;
refreshToken: string;
user?: {
id?: string;
email?: string;
firstName?: string;
lastName?: string;
avatarUrl?: string;
role?: string;
platformRoles?: string[];
};
};
},
// GraphQL — all platform data queries go through this
async graphql<T>(query: string, variables?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
const token = getStoredToken();
if (!token) throw new AuthError();
const doFetch = () => fetch(`${API_URL}/graphql`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ query, variables }),
});
let response = await doFetch();
if (response.status === 401) {
const refreshed = await tryRefreshToken();
if (refreshed) {
response = await doFetch();
} else {
clearTokens();
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
throw new AuthError();
}
}
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);
throw new Error(message);
}
return json.data;
},
// REST — all sidecar API calls go through these
async post<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
const doFetch = () => fetch(`${API_URL}${path}`, {
method: 'POST',
headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
const response = await doFetch();
return handleResponse<T>(response, options?.silent, doFetch);
},
async get<T>(path: string, options?: { silent?: boolean }): Promise<T> {
const doFetch = () => fetch(`${API_URL}${path}`, {
method: 'GET',
headers: authHeaders(),
});
const response = await doFetch();
return handleResponse<T>(response, options?.silent, doFetch);
},
// Health check — silent, no toasts
async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> {
try {
const response = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) });
if (!response.ok) return { status: 'down', platform: { reachable: false } };
return response.json();
} catch {
return { status: 'down', platform: { reachable: false } };
}
},
getStoredToken,
getRefreshToken,
storeTokens,
clearTokens,
isAuthenticated: () => !!getStoredToken(),
};