From c393c6b8e68a03bec88153b5c4fe984f21fa57e2 Mon Sep 17 00:00:00 2001 From: LakshmanTurlapati Date: Fri, 4 Sep 2026 19:41:12 -0500 Subject: [PATCH] Fix silent QR pairing failures on the showcase dashboard Scanning a pairing code that was expired, already used, or otherwise rejected reported nothing at all. handleScannedQR replaced the scan panel markup with the "Connecting..." state, which removed #dash-scan-error from the DOM, and only then called showScanError -- whose querySelector returned null, making it a silent no-op behind its `if (el)` guard. The panel was then rebuilt with a blank hidden error paragraph and switchTab('paste') hid the whole container. pairingErrorMessage() and its five localized strings were unreachable output. Add resetScanPanel() and failScan() so the panel is rebuilt before the error is written into it, and route all three failure paths through failScan. Failures now keep the user on the Scan tab and restart the camera, matching how library and camera startup failures already behave and letting the user scan a regenerated code without switching tabs. A `paired` flag keeps the scanner from returning if post-pairing wiring throws after a successful exchange. Tests: three Angular specs covering rejected exchange, malformed payload, and a payload with no token, each asserting the localized error is visible, the Scan tab stays active, and the scanner restarts. The existing source contract in dashboard-runtime-state only grepped for the pairingErrorMessage call, which is why this shipped; it now also pins the rebuild-before-show ordering and forbids switchTab('paste') in the scan failure paths. messages.xlf carries only regenerated linenumber metadata; no trans-unit IDs, sources, or targets changed. Co-authored-by: Cursor --- CHANGELOG.md | 4 +- .../dashboard-page.component.spec.ts | 72 +++++++++++++++++++ .../dashboard/dashboard-page.component.ts | 42 ++++++++--- showcase/angular/src/locale/messages.xlf | 28 ++++---- tests/dashboard-runtime-state.test.js | 13 ++++ 5 files changed, 133 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a8c9c4..2f6858b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ The independently published `fsb-mcp-server` npm package keeps its own semver ch ## [Unreleased] -Nothing yet. +### Fixed + +- **Dashboard QR pairing failures are visible again.** Scanning a pairing code that was expired, already used, or otherwise rejected reported nothing at all: the "Connecting..." state replaced the scan panel markup, so the error was written into a node that had already been removed from the DOM, and the dashboard then switched to the Paste Key tab. Every failure now renders its localized reason, keeps the user on the Scan tab, and restarts the camera so a regenerated code can be scanned directly. ## v0.9.91 — Version Metadata Alignment — 2026-07-14 diff --git a/showcase/angular/src/app/pages/dashboard/dashboard-page.component.spec.ts b/showcase/angular/src/app/pages/dashboard/dashboard-page.component.spec.ts index 14c504aa..06bf9ebf 100644 --- a/showcase/angular/src/app/pages/dashboard/dashboard-page.component.spec.ts +++ b/showcase/angular/src/app/pages/dashboard/dashboard-page.component.spec.ts @@ -87,6 +87,26 @@ describe('DashboardPageComponent QR scanner lifecycle', () => { }; } + // The scan success handler is the third argument html5-qrcode receives. + function decodeQR(payload: string): void { + const scanner = scannerInstances[scannerInstances.length - 1]; + const onDecode = scanner.start.calls.mostRecent().args[2] as (text: string) => void; + onDecode(payload); + flushMicrotasks(); + } + + function scanErrorEl(fixture: ComponentFixture): HTMLElement { + // Re-query rather than caching: a failed scan rebuilds the panel markup. + return fixture.nativeElement.querySelector('#dash-scan-error') as HTMLElement; + } + + function rejectPairing(code: string): jasmine.Spy { + return spyOn(window, 'fetch').and.returnValue(Promise.resolve({ + ok: false, + json: () => Promise.resolve({ code }), + } as Response)); + } + it('waits for the QR library and starts the scanner exactly once', fakeAsync(() => { const fixture = createFixture(); const script = qrScript(); @@ -166,6 +186,58 @@ describe('DashboardPageComponent QR scanner lifecycle', () => { expect(error.textContent).toBe('Camera unavailable'); })); + it('surfaces a rejected pairing exchange and lets the user rescan', fakeAsync(() => { + const fixture = createFixture(); + const scanTab = fixture.nativeElement.querySelector('#dash-tab-scan') as HTMLButtonElement; + const pasteTab = fixture.nativeElement.querySelector('#dash-tab-paste') as HTMLButtonElement; + + installScanner(); + qrScript().dispatchEvent(new Event('load')); + flushMicrotasks(); + expect(scannerInstances).toHaveSize(1); + + rejectPairing('pair_token_expired'); + decodeQR(JSON.stringify({ t: 'expired-token' })); + + expect(scanErrorEl(fixture).style.display).toBe('block'); + expect(scanErrorEl(fixture).textContent).toBe('The pairing code has expired'); + expect(scanTab.classList).toContain('active'); + expect(pasteTab.classList).not.toContain('active'); + expect(scannerInstances).toHaveSize(2); + })); + + it('reports a malformed QR payload without leaking the parser message', fakeAsync(() => { + const fixture = createFixture(); + const scanTab = fixture.nativeElement.querySelector('#dash-tab-scan') as HTMLButtonElement; + const fetchSpy = spyOn(window, 'fetch'); + + installScanner(); + qrScript().dispatchEvent(new Event('load')); + flushMicrotasks(); + + decodeQR('not-json'); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(scanErrorEl(fixture).style.display).toBe('block'); + expect(scanErrorEl(fixture).textContent).toBe('Scan failed -- paste your key instead'); + expect(scanTab.classList).toContain('active'); + })); + + it('reports a QR payload that carries no pairing token', fakeAsync(() => { + const fixture = createFixture(); + const fetchSpy = spyOn(window, 'fetch'); + + installScanner(); + qrScript().dispatchEvent(new Event('load')); + flushMicrotasks(); + + decodeQR(JSON.stringify({ s: 'https://example.test' })); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(scanErrorEl(fixture).textContent).toBe('QR code does not contain a pairing token'); + expect(scannerInstances).toHaveSize(2); + })); + it('reuses in-flight CDN scripts across dashboard component instances', fakeAsync(() => { const first = createFixture(); const script = qrScript(); diff --git a/showcase/angular/src/app/pages/dashboard/dashboard-page.component.ts b/showcase/angular/src/app/pages/dashboard/dashboard-page.component.ts index 747d2d9e..931cba0d 100644 --- a/showcase/angular/src/app/pages/dashboard/dashboard-page.component.ts +++ b/showcase/angular/src/app/pages/dashboard/dashboard-page.component.ts @@ -2581,7 +2581,10 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy private handleScannedQR(decodedText: string): void { try { const data = JSON.parse(decodedText); - if (!data.t) throw new Error(this.dashboardCopy.qrMissingToken); + if (!data.t) { + this.failScan(this.dashboardCopy.qrMissingToken); + return; + } if (this.tabScanContent) { this.tabScanContent.innerHTML = '

