From 522374584c906591cc56d7cb50ecd3e9d6f2f08e Mon Sep 17 00:00:00 2001 From: Mathew Beane Date: Fri, 2 Oct 2026 22:12:23 -0400 Subject: [PATCH] fix(ui): stop retrying and ask for the API token when the server requires one With RUVIEW_API_TOKEN set on the server and no token in the browser, the sensing socket was refused, closed with 1006, and retried forever while the header said "Connecting...". A browser cannot see the 401 on a refused upgrade, so the service could not tell this from an outage. - ws-ticket.js: resolveWsUrl() reports a stored token the server refuses (401/403) as 'rejected'. probeWsAuthRequired() repeats the ticket request with no bearer after an unticketed socket fails to open; a 401 means the server wants a token this browser does not have ('missing'). The probe only runs on the failure path, so auth-off servers see no extra request. withWsTicket() keeps its string contract for the other callers. - sensing.service.js: new 'auth-required' state and dataSource. Entering it clears the retry timer; reconnect() leaves it. - The header widget shows "Token required", the global banner explains it, and both open QuickSettings on API Access (once automatically per page load, then on click). Tab banners and the dashboard indicator get labels. - Tests: ws-ticket.test.mjs (+6) and a new sensing.service.test.mjs (5), added to the CI UI test step. Fixes #2099 Co-Authored-By: claude-flow --- .github/workflows/ci.yml | 2 +- ui/README.md | 3 +- ui/components/DashboardTab.js | 1 + ui/components/LiveDemoTab.js | 3 + ui/components/SensingTab.js | 2 + ui/services/sensing.service.js | 75 +++++++++++++-- ui/services/sensing.service.test.mjs | 137 +++++++++++++++++++++++++++ ui/services/ws-ticket.js | 69 ++++++++++++-- ui/services/ws-ticket.test.mjs | 54 +++++++++++ ui/utils/connection-status.js | 27 ++++++ ui/utils/data-source-banner.js | 18 +++- ui/utils/quick-settings.js | 20 ++++ 12 files changed, 392 insertions(+), 19 deletions(-) create mode 100644 ui/services/sensing.service.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32cc2a6a7..ef6f74f7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,7 +204,7 @@ jobs: node-version: '22' - name: Run UI unit tests - run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs + run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/services/sensing.service.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs # Unit and Integration Tests # Python pytest matrix — runs against the archived v1 Python tree. diff --git a/ui/README.md b/ui/README.md index 75fcd803a..d406aa463 100644 --- a/ui/README.md +++ b/ui/README.md @@ -82,13 +82,14 @@ ui/ ## Data Sources -The sensing service (`sensing.service.js`) supports three connection states: +The sensing service (`sensing.service.js`) reports these connection states: | State | Banner Color | Description | |-------|-------------|-------------| | **LIVE - ESP32** | Green | Connected to the Rust sensing server receiving real CSI data | | **RECONNECTING** | Yellow (pulsing) | WebSocket disconnected, retrying (up to 20 attempts) | | **SIMULATED DATA** | Red | Fallback to client-side simulation after 5+ failed reconnects | +| **API TOKEN REQUIRED** | Red | The server has `RUVIEW_API_TOKEN` set and this browser has no valid token. Retries stop; click the banner or open Settings → API Access | Simulated frames include a `_simulated: true` marker so code can detect synthetic data. diff --git a/ui/components/DashboardTab.js b/ui/components/DashboardTab.js index 5e505fa99..56fc039c5 100644 --- a/ui/components/DashboardTab.js +++ b/ui/components/DashboardTab.js @@ -97,6 +97,7 @@ export class DashboardTab { 'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' }, 'unreachable': { text: 'NO DATA', status: 'unhealthy', msg: 'Server unreachable — readings below are stale' }, 'simulated': { text: 'INVENTED', status: 'unhealthy', msg: 'Browser-generated data, not measured' }, + 'auth-required': { text: 'TOKEN REQUIRED', status: 'unhealthy', msg: 'Paste the server API token in Settings \u2192 API Access' }, }; const cfg = config[ds] || config['reconnecting']; el.className = `component-status status-${cfg.status}`; diff --git a/ui/components/LiveDemoTab.js b/ui/components/LiveDemoTab.js index b4de941c3..96e56300b 100644 --- a/ui/components/LiveDemoTab.js +++ b/ui/components/LiveDemoTab.js @@ -1320,6 +1320,7 @@ export class LiveDemoTab { if (ds === 'server-simulated') return 'Active \u2014 Simulated Data'; if (ds === 'unreachable') return 'No Data \u2014 Server Unreachable'; if (ds === 'simulated') return 'INVENTED DATA \u2014 Not Measured'; + if (ds === 'auth-required') return 'API Token Required'; return 'Connecting...'; } @@ -1334,6 +1335,7 @@ export class LiveDemoTab { 'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' }, 'unreachable': { text: 'NO DATA \u2014 Server Unreachable, Display Is Stale', cls: 'demo-source-offline' }, 'simulated': { text: 'INVENTED DATA \u2014 Generated By Your Browser, Not Measured', cls: 'demo-source-offline' }, + 'auth-required': { text: 'API TOKEN REQUIRED \u2014 Settings \u2192 API Access', cls: 'demo-source-offline' }, }; const cfg = config[ds] || config['reconnecting']; banner.textContent = cfg.text; @@ -1374,6 +1376,7 @@ export class LiveDemoTab { 'server-simulated': 'Connected \u2014 Simulated', 'reconnecting': 'Reconnecting...', 'simulated': 'Offline \u2014 Simulated', + 'auth-required': 'API token required', }; const label = dsLabels[ds] || this.state.connectionState; elements.connectionStatus.textContent = label; diff --git a/ui/components/SensingTab.js b/ui/components/SensingTab.js index 4ff233bf9..32e81802f 100644 --- a/ui/components/SensingTab.js +++ b/ui/components/SensingTab.js @@ -216,6 +216,7 @@ export class SensingTab { connected: 'Connected', reconnecting: 'Reconnecting...', simulated: 'Simulated', + 'auth-required': 'API token required', }; dot.className = 'sensing-dot ' + state; text.textContent = stateLabels[state] || state; @@ -230,6 +231,7 @@ export class SensingTab { 'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' }, 'unreachable': { text: 'NO DATA \u2014 SERVER UNREACHABLE', cls: 'sensing-source-simulated' }, 'simulated': { text: 'INVENTED DATA \u2014 NOT MEASURED', cls: 'sensing-source-simulated' }, + 'auth-required': { text: 'API TOKEN REQUIRED \u2014 SETTINGS \u2192 API ACCESS', cls: 'sensing-source-simulated' }, }; const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting; banner.textContent = cfg.text; diff --git a/ui/services/sensing.service.js b/ui/services/sensing.service.js index 09f2e6b94..9cecd2d65 100644 --- a/ui/services/sensing.service.js +++ b/ui/services/sensing.service.js @@ -1,4 +1,4 @@ -import { withWsTicket } from './ws-ticket.js'; +import { resolveWsUrl, probeWsAuthRequired } from './ws-ticket.js'; import { apiService } from './api.service.js'; /** * Sensing WebSocket Service @@ -70,7 +70,7 @@ function clientSimulationAllowed() { } } -class SensingService { +export class SensingService { constructor() { /** @type {WebSocket|null} */ this._ws = null; @@ -84,13 +84,19 @@ class SensingService { // Bumped per connect attempt and by `stop()`; lets a socket recognise that // it has been abandoned and stop driving reconnect state. this._connectEpoch = 0; - // Connection state: disconnected | connecting | connected | reconnecting | simulated + // Connection state: disconnected | connecting | connected | reconnecting | + // simulated | auth-required this._state = 'disconnected'; + // #2099: why the server refused us, while in `auth-required`: + // 'missing' — the server needs a token and the browser has none + // 'rejected' — the stored token was refused + this._authRequired = null; // Data-source label exposed to the UI: // "live" — real ESP32 hardware connected // "server-simulated" — server is running but using synthetic data (no hardware) // "reconnecting" — WebSocket disconnected, retrying // "simulated" — client-side fallback simulation (server unreachable) + // "auth-required" — the server refused us for lack of a valid token this._dataSource = 'reconnecting'; // The raw source string from the server (e.g. "esp32", "simulated", "simulate") this._serverSource = null; @@ -152,6 +158,14 @@ class SensingService { return this._state; } + /** + * #2099: null, or why the server refused the stream — + * 'missing' (no token in this browser) or 'rejected' (stored token refused). + */ + get authRequired() { + return this._authRequired; + } + /** * Current data source label. * "live" — frames are arriving from the real ESP32 over WebSocket @@ -194,8 +208,9 @@ class SensingService { this._setState('connecting'); let url = SENSING_WS_URL; + let authRequired = null; try { - url = await withWsTicket(SENSING_WS_URL); + ({ url, authRequired } = await resolveWsUrl(SENSING_WS_URL)); } catch { // Ticket minting is best-effort: against a server with auth off, or one // predating ADR-272, connecting without a ticket is correct. @@ -208,6 +223,15 @@ class SensingService { return; } + if (authRequired) { + // #2099: the stored token was refused. Retrying with it cannot succeed. + this._connectInFlight = false; + this._enterAuthRequired(authRequired); + return; + } + const ticketed = url !== SENSING_WS_URL; + let opened = false; + let ws; try { ws = new WebSocket(url); @@ -230,6 +254,8 @@ class SensingService { return; } console.info('[Sensing] Connected to', SENSING_WS_URL); + opened = true; + this._authRequired = null; this._reconnectAttempt = 0; this._stopSimulation(); this._setState('connected'); @@ -261,7 +287,11 @@ class SensingService { } console.info('[Sensing] Connection closed (code=%d)', evt.code); this._ws = null; - if (evt.code !== 1000) { + if (evt.code !== 1000 && !opened && !ticketed) { + // #2099: a refused upgrade looks like any other failure from here, so + // ask the server whether it wants a token before retrying forever. + void this._handleRefusedConnect(epoch); + } else if (evt.code !== 1000) { this._scheduleReconnect(); } else { this._setState('disconnected'); @@ -270,6 +300,39 @@ class SensingService { }; } + async _handleRefusedConnect(epoch) { + const reason = await probeWsAuthRequired(); + // stop(), reconnect() or another attempt took over during the probe. + if (epoch !== this._connectEpoch || this._ws) return; + if (reason) { + this._enterAuthRequired(reason); + } else { + this._scheduleReconnect(); + } + } + + /** + * #2099: stop the retry loop and say why. Nothing is retried until the user + * supplies a token (QuickSettings reloads the page on save) or asks for a + * reconnect. + */ + _enterAuthRequired(reason) { + console.warn('[Sensing] Server requires an API token (%s); not retrying', reason); + this._clearTimers(); + this._reconnectAttempt = 0; + this._authRequired = reason; + this._setState('auth-required'); + this._setDataSource('auth-required'); + } + + /** Retry now, e.g. from the header's reconnect button. */ + reconnect() { + this._clearTimers(); + this._authRequired = null; + this._reconnectAttempt = 0; + void this._connect(); + } + _scheduleReconnect() { // A retry is already pending; stacking timers would multiply the attempt // count for a single outage. @@ -529,7 +592,7 @@ class SensingService { /** * Update the dataSource label and notify state listeners so the UI can * react without needing a separate subscription. - * @param {'live'|'server-simulated'|'reconnecting'|'simulated'} source + * @param {'live'|'server-simulated'|'reconnecting'|'unreachable'|'simulated'|'auth-required'} source */ _setDataSource(source) { if (source === this._dataSource) return; diff --git a/ui/services/sensing.service.test.mjs b/ui/services/sensing.service.test.mjs new file mode 100644 index 000000000..e08fa427d --- /dev/null +++ b/ui/services/sensing.service.test.mjs @@ -0,0 +1,137 @@ +// Executed regression test for #2099: with RUVIEW_API_TOKEN set on the server +// and no token in the browser, the sensing service retried forever and the +// header said "Connecting..." with no hint that a token was needed. +// +// Run: node --test ui/services/sensing.service.test.mjs +// +// This EXECUTES SensingService in Node with a stub WebSocket that refuses the +// upgrade the way a browser reports a 401 (close code 1006, no open), and a +// stubbed fetch standing in for the server. It does not drive a browser or the +// page wiring (banner, header widget, QuickSettings). + +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +const STORAGE_KEY = 'ruview-api-token'; + +let stored = {}; +let fetchCalls = []; +// Server stand-in: auth on, no session. Status probe and ticket mint both 401. +let ticketStatus = 401; +let sockets = []; +let refuseUpgrade = true; + +globalThis.localStorage = { + getItem: (k) => (k in stored ? stored[k] : null), + setItem: (k, v) => { stored[k] = String(v); }, + removeItem: (k) => { delete stored[k]; }, +}; +globalThis.fetch = async (path, init = {}) => { + fetchCalls.push([path, init]); + if (path === '/api/v1/ws-ticket') { + const ok = ticketStatus === 200; + return { ok, status: ticketStatus, json: async () => (ok ? { ticket: 'T' } : {}) }; + } + return { ok: true, status: 200, json: async () => ({ source: 'simulated' }) }; +}; + +class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + constructor(url) { + this.url = url; + this.readyState = 0; + sockets.push(this); + setTimeout(() => { + if (refuseUpgrade) { + this.readyState = 3; + this.onerror?.({}); + this.onclose?.({ code: 1006 }); + } else { + this.readyState = 1; + this.onopen?.(); + } + }, 0); + } + close() { this.readyState = 3; } +} +globalThis.WebSocket = FakeWebSocket; + +const { SensingService } = await import('./sensing.service.js'); + +const settle = (ms = 30) => new Promise((r) => setTimeout(r, ms)); +let svc; + +beforeEach(() => { + stored = {}; + fetchCalls = []; + ticketStatus = 401; + sockets = []; + refuseUpgrade = true; + svc = new SensingService(); +}); + +afterEach(() => svc.stop()); + +test('no token + server requires one: stops retrying and says why', async () => { + const states = []; + svc.onStateChange((s) => states.push(s)); + svc.start(); + await settle(); + + assert.equal(svc.state, 'auth-required'); + assert.equal(svc.dataSource, 'auth-required'); + assert.equal(svc.authRequired, 'missing'); + assert.equal(svc._reconnectTimer, null, 'no retry may be pending'); + + // Before the fix this kept opening sockets on a 1-16 s back-off forever. + await settle(1200); + assert.equal(sockets.length, 1, 'exactly one refused attempt, then no retries'); + assert.ok(!states.includes('reconnecting'), `went through reconnecting: ${states}`); +}); + +test('a stored token the server rejects is reported without opening a socket', async () => { + stored[STORAGE_KEY] = 'stale'; + svc.start(); + await settle(); + assert.equal(svc.state, 'auth-required'); + assert.equal(svc.authRequired, 'rejected'); + assert.equal(sockets.length, 0); +}); + +test('a plain outage still reconnects (auth off, server down)', async () => { + // Server unreachable: the probe's fetch fails too. + const realFetch = globalThis.fetch; + globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); }; + try { + svc.start(); + await settle(); + assert.equal(svc.state, 'reconnecting'); + assert.equal(svc.authRequired, null); + assert.ok(svc._reconnectTimer, 'a retry must be scheduled'); + } finally { + globalThis.fetch = realFetch; + } +}); + +test('auth off and server up: no probe on the happy path', async () => { + ticketStatus = 200; + refuseUpgrade = false; + svc.start(); + await settle(); + assert.equal(svc.state, 'connected'); + assert.ok(!fetchCalls.some(([p]) => p === '/api/v1/ws-ticket'), + 'no ticket request when no token is stored and the socket opens'); +}); + +test('reconnect() leaves auth-required and tries again', async () => { + svc.start(); + await settle(); + assert.equal(svc.state, 'auth-required'); + + refuseUpgrade = false; + svc.reconnect(); + await settle(); + assert.equal(svc.state, 'connected'); + assert.equal(svc.authRequired, null); +}); diff --git a/ui/services/ws-ticket.js b/ui/services/ws-ticket.js index 9870562af..e9b017aeb 100644 --- a/ui/services/ws-ticket.js +++ b/ui/services/ws-ticket.js @@ -27,12 +27,16 @@ function storedToken() { } /** - * Mint a ticket. Returns null when no token is configured (auth is off, so no - * ticket is needed) or when the server does not offer the endpoint. + * Mint a ticket. Resolves to `{ ticket, authRequired }`: + * + * - `ticket` is null when no token is configured (auth is off, so no ticket is + * needed) or when the server does not offer the endpoint. + * - `authRequired` is `'rejected'` when the stored token was refused (401/403), + * so the caller can stop retrying with it. It is null otherwise. */ async function mintTicket() { const token = storedToken(); - if (!token) return null; + if (!token) return { ticket: null, authRequired: null }; try { const resp = await fetch('/api/v1/ws-ticket', { @@ -42,18 +46,66 @@ async function mintTicket() { // 404 means a server predating ADR-272: it still exempts WebSockets, so // connecting without a ticket is correct there. Treated as "no ticket // needed" rather than as an error, so the UI works against both. - if (resp.status === 404) return null; + if (resp.status === 404) return { ticket: null, authRequired: null }; + // #2099: a wrong or revoked token. Retrying with it can never succeed. + if (resp.status === 401 || resp.status === 403) { + console.warn('[ws-ticket] stored API token was rejected:', resp.status); + return { ticket: null, authRequired: 'rejected' }; + } if (!resp.ok) { console.warn('[ws-ticket] mint failed:', resp.status); - return null; + return { ticket: null, authRequired: null }; } const body = await resp.json(); - return body.ticket || null; + return { ticket: body.ticket || null, authRequired: null }; } catch (err) { // Offline, or the server is down. The socket attempt will fail on its own // and the caller's reconnect logic handles it; failing loudly here would // just duplicate that. console.warn('[ws-ticket] mint error:', err.message); + return { ticket: null, authRequired: null }; + } +} + +/** + * Like `withWsTicket`, but also reports when the stored token was rejected. + * Resolves to `{ url, authRequired }` with `authRequired` null or `'rejected'`. + * + * @param {string} url ws:// or wss:// URL + * @returns {Promise<{url: string, authRequired: null|'rejected'}>} + */ +export async function resolveWsUrl(url) { + const { ticket, authRequired } = await mintTicket(); + if (!ticket) return { url, authRequired }; + const sep = url.includes('?') ? '&' : '?'; + return { url: `${url}${sep}ticket=${encodeURIComponent(ticket)}`, authRequired: null }; +} + +/** + * #2099: ask the server whether a socket refusal was an auth refusal. + * + * A browser cannot see the status of a refused WebSocket upgrade; it only gets + * a close event. So after an unticketed socket closes without ever opening, + * repeat the ticket request with no bearer. The server answers 401 exactly + * when it requires a credential the browser does not have (a signed-in session + * cookie still counts, since `fetch` sends it same-origin). Returns + * `'missing'` in that case and null for anything else, including a network + * failure, which is an outage rather than an auth problem. + * + * Only called on the failure path, so a server with auth off sees no extra + * request while it is reachable. + * + * @returns {Promise} + */ +export async function probeWsAuthRequired() { + if (storedToken()) return null; // mintTicket already judged the stored token + try { + const resp = await fetch('/api/v1/ws-ticket', { + method: 'POST', + credentials: 'same-origin', + }); + return resp.status === 401 || resp.status === 403 ? 'missing' : null; + } catch { return null; } } @@ -70,8 +122,5 @@ async function mintTicket() { * @returns {Promise} */ export async function withWsTicket(url) { - const ticket = await mintTicket(); - if (!ticket) return url; - const sep = url.includes('?') ? '&' : '?'; - return `${url}${sep}ticket=${encodeURIComponent(ticket)}`; + return (await resolveWsUrl(url)).url; } diff --git a/ui/services/ws-ticket.test.mjs b/ui/services/ws-ticket.test.mjs index e96ea4dfc..b428acfed 100644 --- a/ui/services/ws-ticket.test.mjs +++ b/ui/services/ws-ticket.test.mjs @@ -111,3 +111,57 @@ test('a fresh ticket is minted per call and never reused', async () => { assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T2'); assert.equal(fetchCalls.length, 2, 'each connect attempt must mint its own'); }); + +// ---- #2099: auth-required detection ---------------------------------------- + +const { resolveWsUrl, probeWsAuthRequired } = await import('./ws-ticket.js'); + +test('a stored token that the server refuses is reported as rejected', async () => { + // Retrying with a token the server has refused can never succeed, so the + // caller needs to know rather than loop. + stored[STORAGE_KEY] = 'wrong'; + for (const status of [401, 403]) { + fetchImpl = async () => ({ ok: false, status, json: async () => ({}) }); + const r = await resolveWsUrl('ws://host/ws/sensing'); + assert.deepEqual(r, { url: 'ws://host/ws/sensing', authRequired: 'rejected' }); + } +}); + +test('withWsTicket keeps its string contract when the token is refused', async () => { + stored[STORAGE_KEY] = 'wrong'; + fetchImpl = async () => ({ ok: false, status: 401, json: async () => ({}) }); + assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing'); +}); + +test('a 503 or a 404 is not an auth refusal', async () => { + stored[STORAGE_KEY] = 'b'; + for (const status of [404, 503]) { + fetchImpl = async () => ({ ok: false, status, json: async () => ({}) }); + assert.equal((await resolveWsUrl('ws://h/ws/sensing')).authRequired, null); + } +}); + +test('the probe reports a missing token when the server answers 401 with no bearer', async () => { + fetchImpl = async () => ({ ok: false, status: 401, json: async () => ({}) }); + assert.equal(await probeWsAuthRequired(), 'missing'); + const [path, init] = fetchCalls[0]; + assert.equal(path, '/api/v1/ws-ticket'); + assert.equal(init.method, 'POST'); + assert.equal(init.headers, undefined, 'the probe must not send a bearer'); + assert.equal(init.credentials, 'same-origin', 'a signed-in session cookie still counts'); +}); + +test('the probe treats success, other errors and network failure as not-auth', async () => { + fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) }); + assert.equal(await probeWsAuthRequired(), null, 'auth off or a valid session'); + fetchImpl = async () => ({ ok: false, status: 500, json: async () => ({}) }); + assert.equal(await probeWsAuthRequired(), null); + fetchImpl = async () => { throw new Error('offline'); }; + assert.equal(await probeWsAuthRequired(), null, 'an outage is not an auth problem'); +}); + +test('the probe does not second-guess a stored token', async () => { + stored[STORAGE_KEY] = 'b'; + assert.equal(await probeWsAuthRequired(), null); + assert.equal(fetchCalls.length, 0); +}); diff --git a/ui/utils/connection-status.js b/ui/utils/connection-status.js index b4ef75a7a..a0be0aa6d 100644 --- a/ui/utils/connection-status.js +++ b/ui/utils/connection-status.js @@ -3,6 +3,11 @@ import { sensingService } from '../services/sensing.service.js'; +/** Ask QuickSettings to open on its API Access section (#2099). */ +export function openApiAccess(reason) { + document.dispatchEvent(new CustomEvent('ruview-open-api-access', { detail: { reason } })); +} + export class ConnectionStatus { constructor() { this.widget = null; @@ -27,6 +32,15 @@ export class ConnectionStatus { `; + // #2099: while the server wants a token, the widget is the way to it. + this.widget.addEventListener('click', (e) => { + if (sensingService.state !== 'auth-required') return; + if (e.target.closest('.conn-status-reconnect')) return; + // QuickSettings closes on any outside click; this click must not count. + e.stopPropagation(); + openApiAccess(sensingService.authRequired); + }); + this.widget.querySelector('.conn-status-reconnect').addEventListener('click', () => { this.setStatus('reconnecting', 'Reconnecting...'); sensingService.reconnect?.(); @@ -58,6 +72,17 @@ export class ConnectionStatus { this.setStatus('connected', label); } else if (state === 'connecting' || state === 'reconnecting') { this.setStatus('reconnecting', 'Connecting...'); + } else if (state === 'auth-required') { + this.setStatus('error', 'Token required'); + this.widget.title = 'This server requires an API token. Click to open Settings \u2192 API Access.'; + // Open the panel once per page load; after that the widget stays + // clickable but does not keep popping the panel up. + if (!this._openedApiAccess) { + this._openedApiAccess = true; + // Deferred so QuickSettings, initialised after this widget in the same + // synchronous pass, is listening by the time the event fires. + setTimeout(() => openApiAccess(sensingService.authRequired), 0); + } } else if (state === 'error') { this.setStatus('error', 'Error'); } else { @@ -67,6 +92,8 @@ export class ConnectionStatus { setStatus(status, label) { if (!this.widget) return; + if (sensingService.state !== 'auth-required') this.widget.removeAttribute('title'); + this.widget.style.cursor = sensingService.state === 'auth-required' ? 'pointer' : ''; this.widget.className = `conn-status conn-status-${status}`; this.widget.querySelector('.conn-status-label').textContent = label; diff --git a/ui/utils/data-source-banner.js b/ui/utils/data-source-banner.js index d5a412eb1..00c1d993d 100644 --- a/ui/utils/data-source-banner.js +++ b/ui/utils/data-source-banner.js @@ -11,6 +11,7 @@ // meaningful. import { sensingService } from '../services/sensing.service.js'; +import { openApiAccess } from './connection-status.js'; const STYLE_ID = 'ruview-data-source-banner-style'; const BAR_ID = 'ruview-data-source-banner'; @@ -37,6 +38,13 @@ const CONFIG = { bg: '#6b0f6b', fg: '#ffc9ff', }, + // #2099: the server refused the stream. Clicking the bar opens API Access. + 'auth-required': { + text: 'API TOKEN REQUIRED — this server needs its API token. Click here, or Settings → API Access', + textRejected: 'API TOKEN REJECTED — the saved token was refused. Click here, or Settings → API Access', + bg: '#5c1010', + fg: '#ffc9c9', + }, }; const CSS = ` @@ -90,6 +98,12 @@ export class DataSourceBanner { // message describes context, not an error to act on right now. this._bar.setAttribute('aria-live', 'polite'); document.body.appendChild(this._bar); + this._bar.addEventListener('click', (e) => { + if (sensingService.dataSource !== 'auth-required') return; + // QuickSettings closes on any outside click; this click must not count. + e.stopPropagation(); + openApiAccess(sensingService.authRequired); + }); } // dataSource changes are published on the state-listener channel. @@ -107,7 +121,9 @@ export class DataSourceBanner { return; } - this._bar.textContent = cfg.text; + this._bar.textContent = + cfg.textRejected && sensingService.authRequired === 'rejected' ? cfg.textRejected : cfg.text; + this._bar.style.cursor = sensingService.dataSource === 'auth-required' ? 'pointer' : ''; this._bar.style.background = cfg.bg; this._bar.style.color = cfg.fg; this._bar.classList.add('is-visible'); diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js index 480ad1cbd..e3f78a5af 100644 --- a/ui/utils/quick-settings.js +++ b/ui/utils/quick-settings.js @@ -196,6 +196,10 @@ export class QuickSettings { document.body.appendChild(this.panel); + // #2099: the dashboard asks for this when the server refused the stream + // for lack of a valid token. + document.addEventListener('ruview-open-api-access', (e) => this.openApiAccess(e.detail?.reason)); + // Close on outside click document.addEventListener('click', (e) => { if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) { @@ -245,6 +249,22 @@ export class QuickSettings { void refreshSignInPanel(this.panel); } + /** Open on the API Access section and say why (#2099). */ + openApiAccess(reason) { + this.open(); + const status = this.panel.querySelector('#qs-api-token-status'); + if (status) { + status.textContent = reason === 'rejected' + ? 'The server rejected the saved token. Paste the current RUVIEW_API_TOKEN and Save & Apply.' + : 'This server requires an API token. Paste RUVIEW_API_TOKEN and Save & Apply.'; + } + const input = this.panel.querySelector('#qs-api-token'); + if (input) { + input.scrollIntoView?.({ block: 'center' }); + input.focus(); + } + } + close() { this.isOpen = false; this.panel.classList.remove('open');