diff --git a/config.php b/config.php index 8b2564b..99029b7 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ { location.reload(); }); +// Re-usable login prompt loop. Shared between the boot-time auth check +// and the 401-driven re-auth path (session expiry while the UI is open). +// Concurrent callers collapse onto a single dialog via reauthPromise so a +// wave of background polls doesn't stack up N prompts. +let reauthPromise = null; +async function promptLogin(message = '') { + if (reauthPromise) return reauthPromise; + reauthPromise = (async () => { + while (true) { + const pw = await showDialog({ title: 'login', message, input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' }); + if (!pw) continue; + const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); + const d = await r.json(); + if (d.ok) { + if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } + return; + } + // wrong password — loop and re-prompt + } + })(); + try { await reauthPromise; } finally { reauthPromise = null; } +} +// Expose for the api() helpers so a 401 from any fetch triggers the same +// dialog + CSRF refresh flow. +window._reauth = () => promptLogin('Session expired — sign in to continue.'); + async function checkAuth() { const res = await fetch('api/auth/status'); const data = await res.json(); @@ -203,15 +229,7 @@ async function checkAuth() { if (d.error) { location.reload(); return; } if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } } else if (!data.logged_in) { - let ok = false; - while (!ok) { - const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' }); - if (!pw) continue; - const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); - const d = await r.json(); - if (d.ok) { ok = true; if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } } - else { /* failed login: caller will retry */ } - } + await promptLogin(); } loadBuckets(); diff --git a/public/js/hyprcrd.js b/public/js/hyprcrd.js index 52a9a38..78a3803 100644 --- a/public/js/hyprcrd.js +++ b/public/js/hyprcrd.js @@ -127,15 +127,26 @@ csrf() { return csrfToken; }, }; - /** Generic JSON API call with automatic CSRF header. */ + /** Generic JSON API call with automatic CSRF header. + * On 401 (session expired while the UI is open) we call the app's + * re-auth prompt once, pick up the refreshed CSRF token, and retry + * the original request so the caller's save / poll succeeds instead + * of silently returning an unauthorized error payload. */ async function api(path, opts = {}) { - const headers = { 'Content-Type': 'application/json' }; - if (csrfToken) headers['X-CSRF-Token'] = csrfToken; - const res = await fetch(path, { - method: opts.method || 'GET', - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - }); + const doFetch = () => { + const headers = { 'Content-Type': 'application/json' }; + if (csrfToken) headers['X-CSRF-Token'] = csrfToken; + return fetch(path, { + method: opts.method || 'GET', + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + }; + let res = await doFetch(); + if (res.status === 401 && !path.startsWith('api/auth/') && typeof window._reauth === 'function') { + await window._reauth(); + res = await doFetch(); + } return res.json(); } diff --git a/public/js/modules/util.js b/public/js/modules/util.js index 1497b2a..feb40f9 100644 --- a/public/js/modules/util.js +++ b/public/js/modules/util.js @@ -15,13 +15,23 @@ export function setCsrf(token) { csrfToken = token; } export function getCsrf() { return csrfToken; } export async function api(path, opts = {}) { - const headers = { 'Content-Type': 'application/json' }; - if (csrfToken) headers['X-CSRF-Token'] = csrfToken; - const res = await fetch(path, { - headers, - ...opts, - body: opts.body ? JSON.stringify(opts.body) : undefined, - }); + const doFetch = () => { + const headers = { 'Content-Type': 'application/json' }; + if (csrfToken) headers['X-CSRF-Token'] = csrfToken; + return fetch(path, { + headers, + ...opts, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + }; + let res = await doFetch(); + // Session expired mid-use: prompt for re-login, then retry once. Avoid + // recursion on auth endpoints themselves — promptLogin handles its own + // fetches directly. + if (res.status === 401 && !path.startsWith('api/auth/') && typeof window._reauth === 'function') { + await window._reauth(); + res = await doFetch(); + } return res.json(); } diff --git a/public/plugins/tests/detail.test.js b/public/plugins/tests/detail.test.js index 7cdf0bd..efc0cf7 100644 --- a/public/plugins/tests/detail.test.js +++ b/public/plugins/tests/detail.test.js @@ -86,7 +86,17 @@ describe('Detail: open-switches-bucket', () => { it('switches to the card bucket when the setting is on', async () => { const buckets = await fetch('api/buckets').then(r => r.json()); if (!Array.isArray(buckets) || !buckets.length) return; - const b = buckets[0]; + // The bucket bar renders top-level non-super buckets with + // `data-bucket=`, super-bucket parents with `data-bucket-super`, + // and hides child buckets behind the parent's dropdown. To keep + // this test robust across different user bucket layouts, pick two + // plain top-level buckets (no children, no parent). Skip when the + // fixture doesn't give us two. + const childParentIds = new Set(buckets.filter(x => x.parent_id).map(x => x.parent_id)); + const plainTop = buckets.filter(x => !x.parent_id && !childParentIds.has(x.id)); + if (plainTop.length < 2) return; + const b = plainTop[0]; + const other = plainTop[1]; const token = csrf(); const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token }; let card; @@ -99,7 +109,6 @@ describe('Detail: open-switches-bucket', () => { await sleep(100); // Start on a DIFFERENT bucket so the switch is visible. - const other = buckets.length > 1 ? buckets[1] : buckets[0]; window.state.activeBucket = String(other.id); // Enable the setting.