' + this.escapeHtml(this.dashboardCopy.connecting) + '

'; @@ -2591,6 +2594,10 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy if (data.s && data.s === location.origin) exchangeUrl = '/api/pair/exchange'; if (!data.s) exchangeUrl = '/api/pair/exchange'; + // Once the exchange succeeds the scanner must never come back, even if the + // post-pairing wiring below throws. + let paired = false; + fetch(exchangeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -2607,6 +2614,7 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy } return resp.json(); }).then(result => { + paired = true; this.storeSession(result.hashKey, result.sessionToken, result.expiresAt); this.showDashboard(); // DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md @@ -2615,18 +2623,13 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy // DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md // this.startPolling(); }).catch((err: Error & { localizedMessage?: string }) => { - this.showScanError(err?.localizedMessage || this.dashboardCopy.scanFailed); - if (this.tabScanContent) { - this.tabScanContent.innerHTML = - '

' + this.escapeHtml(this.dashboardCopy.pointCamera) + '

' + - '
' + - ''; - } - this.switchTab('paste'); + if (paired) return; + this.failScan(err?.localizedMessage || this.dashboardCopy.qrExchangeFailed); }); } catch (err) { - this.showScanError(this.dashboardCopy.scanFailed); - this.switchTab('paste'); + // A malformed payload carries an untranslated parser message, so report + // our own localized copy instead of err.message. + this.failScan(this.dashboardCopy.scanFailed); } } @@ -2637,6 +2640,23 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy el.style.display = 'block'; } } + + private resetScanPanel(): void { + if (!this.tabScanContent) return; + this.tabScanContent.innerHTML = + '

