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