diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e46997e3..9e92106c0 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 ui/components/DashboardTab.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 ui/components/DashboardTab.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 b3a541464..6588f28d6 100644 --- a/ui/components/DashboardTab.js +++ b/ui/components/DashboardTab.js @@ -117,6 +117,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');