190 lines
5.8 KiB
JavaScript
190 lines
5.8 KiB
JavaScript
const API_BASE = '../api';
|
|
|
|
function getToken() {
|
|
return localStorage.getItem('qdb_token');
|
|
}
|
|
|
|
const WEBSITE_ROLES = ['admin', 'supervisor'];
|
|
|
|
let sessionCheckPromise = null;
|
|
let sessionCheckToken = null;
|
|
|
|
/** Clear cached session validation (after logout or token change). */
|
|
export function invalidateSessionCheck() {
|
|
sessionCheckPromise = null;
|
|
sessionCheckToken = null;
|
|
}
|
|
|
|
/** Clear stored session and open the login page. */
|
|
export function redirectToLogin() {
|
|
invalidateSessionCheck();
|
|
localStorage.removeItem('qdb_token');
|
|
localStorage.removeItem('qdb_user');
|
|
localStorage.removeItem('qdb_role');
|
|
window.location.hash = '#/login';
|
|
}
|
|
|
|
/** @returns {boolean} true if the response means the session is no longer valid */
|
|
function isSessionInvalidResponse(res, json) {
|
|
if (res.status === 401) return true;
|
|
const errMsg = json.error?.message || (typeof json.error === 'string' ? json.error : '');
|
|
const errCode = json.error?.code || '';
|
|
return res.status === 401
|
|
|| res.status === 403 && (
|
|
errCode === 'UNAUTHORIZED'
|
|
|| errCode === 'PASSWORD_CHANGE_REQUIRED'
|
|
|| errCode === 'FORBIDDEN'
|
|
|| errMsg === 'Invalid or expired token'
|
|
|| errMsg === 'Missing Bearer token'
|
|
|| errMsg === 'Password change required before access'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Verify the stored token is still valid on the server. Redirects to login when not.
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export async function ensureValidSession() {
|
|
const token = getToken();
|
|
if (!token) {
|
|
redirectToLogin();
|
|
return false;
|
|
}
|
|
|
|
if (sessionCheckPromise && sessionCheckToken === token) {
|
|
return sessionCheckPromise;
|
|
}
|
|
|
|
sessionCheckToken = token;
|
|
sessionCheckPromise = (async () => {
|
|
try {
|
|
const data = await apiGet('session');
|
|
if (!data.success || !data.valid) {
|
|
redirectToLogin();
|
|
return false;
|
|
}
|
|
if (data.mustChangePassword) {
|
|
redirectToLogin();
|
|
return false;
|
|
}
|
|
if (!WEBSITE_ROLES.includes(data.role)) {
|
|
redirectToLogin();
|
|
return false;
|
|
}
|
|
if (data.user) localStorage.setItem('qdb_user', data.user);
|
|
if (data.role) localStorage.setItem('qdb_role', data.role);
|
|
return true;
|
|
} catch (e) {
|
|
if (e.message !== 'Session expired') {
|
|
redirectToLogin();
|
|
}
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
return sessionCheckPromise;
|
|
}
|
|
|
|
async function handleAuthResponse(res) {
|
|
if (res.status !== 401 && res.status !== 403) return;
|
|
const json = await res.json().catch(() => ({}));
|
|
if (isSessionInvalidResponse(res, json)) {
|
|
redirectToLogin();
|
|
throw new Error('Session expired');
|
|
}
|
|
const errMsg = json.error?.message || json.error || '';
|
|
throw new Error(errMsg || 'Permission denied');
|
|
}
|
|
|
|
async function apiFetch(endpoint, options = {}) {
|
|
const token = getToken();
|
|
const headers = options.headers || {};
|
|
headers['X-QDB-Client'] = 'web';
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
if (!(options.body instanceof FormData)) {
|
|
headers['Content-Type'] = 'application/json; charset=UTF-8';
|
|
}
|
|
|
|
// Strip .php suffix for clean URLs
|
|
const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1');
|
|
const res = await fetch(`${API_BASE}/${cleanEndpoint}`, { ...options, headers });
|
|
|
|
if (res.status === 401 || res.status === 403) {
|
|
await handleAuthResponse(res);
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* Unwrap the unified response envelope:
|
|
* {"ok": true, "data": ...} -> returns { success: true, ...data }
|
|
* {"ok": false, "error": ...} -> returns { success: false, error: message }
|
|
* Also supports legacy format for backward compat.
|
|
*/
|
|
function unwrap(json) {
|
|
if (typeof json.ok === 'boolean') {
|
|
if (json.ok) {
|
|
const d = json.data || {};
|
|
return { success: true, ...(Array.isArray(d) ? { items: d } : d) };
|
|
}
|
|
const err = json.error;
|
|
const msg = typeof err === 'string' ? err : (err?.message || 'Unknown error');
|
|
return { success: false, error: msg, errorCode: typeof err === 'object' ? err?.code : null };
|
|
}
|
|
return json;
|
|
}
|
|
|
|
export async function apiGet(endpoint) {
|
|
const res = await apiFetch(endpoint);
|
|
return unwrap(await res.json());
|
|
}
|
|
|
|
export async function apiPost(endpoint, body) {
|
|
const res = await apiFetch(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
return unwrap(await res.json());
|
|
}
|
|
|
|
export async function apiPut(endpoint, body) {
|
|
const res = await apiFetch(endpoint, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body),
|
|
});
|
|
return unwrap(await res.json());
|
|
}
|
|
|
|
export async function apiPatch(endpoint, body) {
|
|
const res = await apiFetch(endpoint, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
});
|
|
return unwrap(await res.json());
|
|
}
|
|
|
|
export async function apiDelete(endpoint, body) {
|
|
const res = await apiFetch(endpoint, {
|
|
method: 'DELETE',
|
|
body: JSON.stringify(body),
|
|
});
|
|
return unwrap(await res.json());
|
|
}
|
|
|
|
/** Authenticated file/download fetch (marks request as website for activity logging). */
|
|
export async function apiDownloadFetch(url, options = {}) {
|
|
const token = getToken();
|
|
const headers = { ...(options.headers || {}), 'X-QDB-Client': 'web' };
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
const res = await fetch(url, { ...options, headers });
|
|
const ct = res.headers.get('Content-Type') || '';
|
|
if (ct.includes('application/json')) {
|
|
await handleAuthResponse(res);
|
|
} else if (res.status === 401 || res.status === 403) {
|
|
redirectToLogin();
|
|
throw new Error('Session expired');
|
|
}
|
|
return res;
|
|
}
|