v2.0.4 — re-auth on 401, robust bucket-switch test

- 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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-18 12:50:44 +02:00
1 parent 48b5907f4c
commit f986a41ccc
6 files changed
+76 -28

No files matched your search

+27 -9
View File
@@ -180,6 +180,32 @@ document.getElementById('logout-btn')?.addEventListener('click', async () => {
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();
+19 -8
View File
@@ -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();
}
+17 -7
View File
@@ -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();
}
+11 -2
View File
@@ -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=<id>`, 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.