From a79f58b1a4bbb339eb1e016129e12f0380cbb29a Mon Sep 17 00:00:00 2001 From: devv-shayan Date: Tue, 6 Oct 2026 20:36:56 +0500 Subject: [PATCH] fix: preserve focus when changing voice auth --- CHANGELOG.md | 8 + docs/CURRENT-STATE.md | 1 + scripts/format-scope.json | 4 + scripts/qa-voice-auth-focus.mjs | 328 ++++++++++++++++++++++++++++ src/keySetup.js | 49 +++-- src/ui/styles/provider-settings.css | 2 +- 6 files changed, 370 insertions(+), 22 deletions(-) create mode 100644 scripts/qa-voice-auth-focus.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..25415f601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +- Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider + Settings. API-key voice remains the default. Local sign-in can start from + the auth button and reports completion, failure, timeout, or expired + credentials. OAuth credentials stay on the server; local routes require + loopback access and same-site requests. OAuth sessions retain usage data + and show unknown cost. The auth button keeps keyboard focus after a mode + change, and pending sign-in does not discard text in the key fields. + - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 569ce2420..f7de72983 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3925,6 +3925,7 @@ and unreachable upstream (502/504) separately from road geometry. 4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers). - **Degradation**: API-key mode without `OPENAI_API_KEY` returns 503 as before. Selecting OAuth without a usable local ChatGPT/Codex sign-in starts the local `codex login` browser flow, polls for the resulting local token, and selects OAuth when sign-in completes. If the Codex login flow cannot start or does not complete, the rest of the app is unaffected. - **Login lifecycle and storage**: one pending Codex login is shared by the login and status routes. Process failure, success without readable credentials, and a two-minute timeout each report a clear failure; timeout and server shutdown cancel the owned process. The launcher resolves `CODEX_HOME` before changing directories and refuses a mismatched `CODEX_AUTH_JSON`. GEV reads file credentials only, never writes or refreshes them, and does not require the vendor refresh token. Expired access tokens report `CODEX_OAUTH_REAUTH_REQUIRED`. OAuth status and login routes also use the same-site request gate. +- **Auth button focus**: changing voice auth updates the existing controls in place. Enter, Space, and mouse selection keep focus on the auth button; Tab then follows the normal control order. Pending sign-in blocks repeated activation without removing the button from the focus order. Completion does not take focus from another control or discard text in the key fields. `scripts/qa-voice-auth-focus.mjs` checks these paths in Chromium with fixture sign-in responses. - **Support boundary**: the local OAuth option is experimental. OpenAI's documented Sign in with ChatGPT plan-sharing inference endpoint is `/v1/responses`; support for third-party reuse of Codex credentials at `/v1/realtime/client_secrets` remains unconfirmed. Technical success does not establish a supported auth or billing contract. ### AI HUD Summary (June 2026) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 662b2a43a..0d9530871 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -36,6 +36,10 @@ "src/main.js", "docs/APPLICATION.md", "scripts/qa-application.mjs", + "scripts/qa-voice-auth.mjs", + "scripts/qa-voice-auth-focus.mjs", + "src/codexOauthRealtime.test.mjs", + "src/voice/cloudVoiceAuth.test.mjs", "scripts/qa-terrain-429.mjs", "src/app/application.js", "src/app/application.test.mjs", diff --git a/scripts/qa-voice-auth-focus.mjs b/scripts/qa-voice-auth-focus.mjs new file mode 100644 index 000000000..6d131a22c --- /dev/null +++ b/scripts/qa-voice-auth-focus.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node +/** + * Check auth-mode keyboard focus with the real settings template and controls. + * Run: node --test --test-reporter=spec scripts/qa-voice-auth-focus.mjs + * Sign-in responses are fixtures. No credentials or live login are used. + */ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { after, before, test } from 'node:test'; +import puppeteer from 'puppeteer'; + +const root = new URL('../', import.meta.url); +const template = await readFile( + new URL('src/ui/templates/provider-settings.html', root), + 'utf8', +); +const assets = new Set([ + '/src/keySetup.js', + '/src/voice/cloudVoiceAuth.js', + '/src/ui/surfaceKeyboard.js', + '/src/ui/styles/provider-settings.css', +]); +const observations = []; +let server; +let browser; +let origin; + +before(async () => { + server = createServer(async (req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname; + if (pathname === '/') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(` + Voice auth keyboard focus test + + ${template}`); + return; + } + if (!assets.has(pathname)) { + res.statusCode = 404; + res.end(); + return; + } + try { + res.setHeader( + 'Content-Type', + pathname.endsWith('.css') ? 'text/css' : 'text/javascript', + ); + res.end(await readFile(new URL(pathname.slice(1), root))); + } catch { + res.statusCode = 500; + res.end('Could not load the test asset'); + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + origin = `http://127.0.0.1:${server.address().port}`; + const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + (existsSync(chrome) ? chrome : await puppeteer.executablePath()), + }); +}); + +after(async () => { + await browser?.close(); + if (server?.listening) await new Promise((resolve) => server.close(resolve)); + const output = new URL('.gev-logs/voice-auth-focus/', root); + await mkdir(output, { recursive: true }); + await writeFile( + new URL('results.json', output), + `${JSON.stringify(observations, null, 2)}\n`, + ); +}); + +const scenarios = [ + { + name: 'OAuth to API key', + initial: 'oauth', + expected: 'api-key', + flow: 'unused', + }, + { + name: 'API key to existing OAuth', + initial: 'api-key', + expected: 'oauth', + flow: 'existing', + }, + { + name: 'API key to OAuth ready at login launch', + initial: 'api-key', + expected: 'oauth', + flow: 'login-ready', + }, + { + name: 'API key to OAuth ready after polling', + initial: 'api-key', + expected: 'oauth', + flow: 'poll-ready', + }, +]; + +async function mountSettings(page, scenario) { + await page.goto(`${origin}/?setup=1`, { + waitUntil: 'domcontentloaded', + }); + await page.evaluate(async ({ initial, flow }) => { + localStorage.clear(); + localStorage.setItem('godsEyeView.voice.cloudAuth', initial); + window.__focusTestCalls = []; + let statusReads = 0; + const fetchImpl = async (url) => { + window.__focusTestCalls.push(url); + let payload; + let status = 200; + if (url === '/api/setup/status') { + payload = { + setCount: 0, + total: 1, + keys: [ + { + id: 'openai', + title: 'OPENAI', + tier: 'metered', + set: false, + unlocks: 'Voice control', + envVars: ['OPENAI_API_KEY'], + getUrl: 'https://example.invalid/', + }, + ], + }; + } else if (url === '/api/realtime/oauth-status') { + statusReads += 1; + if (flow === 'deferred') { + return await new Promise((resolve) => { + window.__resolveOAuthStatus = (available) => + resolve( + new Response( + JSON.stringify({ available, error: 'Fixture failure' }), + { + status: available ? 200 : 503, + }, + ), + ); + }); + } + payload = { + available: + flow === 'existing' || (flow === 'poll-ready' && statusReads > 1), + }; + } else if (url === '/api/realtime/oauth-login') { + const available = flow === 'login-ready'; + status = available ? 200 : 202; + payload = { available, pending: !available, started: !available }; + } else { + throw new Error(`Unexpected request: ${url}`); + } + return new Response(JSON.stringify(payload), { status }); + }; + const { initKeySetup } = await import('/src/keySetup.js'); + window.__focusTestSettings = await initKeySetup({ fetchImpl }); + }, scenario); + await page.waitForFunction( + () => + document.getElementById('key-setup').classList.contains('visible') && + document.activeElement.matches('input[data-env-var]'), + ); +} + +for (const scenario of scenarios) { + for (const key of ['Enter', 'Space', 'Mouse click']) { + test(`${scenario.name} keeps keyboard focus after ${key}`, async (t) => { + const page = await browser.newPage(); + try { + await mountSettings(page, scenario); + + if (key !== 'Mouse click') { + // Tab from the initial key field to the real auth button. + await page.keyboard.press('Tab'); + assert.equal( + await page.evaluate(() => + document.activeElement.matches('[data-cloud-voice-auth-toggle]'), + ), + true, + 'Keyboard navigation must reach the auth toggle before activation', + ); + } + await page.evaluate(() => { + const toggle = document.querySelector( + '[data-cloud-voice-auth-toggle]', + ); + window.__originalFocusToggle = toggle; + window.__focusAtActivation = null; + toggle.addEventListener( + 'click', + () => { + window.__focusAtActivation = document.activeElement === toggle; + }, + { capture: true, once: true }, + ); + }); + if (key === 'Mouse click') { + await page.waitForFunction( + () => + getComputedStyle(document.getElementById('key-setup')).opacity === + '1', + ); + await page.click('[data-cloud-voice-auth-toggle]'); + } else { + await page.keyboard.press(key); + } + await page.waitForFunction( + (expected) => + localStorage.getItem('godsEyeView.voice.cloudAuth') === expected && + !document.querySelector('[data-cloud-voice-auth-toggle]') + .disabled && + document + .querySelector('[data-cloud-voice-auth-toggle]') + .getAttribute('aria-disabled') !== 'true', + {}, + scenario.expected, + ); + const selection = await page.evaluate(() => ({ + focusOnToggleAtActivation: window.__focusAtActivation, + focusOnToggle: + document.activeElement === + document.querySelector('[data-cloud-voice-auth-toggle]'), + activeTag: document.activeElement.tagName, + originalToggleConnected: window.__originalFocusToggle.isConnected, + requests: window.__focusTestCalls, + })); + await page.keyboard.press('Tab'); + const nextTab = await page.evaluate(() => ({ + focusOnSave: document.activeElement.matches('[data-key-setup-apply]'), + activeLabel: + document.activeElement.getAttribute('aria-label') || + document.activeElement.textContent.trim(), + })); + const observed = { scenario: scenario.name, key, selection, nextTab }; + observations.push(observed); + t.diagnostic(JSON.stringify(observed)); + assert.equal( + selection.focusOnToggle, + true, + 'Auth selection must keep focus on its usable toggle', + ); + assert.equal( + nextTab.focusOnSave, + true, + 'Tab must continue to SAVE KEYS rather than restart at Close', + ); + } finally { + await page.close(); + } + }); + } +} + +for (const available of [true, false]) { + test(`pending OAuth ${available ? 'success' : 'failure'} blocks repeats without moving focus or losing a key draft`, async () => { + const page = await browser.newPage(); + try { + await mountSettings(page, { initial: 'api-key', flow: 'deferred' }); + await page.type('input[data-env-var]', 'test-draft-only'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => typeof window.__resolveOAuthStatus === 'function', + ); + assert.equal( + await page.evaluate(() => + document.activeElement.matches('[data-cloud-voice-auth-toggle]'), + ), + true, + ); + await page.keyboard.press('Enter'); + await page.keyboard.press('Space'); + assert.equal( + await page.evaluate( + () => + window.__focusTestCalls.filter( + (url) => url === '/api/realtime/oauth-status', + ).length, + ), + 1, + ); + await page.keyboard.press('Tab'); + assert.equal( + await page.evaluate(() => + document.activeElement.matches('[data-key-setup-apply]'), + ), + true, + ); + await page.evaluate( + (available) => window.__resolveOAuthStatus(available), + available, + ); + await page.waitForFunction( + () => + document + .querySelector('[data-cloud-voice-auth-toggle]') + .getAttribute('aria-disabled') === 'false', + ); + const observed = await page.evaluate(() => ({ + mode: localStorage.getItem('godsEyeView.voice.cloudAuth'), + focusOnSave: document.activeElement.matches('[data-key-setup-apply]'), + draft: document.querySelector('input[data-env-var]').value, + requests: window.__focusTestCalls, + })); + observations.push({ scenario: 'pending OAuth', available, observed }); + assert.equal(observed.mode, available ? 'oauth' : 'api-key'); + assert.equal( + observed.focusOnSave, + true, + 'Completing a request must not take focus from another control', + ); + assert.equal(observed.draft, 'test-draft-only'); + } finally { + await page.close(); + } + }); +} diff --git a/src/keySetup.js b/src/keySetup.js index 1186e28a7..1029d479b 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -111,6 +111,21 @@ export function stripKeylessBasemapFromHash(hash) { const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' }); +function updateCloudVoiceAuthControl(auth, busy = false) { + const mode = readStoredCloudVoiceAuthMode(); + const state = auth.querySelector('.key-setup-cloud-auth-state'); + const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]'); + state.textContent = + mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY'; + toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH'; + toggle.title = + mode === 'oauth' + ? 'Use OPENAI_API_KEY for the next cloud voice session' + : 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session'; + // Keep the control focusable while the delegated handler blocks repeats. + toggle.setAttribute('aria-disabled', String(busy)); +} + /** Build one key row. All content is our own registry text, set via textContent. */ function buildRow(documentRef, key) { const row = documentRef.createElement('section'); @@ -197,21 +212,14 @@ function buildRow(documentRef, key) { if (key.id === 'openai') { const auth = documentRef.createElement('div'); auth.className = 'key-setup-cloud-auth'; - const mode = readStoredCloudVoiceAuthMode(); const state = documentRef.createElement('span'); state.className = 'key-setup-cloud-auth-state'; - state.textContent = - mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY'; const toggle = documentRef.createElement('button'); toggle.type = 'button'; toggle.className = 'key-setup-cloud-auth-toggle'; toggle.dataset.cloudVoiceAuthToggle = 'true'; - toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH'; - toggle.title = - mode === 'oauth' - ? 'Use OPENAI_API_KEY for the next cloud voice session' - : 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session'; auth.append(state, toggle); + updateCloudVoiceAuthControl(auth); row.append(auth); } return row; @@ -297,6 +305,11 @@ export async function initKeySetup({ let oauthBusy = false; let open = false; + const syncCloudVoiceAuth = () => { + for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth')) + updateCloudVoiceAuthControl(auth, oauthBusy); + }; + const render = (nextStatus) => { if (disposed) return; status = nextStatus; @@ -310,10 +323,7 @@ export async function initKeySetup({ rowsHost.textContent = ''; for (const key of status.keys || []) rowsHost.append(buildRow(documentRef, key)); - for (const button of rowsHost.querySelectorAll( - '[data-cloud-voice-auth-toggle]', - )) - button.disabled = oauthBusy; + syncCloudVoiceAuth(); }; const visible = () => @@ -451,12 +461,12 @@ export async function initKeySetup({ const current = readStoredCloudVoiceAuthMode(); if (current === 'oauth') { writeStoredCloudVoiceAuthMode('api-key'); - render(status); + syncCloudVoiceAuth(); say('Cloud voice will use OPENAI_API_KEY on the next session.'); return; } - authButton.disabled = true; oauthBusy = true; + syncCloudVoiceAuth(); say('Checking local ChatGPT OAuth sign-in…'); try { const response = await doFetch('/api/realtime/oauth-status', { @@ -471,7 +481,7 @@ export async function initKeySetup({ ); if (payload.available) { writeStoredCloudVoiceAuthMode('oauth'); - render(status); + syncCloudVoiceAuth(); say( 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', ); @@ -500,7 +510,7 @@ export async function initKeySetup({ } if (loginPayload.available) { writeStoredCloudVoiceAuthMode('oauth'); - render(status); + syncCloudVoiceAuth(); say( 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', ); @@ -522,7 +532,7 @@ export async function initKeySetup({ return; } writeStoredCloudVoiceAuthMode('oauth'); - render(status); + syncCloudVoiceAuth(); say( 'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.', ); @@ -531,10 +541,7 @@ export async function initKeySetup({ say(`OAuth check failed: ${error?.message || error}`); } finally { oauthBusy = false; - for (const button of rowsHost.querySelectorAll( - '[data-cloud-voice-auth-toggle]', - )) - button.disabled = false; + if (!disposed) syncCloudVoiceAuth(); } })(); return; diff --git a/src/ui/styles/provider-settings.css b/src/ui/styles/provider-settings.css index 043e046d6..7563a886d 100644 --- a/src/ui/styles/provider-settings.css +++ b/src/ui/styles/provider-settings.css @@ -332,7 +332,7 @@ outline: none; } -.key-setup-cloud-auth-toggle:disabled { +.key-setup-cloud-auth-toggle[aria-disabled='true'] { cursor: wait; opacity: 0.5; }