From f986a41ccc46d0841350c757328b64460775229b Mon Sep 17 00:00:00 2001 From: th Date: Sat, 18 Apr 2026 12:50:44 +0200 Subject: [PATCH] =?UTF-8?q?v2.0.4=20=E2=80=94=20re-auth=20on=20401,=20robu?= =?UTF-8?q?st=20bucket-switch=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth: when a background poll or save returns 401 (session expired while the UI is still open), the api() helpers now prompt for the password instead of letting the save fail silently. Concurrent 401s collapse onto a single dialog via a shared reauthPromise, and each caller retries its original request once after login. /api/auth/* is excluded from the retry path. Extracts promptLogin() in app.js and exposes it as window._reauth so both the core util.js api() and the plugin-SDK hyprcrd.js api() can share the flow. - tests: the "open-switches-bucket" detail test assumed buckets[0] was a plain top-level bucket, but the bar renders super-bucket parents with data-bucket-super and hides child buckets entirely, so the .bucket-tab.active[data-bucket] selector found nothing on setups with nested layouts. Test now picks two plain top-level buckets or skips. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.php | 2 +- electron/package.json | 2 +- public/js/app.js | 36 +++++++++++++++++++++-------- public/js/hyprcrd.js | 27 +++++++++++++++------- public/js/modules/util.js | 24 +++++++++++++------ public/plugins/tests/detail.test.js | 13 +++++++++-- 6 files changed, 76 insertions(+), 28 deletions(-) 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.