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:
parent
955568c8e4
commit
ab8c7ae02b
3 changed files with 415 additions and 0 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
394
ui/suite/js/api-client.js
Normal 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;
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue