- 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
394 lines
9.7 KiB
JavaScript
394 lines
9.7 KiB
JavaScript
/**
|
|
* 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;
|
|
})();
|