Skip to content

Commit aa2eaf8

Browse files
committed
fix(auth): smoother session-expiry recovery (no toast spam, no focus theft)
When a session expired, every parallel in-flight API call returned 401 and each one independently spawned a "Failed to load X: Unauthorized" toast — burying the login form under 5-15 stacked errors. _showLogin() also cloned the form node on every 401, stealing focus from anyone trying to type their password. - Toast.muteErrorsForMs(ms): drops error/warning toasts during auth transitions. Set to 6s on the first 401, self-extends if more arrive. - App.handleUnauthorized(): now idempotent (_inUnauthState flag) and destroys the current page so its setInterval polling stops. - App._showLogin(): if already visible, reuses the existing form bindings instead of cloning. Best-effort focus restore. - Auto-focus on #login-user 50ms after _showLogin() so re-auth is type → tab → type → enter, no mouse needed. - _inUnauthState cleared in _showApp() so future expirations work. Also updates whatsnew.js with entries for v7.3.0, v7.3.1, and v7.2.1. Release: v7.3.1
1 parent 4191c49 commit aa2eaf8

8 files changed

Lines changed: 124 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
All notable changes to Docker Dash are documented here.
44

5+
## [7.3.1] - 2026-04-25 — Smoother session-expiry recovery
6+
7+
When a session expired, the UX collapsed: every parallel in-flight API call (containers list, stats, alerts, notifications, host overview…) returned 401 and each one independently:
8+
9+
1. Spawned a `Failed to load X: Unauthorized` red toast — burying the login form under 5-15 stacked errors.
10+
2. Called `App.handleUnauthorized()``_showLogin()` → which **cloned the login form node** to remove old listeners, **detaching whatever the user was typing into**. Focus disappeared mid-keystroke. Some users had to triple-click to re-focus the password field.
11+
3. Did nothing to stop the previous page's `setInterval` polling, so 401s kept arriving every few seconds and the cycle repeated.
12+
13+
This release fixes all three.
14+
15+
### Fixed
16+
17+
- **Toast spam during auth transitions** — added [`Toast.muteErrorsForMs(ms)`](public/js/components/toast.js). When `Api.request` sees a 401, it mutes error/warning toasts for 6s before calling `handleUnauthorized()`. The mute window self-extends if more 401s arrive (so a stuck `setInterval` doesn't break out after 6s).
18+
- **`App.handleUnauthorized()` is idempotent** — the first 401 transitions to login and sets `_inUnauthState = true`; subsequent 401s are no-ops until login succeeds. Cleared in `_showApp()` so a future expiration triggers fresh.
19+
- **`App._showLogin()` is idempotent** — if the screen is already visible, the existing form bindings are reused (no clone, no focus theft). Best-effort focus to `#login-user` if nothing else is focused.
20+
- **Stale polling stopped**`handleUnauthorized()` now destroys `_currentPage` (calling its `destroy()` to clear `_refreshTimer` / `_statsTimer` / etc.) so the previous page's intervals stop firing while the user is on the login screen.
21+
- **Auto-focus on login screen** — username field gets focus 50ms after `_showLogin()` so re-auth is `type → tab → type → enter` (no mouse).
22+
23+
### Files touched
24+
25+
- `public/js/components/toast.js``muteErrorsForMs` + `show()` mute gate
26+
- `public/js/api.js` — sets the mute window before calling `handleUnauthorized`, throws `Error` with `isAuthError = true` flag
27+
- `public/js/app.js` — idempotent `handleUnauthorized` + `_showLogin`, page destroy on 401, auto-focus, `_inUnauthState` cleared in `_showApp`
28+
- `public/js/pages/whatsnew.js` — entries for v7.3.1, v7.3.0, v7.2.1
29+
530
## [7.3.0] - 2026-04-25 — "Update Notifications"
631

732
Periodic, opt-out check for new Docker Dash releases on GitHub. Solves the "user cloned the repo a week ago and has no idea v7.3.0 shipped" gap. Designed to be **quiet**: a tiny pulsing ↑ badge next to the sidebar version, click-to-open modal with the full release notes (rendered from the GitHub Release `body`), and a one-click "show upgrade command" for admins.

docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ services:
44
context: .
55
dockerfile: Dockerfile
66
args:
7-
APP_VERSION: "${APP_VERSION:-7.3.0}"
8-
image: docker-dash:${APP_VERSION:-7.3.0}
7+
APP_VERSION: "${APP_VERSION:-7.3.1}"
8+
image: docker-dash:${APP_VERSION:-7.3.1}
99
container_name: docker-dash
1010
restart: unless-stopped
1111
env_file:
@@ -54,7 +54,7 @@ services:
5454
dd-egress-filter:
5555
build:
5656
context: ./docker/egress-filter
57-
image: docker-dash-egress-filter:${APP_VERSION:-7.3.0}
57+
image: docker-dash-egress-filter:${APP_VERSION:-7.3.1}
5858
container_name: dd-egress-filter
5959
restart: unless-stopped
6060
# Uses the default bridge so target containers on the default bridge can

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "docker-dash",
3-
"version": "7.3.0",
3+
"version": "7.3.1",
44
"description": "Full-featured Docker management dashboard",
55
"main": "src/server.js",
66
"scripts": {

public/js/api.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,14 @@ const Api = {
6161
try {
6262
const res = await fetch(`/api${this._appendHostId(path)}`, options);
6363
if (res.status === 401 && !path.startsWith('/auth/login')) {
64+
// v7.3.1: mute error toasts for 6s so parallel in-flight requests
65+
// don't bury the login form with "Failed to load X: Unauthorized".
66+
// App.handleUnauthorized() is idempotent so repeated 401s are harmless.
67+
if (typeof Toast !== 'undefined') Toast.muteErrorsForMs(6000);
6468
App.handleUnauthorized();
65-
throw new Error('Unauthorized');
69+
const err = new Error('Unauthorized');
70+
err.isAuthError = true;
71+
throw err;
6672
}
6773
const data = res.headers.get('content-type')?.includes('json')
6874
? await res.json()

public/js/app.js

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,23 @@ const App = {
7373
// ─── Auth ──────────────────────────────────────
7474

7575
_showLogin() {
76-
document.getElementById('login-screen').classList.remove('hidden');
77-
document.getElementById('app-shell').classList.add('hidden');
76+
const loginScreen = document.getElementById('login-screen');
77+
const appShell = document.getElementById('app-shell');
78+
79+
// v7.3.1: if the login screen is already visible, don't tear down + re-bind
80+
// the form. Doing so on every 401 stole keyboard focus mid-typing. The
81+
// form bindings from the previous _showLogin call are still good.
82+
if (!loginScreen.classList.contains('hidden')) {
83+
// Best-effort: focus the username field if nothing is focused yet
84+
const userField = document.getElementById('login-user');
85+
if (userField && document.activeElement === document.body) {
86+
try { userField.focus(); } catch { /* ignore */ }
87+
}
88+
return;
89+
}
90+
91+
loginScreen.classList.remove('hidden');
92+
appShell.classList.add('hidden');
7893
WS.disconnect();
7994

8095
// Check if OIDC is enabled and show SSO button
@@ -92,6 +107,11 @@ const App = {
92107
// IMPORTANT: re-query errEl from the NEW form (old ref is detached from DOM)
93108
const errEl = newForm.querySelector('#login-error');
94109

110+
// Auto-focus username so the user can start typing immediately
111+
setTimeout(() => {
112+
try { newForm.querySelector('#login-user')?.focus(); } catch { /* ignore */ }
113+
}, 50);
114+
95115
newForm.addEventListener('submit', async (e) => {
96116
e.preventDefault();
97117
errEl.classList.add('hidden');
@@ -354,6 +374,9 @@ const App = {
354374
},
355375

356376
_showApp() {
377+
// v7.3.1: clear unauth-state flag so a future session expiry triggers
378+
// the login transition again.
379+
this._inUnauthState = false;
357380
document.getElementById('login-screen').classList.add('hidden');
358381
document.getElementById('app-shell').classList.remove('hidden');
359382

@@ -1210,8 +1233,20 @@ const App = {
12101233
},
12111234

12121235
handleUnauthorized() {
1236+
// v7.3.1: idempotent. Multiple parallel 401s used to each tear down
1237+
// the login form (re-cloning it), stealing focus from the user trying
1238+
// to type their password. Now: first 401 transitions, subsequent 401s
1239+
// are no-ops until login succeeds.
1240+
if (this._inUnauthState) return;
1241+
this._inUnauthState = true;
12131242
this.user = null;
12141243
WS.disconnect();
1244+
// Stop the current page's timers/intervals so they don't keep firing
1245+
// 401s in a loop while the user is on the login screen.
1246+
if (this._currentPage?.destroy) {
1247+
try { this._currentPage.destroy(); } catch { /* page destroy errors are non-fatal */ }
1248+
}
1249+
this._currentPage = null;
12151250
this._showLogin();
12161251
},
12171252

public/js/components/toast.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
const Toast = {
77
_container: null,
8+
_muteErrorsUntil: 0, // v7.3.1: drop error/warning toasts during auth transitions
89

910
_getContainer() {
1011
if (!this._container) {
@@ -13,7 +14,21 @@ const Toast = {
1314
return this._container;
1415
},
1516

17+
/**
18+
* Suppress error + warning toasts for `ms` milliseconds. Used by the
19+
* auth layer when a 401 fires: in-flight parallel requests would
20+
* otherwise each spawn a "Failed to load X: Unauthorized" toast,
21+
* burying the login form.
22+
*/
23+
muteErrorsForMs(ms) {
24+
const until = Date.now() + ms;
25+
if (until > this._muteErrorsUntil) this._muteErrorsUntil = until;
26+
},
27+
1628
show(message, type = 'info', duration = 4000) {
29+
if ((type === 'error' || type === 'warning') && Date.now() < this._muteErrorsUntil) {
30+
return null;
31+
}
1732
const container = this._getContainer();
1833
if (!container) return;
1934

public/js/pages/whatsnew.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,41 @@ const WhatsNewPage = {
99
// Add new releases at the TOP of this array.
1010
// Types: feature, fix, improvement, security, breaking
1111
_releases: [
12+
{
13+
version: '7.3.1',
14+
date: '2026-04-25',
15+
title: 'Smoother session-expiry recovery',
16+
changes: [
17+
{ type: 'fix', text: 'When the session expired, parallel in-flight API calls each spawned a "Failed to load X: Unauthorized" toast — burying the login form under 5-15 red error toasts. Now: the first 401 mutes error/warning toasts for 6 seconds, so the login form stays clean. The mute window auto-extends if more 401s arrive (e.g. from setIntervals on the previously-active page).' },
18+
{ type: 'fix', text: 'Login form no longer steals keyboard focus mid-typing. Previously, every parallel 401 called _showLogin() which cloned the form node, detaching whatever the user was typing into. Now _showLogin() is idempotent — if the screen is already visible, the form bindings are reused and focus is preserved.' },
19+
{ type: 'fix', text: 'handleUnauthorized() is now idempotent and also destroys the current page (stopping its setInterval timers). Previously, a containers list page kept polling every few seconds while the user was on the login screen, generating a fresh 401 each tick and re-triggering the login dance.' },
20+
{ type: 'improvement', text: 'Username field auto-focuses when the login screen appears, so re-authenticating after a session timeout is now: type → tab → type → enter, with no mouse needed.' },
21+
],
22+
},
23+
{
24+
version: '7.3.0',
25+
date: '2026-04-25',
26+
title: 'In-app update notifications via GitHub releases',
27+
changes: [
28+
{ type: 'feature', text: 'Subtle pulsing ↑ badge appears next to the sidebar version when a newer Docker Dash release exists on GitHub. Click → modal with the release notes (rendered from this very Release body), publish date, last-checked timestamp, and a "View on GitHub" link.' },
29+
{ type: 'feature', text: 'Admin-only collapsed details inside the modal: copy-pasteable upgrade command (`git pull && APP_VERSION=X.Y.Z docker compose up -d --build app`) with a "back up /data first" reminder. Operators and viewers see the notes but not the command.' },
30+
{ type: 'feature', text: 'New System Settings → General card with a toggle ("Check for updates"), the last-checked timestamp, and a "Check now" button (admin-only). Default ON. Disable for fully air-gapped deployments — zero outbound calls, badge never appears.' },
31+
{ type: 'feature', text: 'Backend: src/services/update-check.js polls api.github.com/repos/<owner>/<repo>/releases/latest every 12 hours (configurable owner/repo via DD_UPDATE_CHECK_OWNER and DD_UPDATE_CHECK_REPO env vars). Cache lives in the settings table. Network failures preserve the existing cache (UI shows last known release until next successful poll).' },
32+
{ type: 'feature', text: 'Endpoints: GET /api/system/update-check (any auth user, for sidebar badge) + POST /api/system/update-check/refresh (admin, force) + POST /api/system/update-check/setting (admin, toggle, audited).' },
33+
{ type: 'improvement', text: 'HA-aware: the 12h cron + the 60s post-boot one-shot both run on the leader replica only, so 4-replica HA still makes 1 GitHub call per 12h (not 4). User-Agent is `docker-dash/<version>` — no install ID, no telemetry beyond what the TCP connection inherently exposes.' },
34+
{ type: 'improvement', text: 'Minimal markdown-to-HTML renderer in update-notifier.js (~190 LOC, no external deps) handles the subset GitHub release notes use: headings, bold/italic, inline code, fenced code blocks, lists, links. All input HTML-escaped first.' },
35+
{ type: 'improvement', text: '24 new tests (semver compare, enable/disable, getStatus state machine including cache JSON corruption tolerance, refresh HTTP behavior with mocked https). Suite: 907 → 931 / 60 suites. Lint clean, npm audit clean.' },
36+
],
37+
},
38+
{
39+
version: '7.2.1',
40+
date: '2026-04-23',
41+
title: 'Containers page TypeError + missing nav.observability key',
42+
changes: [
43+
{ type: 'fix', text: 'Fixed `TypeError: this._stopLogFollow is not a function` thrown on every navigation away from the containers list view. Root cause was the v6.16.0 lazy-load split: `_stopLogFollow` lives in the lazy-loaded container-detail.js module, but `destroy()` (eager containers.js) called it unconditionally. If the user never opened a detail view, the method didn\'t exist and the call crashed. Now guarded with a `typeof === "function"` check — harmless no-op when the detail module was never loaded.' },
44+
{ type: 'fix', text: 'Sidebar showed `nav.observability` as a raw string (missing translation). Added the key to the `nav:` block in EN + RO; other 9 languages fall back to EN automatically via `_fallback`.' },
45+
],
46+
},
1247
{
1348
version: '7.2.0',
1449
date: '2026-04-22',

src/version.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
// Single source of truth for the application version.
33
// Updated automatically by: npm version X.Y.Z (via scripts/sync-version.js)
44
// server.js reads this to inject into index.html at startup — no build step needed.
5-
module.exports = '7.3.0';
5+
module.exports = '7.3.1';

0 commit comments

Comments
 (0)