diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d48d9d516 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to Odysseus will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Fixed + +- **auth: prevent spurious login redirect on mobile during text input** + The global `window.fetch` 401 interceptor (`static/app.js`) unconditionally + redirected to `/login` on any background API call that returned 401. On mobile, + when the user backgrounded the app (switched apps, locked phone), session + cookies could become stale. When they returned, background fetches (task + notification polling every 30s, calendar refetch on tab resume, notes badge + refresh) received 401 responses and triggered an immediate redirect to + `/login`, which wiped the user's unsent text input and chat state. + + This was especially noticeable on mobile browsers which aggressively suspend + background tabs and expire cookies faster than desktop browsers. + + The fix adds three improvements to the 401 interceptor: + 1. **Retry once after 2s** — catches transient 401s from stale cookies on + mobile tab resume that resolve on retry. + 2. **Deduplication** — prevents multiple stacked redirects from concurrent + background fetches (e.g. task polling + calendar refetch simultaneously + hitting 401). + 3. **Typing guard** — if the user is actively typing in the message input + when the redirect fires, it defers until the input loses focus, preserving + the draft. diff --git a/static/app.js b/static/app.js index 97f0ae77e..381916f78 100644 --- a/static/app.js +++ b/static/app.js @@ -185,11 +185,27 @@ function initRailHoverLabels() { }); } -// Redirect to login on 401 from any fetch +// Redirect to login on 401 from any fetch — but retry once first to avoid +// spurious redirects on mobile when background tabs resume after cookie staleness. +// If the user is mid-typing, delay the redirect so their draft isn't wiped. +let _401Cooldown = false; const _origFetch = window.fetch; window.fetch = async function(...args) { const res = await _origFetch.apply(this, args); - if (res.status === 401 && !String(args[0]).includes('/api/auth/')) { + if (res.status !== 401 || String(args[0]).includes('/api/auth/')) return res; + if (_401Cooldown) return res; + _401Cooldown = true; + await new Promise(r => setTimeout(r, 2000)); + try { + const retry = await _origFetch.apply(this, args); + if (retry.status !== 401) { _401Cooldown = false; return res; } + } catch (_) {} + const msgInput = document.getElementById('message'); + const isTyping = msgInput && msgInput === document.activeElement && msgInput.value.trim().length > 0; + if (isTyping) { + const _onBlur = () => { msgInput.removeEventListener('blur', _onBlur); window.location.href = '/login'; }; + msgInput.addEventListener('blur', _onBlur); + } else { window.location.href = '/login'; } return res;