Add session management features: implement user session revocation on password change, enhance session validation, and update UI for password change functionality

This commit is contained in:
tom.hempel
2026-06-01 23:11:00 +02:00
parent e4d55f4c90
commit 43c47bca6d
13 changed files with 472 additions and 200 deletions

View File

@ -4,6 +4,95 @@ 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 || json.error || '';
return res.status === 403 && (
errMsg === 'Invalid or expired token'
|| errMsg === 'Missing Bearer token'
|| errMsg === 'Password change required before access'
|| json.error?.code === 'UNAUTHORIZED'
|| json.error?.code === 'PASSWORD_CHANGE_REQUIRED'
);
}
/**
* 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 || {};
@ -18,16 +107,7 @@ async function apiFetch(endpoint, options = {}) {
const res = await fetch(`${API_BASE}/${cleanEndpoint}`, { ...options, headers });
if (res.status === 401 || res.status === 403) {
const json = await res.json().catch(() => ({}));
const errMsg = json.error?.message || json.error || '';
if (res.status === 401 || errMsg === 'Invalid or expired token') {
localStorage.removeItem('qdb_token');
localStorage.removeItem('qdb_user');
localStorage.removeItem('qdb_role');
window.location.hash = '#/login';
throw new Error('Session expired');
}
throw new Error(errMsg || 'Permission denied');
await handleAuthResponse(res);
}
return res;
@ -98,5 +178,13 @@ export async function apiDownloadFetch(url, options = {}) {
const token = getToken();
const headers = { ...(options.headers || {}), 'X-QDB-Client': 'web' };
if (token) headers['Authorization'] = `Bearer ${token}`;
return fetch(url, { ...options, headers });
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;
}