api: remove auth helpers, route 401 through auth model

This commit is contained in:
2026-08-15 00:53:05 +00:00
parent 5d84710d1a
commit 382bbd989b
+28 -146
View File
@@ -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. */