' + this.escapeHtml(this.dashboardCopy.pointCamera) + '

' + + '
' + + ''; + this.scanError = this.el('dash-scan-error'); + } + + // Rebuild before showing: the connecting state replaces the panel markup, so + // writing the error first would target a node that is no longer in the DOM. + private failScan(message: string): void { + this.resetScanPanel(); + this.showScanError(message); + void this.startQRScanner(); + } // // ==================== DATA LOADING ==================== // diff --git a/showcase/angular/src/locale/messages.xlf b/showcase/angular/src/locale/messages.xlf index 0714ece6..440816ba 100644 --- a/showcase/angular/src/locale/messages.xlf +++ b/showcase/angular/src/locale/messages.xlf @@ -4831,98 +4831,98 @@ Last snapshot: src/app/pages/dashboard/dashboard-page.component.ts - 3994 + 4014 State: src/app/pages/dashboard/dashboard-page.component.ts - 3998 + 4018 Reason: src/app/pages/dashboard/dashboard-page.component.ts - 4002 + 4022 Recovering for s src/app/pages/dashboard/dashboard-page.component.ts - 4006 + 4026 last frame: s ago src/app/pages/dashboard/dashboard-page.component.ts - 4012 + 4032 mutations: src/app/pages/dashboard/dashboard-page.component.ts - 4013 + 4033 apply failures: src/app/pages/dashboard/dashboard-page.component.ts - 4014 + 4034 stale: src/app/pages/dashboard/dashboard-page.component.ts - 4015 + 4035 No stream data src/app/pages/dashboard/dashboard-page.component.ts - 4016 + 4036 Request failed with status src/app/pages/dashboard/dashboard-page.component.ts - 4319 + 4339 ~ min remaining src/app/pages/dashboard/dashboard-page.component.ts - 4474 + 4494 ~ s remaining src/app/pages/dashboard/dashboard-page.component.ts - 4475 + 4495 Running for src/app/pages/dashboard/dashboard-page.component.ts - 4549 + 4569 Stopped by user -- was: src/app/pages/dashboard/dashboard-page.component.ts - 4554 + 4574 diff --git a/tests/dashboard-runtime-state.test.js b/tests/dashboard-runtime-state.test.js index c367ce23..19d26ad3 100644 --- a/tests/dashboard-runtime-state.test.js +++ b/tests/dashboard-runtime-state.test.js @@ -533,6 +533,19 @@ assert(angularDashboardTsSource.includes('this.translateTaskError(') assert(angularDashboardTsSource.includes('this.pairingErrorMessage(body?.code)') && !angularDashboardTsSource.includes('body.error || this.dashboardCopy.qrExchangeFailed'), 'QR pairing failures use trusted codes instead of rendering server English'); +// The connecting state replaces the scan panel markup, so a pairing error written +// before the rebuild lands on a detached node and is never seen. +assert(/failScan\(message: string\): void \{\s*this\.resetScanPanel\(\);\s*this\.showScanError\(message\);\s*void this\.startQRScanner\(\);/ + .test(angularDashboardTsSource), + 'failScan rebuilds the scan panel before showing the error, then restarts the scanner'); +const handleScannedQRStart = angularDashboardTsSource.indexOf('private handleScannedQR'); +const handleScannedQREnd = angularDashboardTsSource.indexOf('private showScanError'); +assert(handleScannedQRStart >= 0 && handleScannedQREnd > handleScannedQRStart, + 'Angular dashboard still defines handleScannedQR ahead of showScanError'); +const handleScannedQRBody = angularDashboardTsSource.slice(handleScannedQRStart, handleScannedQREnd); +assert(!handleScannedQRBody.includes("switchTab('paste')") + && (handleScannedQRBody.match(/this\.failScan\(/g) || []).length === 3, + 'every QR scan failure reports through failScan and keeps the user on the Scan tab'); assert(angularDashboardTsSource.includes('inject(LOCALE_ID)') && angularDashboardTsSource.includes('Math.round(safe).toLocaleString(this.localeId)') && angularDashboardTsSource.includes('new Date(this.lastSnapshotTime).toLocaleTimeString(this.localeId)'),