api: remove auth helpers, route 401 through auth model
This commit is contained in:
+28
-146
@@ -1,145 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Hoover — api.js
|
* Hoover — api.js
|
||||||
*
|
*
|
||||||
* JSON-friendly fetch wrapper with automatic header management and JWT auth.
|
* JSON-friendly fetch wrapper with automatic header management (JWT headers
|
||||||
|
* injected from the auth model) and 401 re-authentication.
|
||||||
* Toast notification system with auto-dismiss.
|
* Toast notification system with auto-dismiss.
|
||||||
* Modal processing guard for async form submissions.
|
* Modal processing guard for async form submissions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { modelFetch } from './model.js';
|
import { modelFetch } from './model.js';
|
||||||
|
import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js';
|
||||||
import { requestUpdate } from './reactivity.js';
|
import { requestUpdate } from './reactivity.js';
|
||||||
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js';
|
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Global state — shared with auth.js component.
|
|
||||||
*
|
|
||||||
* ``window.__auth_token__`` — current access token (in memory).
|
|
||||||
* ``sessionStorage['vw:access']`` — persisted access token (tab-scoped).
|
|
||||||
* ``sessionStorage['vw:refresh']`` — refresh token (tab-scoped, cleared on close).
|
|
||||||
* ``sessionStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling.
|
|
||||||
* ``sessionStorage['vw:session_id']`` — session binding ID for token validation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject ``Authorization: Bearer <token>`` header from ``window.__auth_token__``.
|
|
||||||
* Returns undefined when no token is available.
|
|
||||||
*
|
|
||||||
* @returns {string|undefined}
|
|
||||||
*/
|
|
||||||
function getAuthToken() {
|
|
||||||
return window.__auth_token__ || sessionStorage.getItem('vw:access');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store access token in memory and schedule refresh.
|
|
||||||
*
|
|
||||||
* @param {string} token
|
|
||||||
*/
|
|
||||||
function setAuthToken(token) {
|
|
||||||
window.__auth_token__ = token;
|
|
||||||
sessionStorage.setItem('vw:access', token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all auth tokens from memory and storage.
|
|
||||||
*/
|
|
||||||
function clearAuthTokens() {
|
|
||||||
window.__auth_token__ = undefined;
|
|
||||||
sessionStorage.removeItem('vw:refresh');
|
|
||||||
sessionStorage.removeItem('vw:access_ttl');
|
|
||||||
sessionStorage.removeItem('vw:session_id');
|
|
||||||
sessionStorage.removeItem('vw:user');
|
|
||||||
sessionStorage.removeItem('vw:permissions');
|
|
||||||
sessionStorage.removeItem('vw:access');
|
|
||||||
if (typeof window.__authRefreshTimer__ !== 'undefined') {
|
|
||||||
clearTimeout(window.__authRefreshTimer__);
|
|
||||||
window.__authRefreshTimer__ = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read refresh token and access TTL from sessionStorage.
|
|
||||||
* @returns {{refresh?: string, ttl?: number, session_id?: string}}
|
|
||||||
*/
|
|
||||||
function getStoredAuth() {
|
|
||||||
return {
|
|
||||||
refresh: sessionStorage.getItem('vw:refresh'),
|
|
||||||
ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000,
|
|
||||||
session_id: sessionStorage.getItem('vw:session_id'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Serialize concurrent refresh attempts — only one refresh in-flight at a time. */
|
|
||||||
let _refreshPromise = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to refresh the access token using the stored refresh token.
|
|
||||||
* Concurrent calls wait on the in-flight refresh; subsequent calls reuse
|
|
||||||
* whatever the outcome was.
|
|
||||||
*
|
|
||||||
* Sends: POST /api/auth/refresh { refresh_token: ... }
|
|
||||||
* On success: updates ``window.__auth_token__`` and ``sessionStorage['vw:refresh']``.
|
|
||||||
* On failure: clears all tokens.
|
|
||||||
*
|
|
||||||
* @returns {Promise<boolean>} ``true`` if refresh succeeded
|
|
||||||
*/
|
|
||||||
async function tryRefreshToken() {
|
|
||||||
if (!_refreshPromise) {
|
|
||||||
_refreshPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const stored = getStoredAuth();
|
|
||||||
if (!stored.refresh) return false;
|
|
||||||
|
|
||||||
const res = await fetch('/api/auth/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
||||||
body: JSON.stringify({ refresh_token: stored.refresh, session_id: stored.session_id }),
|
|
||||||
credentials: 'same-origin',
|
|
||||||
});
|
|
||||||
if (res.status !== 200) {
|
|
||||||
clearAuthTokens();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const json = await res.json();
|
|
||||||
if (!json.ok || !json.data?.tokens) {
|
|
||||||
clearAuthTokens();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const tokens = json.data.tokens;
|
|
||||||
setAuthToken(tokens.access_token);
|
|
||||||
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
|
||||||
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
|
|
||||||
sessionStorage.setItem('vw:session_id', tokens.session_id);
|
|
||||||
if (json.data.user) {
|
|
||||||
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Auth] Token refresh failed:', err);
|
|
||||||
clearAuthTokens();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
return _refreshPromise.finally(() => { _refreshPromise = null; });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect to login page, clearing tokens.
|
|
||||||
*/
|
|
||||||
function redirectLogin() {
|
|
||||||
clearAuthTokens();
|
|
||||||
window.location.href = '/#/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON-friendly fetch wrapper.
|
* JSON-friendly fetch wrapper.
|
||||||
*
|
*
|
||||||
* Automatically sets Content-Type for object bodies, parses JSON
|
* Automatically sets Content-Type for object bodies, parses JSON
|
||||||
* responses, and normalises the result to { ok, data, error, status }.
|
* responses, and normalises the result to { ok, data, error, status }.
|
||||||
* Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when
|
* Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when a
|
||||||
* a token is present. On 401, tries token refresh once; on persistent
|
* token is present (read from the auth model). On 401 it runs the auth
|
||||||
* failure, redirects to login.
|
* model's refresh once and retries; a still-401 retry drives the model to
|
||||||
|
* the terminal logout state (clears storage, redirects to login).
|
||||||
*
|
*
|
||||||
* @param {string} url – Target URL
|
* @param {string} url – Target URL
|
||||||
* @param {object} [options] – Fetch options (method, body, headers, …)
|
* @param {object} [options] – Fetch options (method, body, headers, …)
|
||||||
@@ -147,12 +28,15 @@ function redirectLogin() {
|
|||||||
*/
|
*/
|
||||||
export async function apiFetch(url, options = {}) {
|
export async function apiFetch(url, options = {}) {
|
||||||
const { method = 'GET', body, ...opts } = options;
|
const { method = 'GET', body, ...opts } = options;
|
||||||
const headers = { 'Accept': 'application/json', ...opts.headers };
|
// Drop the caller's raw headers so the merged object (with the injected
|
||||||
|
// Authorization / X-Session-Id) always wins when spread into the fetch options.
|
||||||
|
const { headers: _callerHeaders, ...safeOpts } = opts;
|
||||||
|
const headers = { 'Accept': 'application/json', ..._callerHeaders };
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = 'Bearer ' + token;
|
headers['Authorization'] = 'Bearer ' + token;
|
||||||
const stored = getStoredAuth();
|
const auth = getAuthData();
|
||||||
headers['X-Session-Id'] = stored.session_id;
|
if (auth?.session_id) headers['X-Session-Id'] = auth.session_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body && typeof body === 'object' && !(body instanceof FormData)) {
|
if (body && typeof body === 'object' && !(body instanceof FormData)) {
|
||||||
@@ -161,29 +45,33 @@ export async function apiFetch(url, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
|
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...safeOpts });
|
||||||
if (opts.signal?.aborted) {
|
if (safeOpts.signal?.aborted) {
|
||||||
return { ok: false, data: null, error: 'Aborted', status: 0 };
|
return { ok: false, data: null, error: 'Aborted', status: 0 };
|
||||||
}
|
}
|
||||||
if (res.status === 401 && getAuthToken()) {
|
if (res.status === 401 && token) {
|
||||||
const refreshed = await tryRefreshToken();
|
await refreshAuth();
|
||||||
if (refreshed) {
|
const auth = getAuthData();
|
||||||
const refreshedStored = getStoredAuth();
|
if (auth?.token) {
|
||||||
headers['Authorization'] = 'Bearer ' + getAuthToken();
|
headers['Authorization'] = 'Bearer ' + auth.token;
|
||||||
headers['X-Session-Id'] = refreshedStored.session_id;
|
headers['X-Session-Id'] = auth.session_id; // rotated — re-read from model
|
||||||
const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
|
const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...safeOpts });
|
||||||
if (retryRes.ok) {
|
if (retryRes.ok) {
|
||||||
const json = await retryRes.json().catch(() => null);
|
const json = await retryRes.json().catch(() => null);
|
||||||
return { ok: json?.ok ?? true, data: json ? (json.ok ? json.data : json) : null, error: null, status: retryRes.status };
|
return { ok: json?.ok ?? true, data: json ? (json.ok ? json.data : json) : null, error: null, status: retryRes.status };
|
||||||
}
|
}
|
||||||
if (retryRes.status === 401) {
|
if (retryRes.status === 401) {
|
||||||
redirectLogin();
|
// Refresh succeeded but the retry is still 401 — the session is
|
||||||
|
// dead. Drive the model to the terminal logout state; its
|
||||||
|
// onSuccess clears storage and redirects to #/login.
|
||||||
|
modelFetch('auth', { action: 'logout' });
|
||||||
return { ok: false, data: null, error: 'Session expired', status: 401 };
|
return { ok: false, data: null, error: 'Session expired', status: 401 };
|
||||||
}
|
}
|
||||||
const json = await retryRes.json().catch(() => null);
|
const json = await retryRes.json().catch(() => null);
|
||||||
return { ok: false, data: null, error: json?.error || `HTTP ${retryRes.status}`, status: retryRes.status };
|
return { ok: false, data: null, error: json?.error || `HTTP ${retryRes.status}`, status: retryRes.status };
|
||||||
}
|
}
|
||||||
redirectLogin();
|
// No token after the refresh — onSuccess already cleared storage
|
||||||
|
// and redirected to #/login.
|
||||||
return { ok: false, data: null, error: 'Session expired', status: 401 };
|
return { ok: false, data: null, error: 'Session expired', status: 401 };
|
||||||
}
|
}
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
@@ -197,12 +85,6 @@ export async function apiFetch(url, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Export auth helpers for use by other modules.
|
|
||||||
*/
|
|
||||||
export { setAuthToken, clearAuthTokens, getAuthToken, tryRefreshToken, redirectLogin };
|
|
||||||
|
|
||||||
|
|
||||||
/** ─── Toast notifications ────────────────────────────────── */
|
/** ─── Toast notifications ────────────────────────────────── */
|
||||||
|
|
||||||
/** Toast notification queue. Exported for ToastContainer component. */
|
/** Toast notification queue. Exported for ToastContainer component. */
|
||||||
|
|||||||
Reference in New Issue
Block a user