Upload app/webui/js/api.js

This commit is contained in:
2026-06-04 00:06:29 +00:00
committed by leocrm-bot
parent 8d6f496d14
commit be1b473b46
+158
View File
@@ -0,0 +1,158 @@
/**
* CRM Frontend API-Wrapper
*
* Lightweight fetch() wrapper with:
* - Automatic JWT injection (Authorization: Bearer <token> from localStorage)
* - 401 handling: clears token + redirects to /index.html
* - JSON-only requests (FormData→x-www-form-urlencoded via postForm)
* - ApiError class with status and parsed body for granular error handling
*
* All pages import this via <script type="module"> and use `api.get/post/patch/del`.
*
* NOTE: No x-html, no eval, no untrusted-string-template — XSS-safe by design.
*/
const API_BASE = '/api/v1';
export class ApiError extends Error {
constructor(response, body) {
const message = body?.detail || body?.error?.message || `HTTP ${response.status}`;
super(typeof message === 'string' ? message : JSON.stringify(message));
this.name = 'ApiError';
this.status = response.status;
this.body = body;
}
}
/**
* Read the JWT from localStorage.
* @returns {string|null}
*/
export function getToken() {
try {
return localStorage.getItem('jwt');
} catch {
return null;
}
}
/**
* Persist the JWT in localStorage. Caller is responsible for also updating
* Alpine.store('auth') so the UI stays in sync.
* @param {string} token
*/
export function setToken(token) {
try {
if (token) localStorage.setItem('jwt', token);
else localStorage.removeItem('jwt');
} catch {
// localStorage may be disabled in privacy mode — swallow silently
}
}
export function clearToken() {
setToken(null);
}
/**
* Core fetch wrapper. Adds JWT, handles 401 (redirect to login), parses JSON.
*
* @param {string} path Path under API_BASE, e.g. "/accounts/42"
* @param {object} [options] fetch() options, with `body` already stringified if needed
* @returns {Promise<any>} parsed JSON body, or null for 204
* @throws {ApiError} on non-2xx status
*/
export async function apiFetch(path, options = {}) {
const token = getToken();
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers,
};
// Strip Content-Type for FormData/URLSearchParams bodies (let the browser set it)
if (options.body instanceof URLSearchParams || options.body instanceof FormData) {
delete headers['Content-Type'];
}
let response;
try {
response = await fetch(`${API_BASE}${path}`, { ...options, headers });
} catch (networkErr) {
throw new ApiError(
{ status: 0, statusText: 'Network Error' },
{ detail: `Netzwerkfehler: ${networkErr.message}` }
);
}
// 401 → token invalid, force re-login (but not on the login page itself)
if (response.status === 401) {
clearToken();
// Avoid redirect-loop when already on the login screen
if (!window.location.pathname.endsWith('/index.html') &&
window.location.pathname !== '/' &&
!window.location.pathname.endsWith('/')) {
window.location.href = '/index.html';
}
const body = await response.json().catch(() => null);
throw new ApiError(response, body);
}
// 204 No Content (e.g. DELETE)
if (response.status === 204) {
return null;
}
// Try to parse JSON for everything else
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = { detail: text };
}
}
if (!response.ok) {
throw new ApiError(response, body);
}
return body;
}
/**
* Convenience API. Pages import `api` and call `api.get('/accounts')` etc.
*/
export const api = {
/** GET request */
get: (path, options = {}) => apiFetch(path, { ...options, method: 'GET' }),
/** POST JSON */
post: (path, data, options = {}) =>
apiFetch(path, { ...options, method: 'POST', body: JSON.stringify(data) }),
/** PATCH JSON */
patch: (path, data, options = {}) =>
apiFetch(path, { ...options, method: 'PATCH', body: JSON.stringify(data) }),
/** DELETE (no body) */
del: (path, options = {}) => apiFetch(path, { ...options, method: 'DELETE' }),
/** POST application/x-www-form-urlencoded (for OAuth2-style login if needed) */
postForm: (path, params, options = {}) => {
let body;
if (params instanceof URLSearchParams) {
body = params;
} else {
body = new URLSearchParams();
for (const [k, v] of Object.entries(params || {})) {
if (v !== undefined && v !== null) body.append(k, String(v));
}
}
return apiFetch(path, { ...options, method: 'POST', body });
},
};
export default api;