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
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-10 11:47:54 -03:00
parent 955568c8e4
commit ab8c7ae02b
3 changed files with 415 additions and 0 deletions

View file

@ -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 },

View file

@ -2359,6 +2359,7 @@
</main>
<!-- Core scripts -->
<script src="js/api-client.js"></script>
<script src="js/theme-manager.js"></script>
<script src="js/htmx-app.js"></script>
<script src="js/base.js"></script>

394
ui/suite/js/api-client.js Normal file
View file

@ -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;
})();