Upload app/webui/js/api.js
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user