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 = {}) {
|
async function apiRequest(endpoint, options = {}) {
|
||||||
const url = `${API_BASE}${endpoint}`;
|
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" };
|
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 {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { ...defaultHeaders, ...options.headers },
|
headers: { ...defaultHeaders, ...options.headers },
|
||||||
|
|
|
||||||
|
|
@ -2359,6 +2359,7 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Core scripts -->
|
<!-- Core scripts -->
|
||||||
|
<script src="js/api-client.js"></script>
|
||||||
<script src="js/theme-manager.js"></script>
|
<script src="js/theme-manager.js"></script>
|
||||||
<script src="js/htmx-app.js"></script>
|
<script src="js/htmx-app.js"></script>
|
||||||
<script src="js/base.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