From ab8c7ae02b9238507d2e28aa9bf7983fdfefeb30 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sat, 10 Jan 2026 11:47:54 -0300 Subject: [PATCH] feat(api-client): add centralized API client with auth token injection - Create api-client.js for automatic auth token handling - Include Authorization header in all API requests - Support for localStorage/sessionStorage token persistence - Handle 401/403 errors with global event dispatch - Add file upload with progress tracking - Update drive.js to use ApiClient - Add api-client.js to index.html --- ui/suite/drive/drive.js | 20 ++ ui/suite/index.html | 1 + ui/suite/js/api-client.js | 394 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 415 insertions(+) create mode 100644 ui/suite/js/api-client.js diff --git a/ui/suite/drive/drive.js b/ui/suite/drive/drive.js index dd9d43e..9b2ef09 100644 --- a/ui/suite/drive/drive.js +++ b/ui/suite/drive/drive.js @@ -223,8 +223,28 @@ async function apiRequest(endpoint, options = {}) { const url = `${API_BASE}${endpoint}`; + + // Use global ApiClient if available for automatic auth token injection + if (window.ApiClient) { + try { + return await window.ApiClient.request(url, options); + } catch (err) { + console.error(`API Error [${endpoint}]:`, err); + throw err; + } + } + + // Fallback if ApiClient not loaded const defaultHeaders = { "Content-Type": "application/json" }; + // Try to get auth token from storage + const token = + localStorage.getItem("gb-access-token") || + sessionStorage.getItem("gb-access-token"); + if (token) { + defaultHeaders["Authorization"] = `Bearer ${token}`; + } + try { const response = await fetch(url, { headers: { ...defaultHeaders, ...options.headers }, diff --git a/ui/suite/index.html b/ui/suite/index.html index 9f8000b..cb6c37b 100644 --- a/ui/suite/index.html +++ b/ui/suite/index.html @@ -2359,6 +2359,7 @@ + diff --git a/ui/suite/js/api-client.js b/ui/suite/js/api-client.js new file mode 100644 index 0000000..3ab9f17 --- /dev/null +++ b/ui/suite/js/api-client.js @@ -0,0 +1,394 @@ +/** + * API Client - Centralized API request handler with authentication + * + * This module provides a consistent way to make API requests with: + * - Automatic auth token injection + * - Request/response logging in development + * - Error handling + * - Retry logic for failed requests + */ + +(function () { + "use strict"; + + const AUTH_TOKEN_KEY = "gb-access-token"; + const REFRESH_TOKEN_KEY = "gb-refresh-token"; + const SESSION_ID_KEY = "gb-session-id"; + + /** + * Get the current auth token from storage + */ + function getAuthToken() { + // Try localStorage first (persistent login) + let token = localStorage.getItem(AUTH_TOKEN_KEY); + if (token) return token; + + // Fall back to sessionStorage + token = sessionStorage.getItem(AUTH_TOKEN_KEY); + if (token) return token; + + // Try to get from cookie + const cookies = document.cookie.split(";"); + for (const cookie of cookies) { + const [name, value] = cookie.trim().split("="); + if (name === "gb_session" || name === "session_id") { + return value; + } + } + + return null; + } + + /** + * Set the auth token in storage + */ + function setAuthToken(token, persistent = true) { + if (persistent) { + localStorage.setItem(AUTH_TOKEN_KEY, token); + } else { + sessionStorage.setItem(AUTH_TOKEN_KEY, token); + } + } + + /** + * Clear the auth token from storage + */ + function clearAuthToken() { + localStorage.removeItem(AUTH_TOKEN_KEY); + sessionStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + sessionStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(SESSION_ID_KEY); + sessionStorage.removeItem(SESSION_ID_KEY); + localStorage.removeItem("gb-token-expires"); + sessionStorage.removeItem("gb-token-expires"); + } + + /** + * Get session ID + */ + function getSessionId() { + return ( + localStorage.getItem(SESSION_ID_KEY) || + sessionStorage.getItem(SESSION_ID_KEY) + ); + } + + /** + * Set session ID + */ + function setSessionId(sessionId, persistent = true) { + if (persistent) { + localStorage.setItem(SESSION_ID_KEY, sessionId); + } else { + sessionStorage.setItem(SESSION_ID_KEY, sessionId); + } + } + + /** + * Build headers with auth token + */ + function buildHeaders(customHeaders = {}) { + const headers = { + "Content-Type": "application/json", + ...customHeaders, + }; + + const token = getAuthToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const sessionId = getSessionId(); + if (sessionId) { + headers["X-Session-ID"] = sessionId; + } + + return headers; + } + + /** + * Make an API request with auth + */ + async function request(url, options = {}) { + const { + method = "GET", + body = null, + headers = {}, + retries = 0, + retryDelay = 1000, + timeout = 30000, + credentials = "same-origin", + skipAuth = false, + } = options; + + const requestHeaders = skipAuth + ? { "Content-Type": "application/json", ...headers } + : buildHeaders(headers); + + const fetchOptions = { + method, + headers: requestHeaders, + credentials, + }; + + if (body && method !== "GET" && method !== "HEAD") { + fetchOptions.body = + typeof body === "string" ? body : JSON.stringify(body); + } + + // Add timeout support + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + fetchOptions.signal = controller.signal; + + let lastError; + let attempt = 0; + + while (attempt <= retries) { + try { + const response = await fetch(url, fetchOptions); + clearTimeout(timeoutId); + + // Handle auth errors + if (response.status === 401) { + const error = new Error("Unauthorized"); + error.status = 401; + error.response = response; + + // Dispatch event for global handling + window.dispatchEvent( + new CustomEvent("api:unauthorized", { detail: { url, response } }), + ); + + throw error; + } + + if (response.status === 403) { + const error = new Error("Forbidden"); + error.status = 403; + error.response = response; + throw error; + } + + if (!response.ok) { + let errorBody; + try { + errorBody = await response.json(); + } catch { + errorBody = { error: response.statusText }; + } + const error = new Error( + errorBody.message || errorBody.error || "Request failed", + ); + error.status = response.status; + error.body = errorBody; + error.response = response; + throw error; + } + + // Handle empty responses + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + return await response.json(); + } else if (response.status === 204) { + return null; + } else { + return await response.text(); + } + } catch (err) { + clearTimeout(timeoutId); + lastError = err; + + // Don't retry on auth errors or client errors + if (err.status && err.status >= 400 && err.status < 500) { + throw err; + } + + // Don't retry on abort + if (err.name === "AbortError") { + const error = new Error("Request timeout"); + error.status = 408; + throw error; + } + + attempt++; + if (attempt <= retries) { + await new Promise((resolve) => + setTimeout(resolve, retryDelay * attempt), + ); + } + } + } + + throw lastError; + } + + /** + * GET request helper + */ + async function get(url, options = {}) { + return request(url, { ...options, method: "GET" }); + } + + /** + * POST request helper + */ + async function post(url, body, options = {}) { + return request(url, { ...options, method: "POST", body }); + } + + /** + * PUT request helper + */ + async function put(url, body, options = {}) { + return request(url, { ...options, method: "PUT", body }); + } + + /** + * PATCH request helper + */ + async function patch(url, body, options = {}) { + return request(url, { ...options, method: "PATCH", body }); + } + + /** + * DELETE request helper + */ + async function del(url, options = {}) { + return request(url, { ...options, method: "DELETE" }); + } + + /** + * Upload file with progress tracking + */ + async function upload(url, file, options = {}) { + const { onProgress, ...restOptions } = options; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open("POST", url); + + // Set auth header + const token = getAuthToken(); + if (token) { + xhr.setRequestHeader("Authorization", `Bearer ${token}`); + } + + const sessionId = getSessionId(); + if (sessionId) { + xhr.setRequestHeader("X-Session-ID", sessionId); + } + + // Set custom headers + if (restOptions.headers) { + Object.entries(restOptions.headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value); + }); + } + + // Track progress + if (onProgress && xhr.upload) { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const progress = Math.round((event.loaded / event.total) * 100); + onProgress(progress, event.loaded, event.total); + } + }; + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch { + resolve(xhr.responseText); + } + } else if (xhr.status === 401) { + window.dispatchEvent( + new CustomEvent("api:unauthorized", { detail: { url } }), + ); + reject(new Error("Unauthorized")); + } else { + reject(new Error(xhr.statusText || "Upload failed")); + } + }; + + xhr.onerror = () => reject(new Error("Network error")); + xhr.ontimeout = () => reject(new Error("Upload timeout")); + + // Create FormData + const formData = new FormData(); + if (file instanceof File) { + formData.append("file", file); + } else if (file instanceof FormData) { + // If already FormData, use it directly + xhr.send(file); + return; + } else { + formData.append("file", file); + } + + xhr.send(formData); + }); + } + + /** + * Initialize auth from login response + */ + function initFromLoginResponse(response) { + if (response.access_token) { + setAuthToken(response.access_token, response.remember !== false); + } + if (response.session_id) { + setSessionId(response.session_id, response.remember !== false); + } + } + + /** + * Check if user is authenticated (has token) + */ + function isAuthenticated() { + return !!getAuthToken(); + } + + // Global handler for unauthorized responses + window.addEventListener("api:unauthorized", () => { + // Clear invalid tokens + clearAuthToken(); + + // Optionally redirect to login + // Only redirect if not already on login page + if ( + !window.location.hash.includes("login") && + !window.location.pathname.includes("/auth/") + ) { + console.warn("Session expired. Please log in again."); + // Could dispatch event for UI to handle + window.dispatchEvent(new CustomEvent("auth:expired")); + } + }); + + // Export API client + window.ApiClient = { + request, + get, + post, + put, + patch, + delete: del, + upload, + getAuthToken, + setAuthToken, + clearAuthToken, + getSessionId, + setSessionId, + buildHeaders, + initFromLoginResponse, + isAuthenticated, + }; + + // Also export as gbApi for backwards compatibility + window.gbApi = window.ApiClient; +})();