diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 0b10e9e189c..cfc5b990641 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1336,6 +1336,17 @@ export class BrowserManager { return this.worktreeIdByTabId.get(browserTabId) } + getRendererContextForGuest( + guestWebContentsId: number + ): { browserPageId: string; renderer: Electron.WebContents } | null { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return null + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + return renderer ? { browserPageId, renderer } : null + } + getSessionProfileIdForTab(browserTabId: string): string | null { return this.sessionProfileIdByPageId.get(browserTabId) ?? null } diff --git a/src/main/browser/browser-webauthn-access.test.ts b/src/main/browser/browser-webauthn-access.test.ts new file mode 100644 index 00000000000..1bde65c6bf9 --- /dev/null +++ b/src/main/browser/browser-webauthn-access.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { requestAccountMock, cancelAllAccountRequestsMock } = vi.hoisted(() => ({ + requestAccountMock: vi.fn(), + cancelAllAccountRequestsMock: vi.fn() +})) + +vi.mock('./browser-webauthn-account-picker', () => ({ + requestBrowserWebAuthnAccount: requestAccountMock, + cancelAllBrowserWebAuthnAccountRequests: cancelAllAccountRequestsMock +})) + +import { installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' + +describe('browser WebAuthn access', () => { + beforeEach(() => { + requestAccountMock.mockReset() + cancelAllAccountRequestsMock.mockReset() + }) + + it('dispatches multi-account assertions to the account picker', async () => { + let selectHandler: + | (( + event: Electron.Event, + details: Electron.SelectWebauthnAccountDetails, + callback: (credentialId?: string | null) => void + ) => Promise) + | undefined + const browserSession = { + setDevicePermissionHandler: vi.fn(), + removeListener: vi.fn(), + on: vi.fn((eventName: string, listener: unknown) => { + if (eventName === 'select-webauthn-account') { + selectHandler = listener as typeof selectHandler + } + }) + } as unknown as Electron.Session + const details = { + relyingPartyId: 'example.com', + frame: {} as Electron.WebFrameMain, + accounts: [{ credentialId: 'first' }, { credentialId: 'second' }] + } + const callback = vi.fn() + requestAccountMock.mockResolvedValue('second') + + installBrowserWebAuthnAccessHandlers(browserSession) + await selectHandler?.( + { preventDefault: vi.fn() } as unknown as Electron.Event, + details, + callback + ) + + expect(requestAccountMock).toHaveBeenCalledWith(details) + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith('second') + }) +}) diff --git a/src/main/browser/browser-webauthn-access.ts b/src/main/browser/browser-webauthn-access.ts index 60da5b1e297..aa8fa579fa1 100644 --- a/src/main/browser/browser-webauthn-access.ts +++ b/src/main/browser/browser-webauthn-access.ts @@ -1,4 +1,8 @@ import type { Session } from 'electron' +import { + cancelAllBrowserWebAuthnAccountRequests, + requestBrowserWebAuthnAccount +} from './browser-webauthn-account-picker' const FIDO_HID_USAGE_PAGE = 0xf1d0 const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) @@ -53,15 +57,22 @@ function handleBrowserSelectHidDevice( callback(selectedDevice?.deviceId) } -function handleBrowserSelectWebAuthnAccount( +async function handleBrowserSelectWebAuthnAccount( event: Electron.Event, details: Electron.SelectWebauthnAccountDetails, callback: (credentialId?: string | null) => void -): void { +): Promise { event.preventDefault() - // Why: Electron cancels discoverable WebAuthn when no listener exists. Pick - // only the unambiguous single-account case until Orca has account-picker UI. - callback(details.accounts.length === 1 ? details.accounts[0].credentialId : null) + if (details.accounts.length <= 1) { + callback(details.accounts[0]?.credentialId ?? null) + return + } + let credentialId: string | null = null + try { + credentialId = await requestBrowserWebAuthnAccount(details) + } finally { + callback(credentialId) + } } export function installBrowserWebAuthnAccessHandlers(browserSession: Session): void { @@ -82,4 +93,5 @@ export function clearBrowserWebAuthnAccessHandlers(browserSession: Session): voi browserSession.removeListener('select-hid-device', handleBrowserSelectHidDevice) browserSession.removeListener('select-webauthn-account', handleBrowserSelectWebAuthnAccount) browserSession.setDevicePermissionHandler(null) + cancelAllBrowserWebAuthnAccountRequests() } diff --git a/src/main/browser/browser-webauthn-account-picker.test.ts b/src/main/browser/browser-webauthn-account-picker.test.ts new file mode 100644 index 00000000000..e993d1681c3 --- /dev/null +++ b/src/main/browser/browser-webauthn-account-picker.test.ts @@ -0,0 +1,144 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { fromFrameMock, getRendererContextForGuestMock } = vi.hoisted(() => ({ + fromFrameMock: vi.fn(), + getRendererContextForGuestMock: vi.fn() +})) + +vi.mock('electron', () => ({ + webContents: { fromFrame: fromFrameMock } +})) + +vi.mock('./browser-manager', () => ({ + browserManager: { getRendererContextForGuest: getRendererContextForGuestMock } +})) + +import { + BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS, + cancelAllBrowserWebAuthnAccountRequests, + cancelBrowserWebAuthnAccountRequests, + requestBrowserWebAuthnAccount, + respondToBrowserWebAuthnAccountRequest +} from './browser-webauthn-account-picker' + +function mockWebContents(id: number): Electron.WebContents & EventEmitter { + const contents = new EventEmitter() as Electron.WebContents & EventEmitter + Object.assign(contents, { + id, + isDestroyed: vi.fn(() => false), + send: vi.fn() + }) + return contents +} + +function accountDetails(): Electron.SelectWebauthnAccountDetails { + return { + relyingPartyId: 'accounts.example.com', + frame: {} as Electron.WebFrameMain, + accounts: [ + { credentialId: 'credential-1', displayName: 'Personal', name: 'me@example.com' }, + { credentialId: 'credential-2', displayName: 'Work', name: 'me@work.example' } + ] + } +} + +describe('browser WebAuthn account picker', () => { + beforeEach(() => { + vi.useFakeTimers() + fromFrameMock.mockReset() + getRendererContextForGuestMock.mockReset() + }) + + afterEach(() => { + cancelAllBrowserWebAuthnAccountRequests() + vi.useRealTimers() + }) + + it('returns the credential selected by the owning renderer', async () => { + const guest = mockWebContents(41) + const renderer = mockWebContents(42) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-1', renderer }) + + const selection = requestBrowserWebAuthnAccount(accountDetails()) + const request = vi.mocked(renderer.send).mock.calls[0][1] + + expect(request).toMatchObject({ + browserPageId: 'page-1', + relyingPartyId: 'accounts.example.com', + accounts: [ + { credentialId: 'credential-1', displayName: 'Personal', name: 'me@example.com' }, + { credentialId: 'credential-2', displayName: 'Work', name: 'me@work.example' } + ] + }) + expect( + respondToBrowserWebAuthnAccountRequest(renderer, { + requestId: request.requestId, + credentialId: 'credential-2' + }) + ).toBe(true) + await expect(selection).resolves.toBe('credential-2') + }) + + it('rejects responses from another renderer or with an unoffered credential', async () => { + const guest = mockWebContents(51) + const renderer = mockWebContents(52) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-2', renderer }) + + const selection = requestBrowserWebAuthnAccount(accountDetails()) + const requestId = vi.mocked(renderer.send).mock.calls[0][1].requestId + + expect( + respondToBrowserWebAuthnAccountRequest(mockWebContents(53), { + requestId, + credentialId: 'credential-1' + }) + ).toBe(false) + expect( + respondToBrowserWebAuthnAccountRequest(renderer, { + requestId, + credentialId: 'credential-not-offered' + }) + ).toBe(false) + expect( + respondToBrowserWebAuthnAccountRequest(renderer, { requestId, credentialId: null }) + ).toBe(true) + await expect(selection).resolves.toBeNull() + }) + + it('cancels when the tab closes or the prompt times out', async () => { + const guest = mockWebContents(61) + const renderer = mockWebContents(62) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-3', renderer }) + const closedSelection = requestBrowserWebAuthnAccount(accountDetails()) + vi.mocked(renderer.send).mockImplementation((channel) => { + if (channel === 'browser:webauthn-account-request-closed') { + throw new Error('renderer destroyed during send') + } + }) + + cancelBrowserWebAuthnAccountRequests('page-3') + await expect(closedSelection).resolves.toBeNull() + + const timedOutSelection = requestBrowserWebAuthnAccount(accountDetails()) + await vi.advanceTimersByTimeAsync(BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS) + await expect(timedOutSelection).resolves.toBeNull() + }) + + it('cancels when the guest or window is destroyed', async () => { + const guest = mockWebContents(71) + const renderer = mockWebContents(72) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-4', renderer }) + const guestDestroyed = requestBrowserWebAuthnAccount(accountDetails()) + guest.emit('destroyed') + await expect(guestDestroyed).resolves.toBeNull() + + const windowDestroyed = requestBrowserWebAuthnAccount(accountDetails()) + renderer.emit('destroyed') + await expect(windowDestroyed).resolves.toBeNull() + }) +}) diff --git a/src/main/browser/browser-webauthn-account-picker.ts b/src/main/browser/browser-webauthn-account-picker.ts new file mode 100644 index 00000000000..4173d4f2244 --- /dev/null +++ b/src/main/browser/browser-webauthn-account-picker.ts @@ -0,0 +1,133 @@ +import { randomUUID } from 'node:crypto' +import { webContents } from 'electron' + +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../../shared/browser-webauthn-account' +import { browserManager } from './browser-manager' + +export const BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS = 60_000 + +type PendingAccountRequest = { + browserPageId: string + guest: Electron.WebContents + renderer: Electron.WebContents + credentialIds: Set + timer: ReturnType + onDestroyed: () => void + settle: (credentialId: string | null) => void +} + +const pendingAccountRequests = new Map() + +function closeRendererPrompt(requestId: string, request: PendingAccountRequest): void { + try { + if (!request.renderer.isDestroyed()) { + request.renderer.send('browser:webauthn-account-request-closed', { requestId }) + } + } catch { + // Renderer may be destroyed between the check and send. + } +} + +function cleanUpRequest(requestId: string, request: PendingAccountRequest): void { + clearTimeout(request.timer) + request.guest.removeListener('destroyed', request.onDestroyed) + request.renderer.removeListener('destroyed', request.onDestroyed) + pendingAccountRequests.delete(requestId) +} + +export function requestBrowserWebAuthnAccount( + details: Electron.SelectWebauthnAccountDetails +): Promise { + const guest = details.frame ? webContents.fromFrame(details.frame) : undefined + if (!guest || guest.isDestroyed()) { + return Promise.resolve(null) + } + const context = browserManager.getRendererContextForGuest(guest.id) + if (!context || context.renderer.isDestroyed()) { + return Promise.resolve(null) + } + + const requestId = randomUUID() + const requestPayload: BrowserWebAuthnAccountRequest = { + requestId, + browserPageId: context.browserPageId, + relyingPartyId: details.relyingPartyId, + accounts: details.accounts.map(({ credentialId, displayName, name }) => ({ + credentialId, + ...(displayName ? { displayName } : {}), + ...(name ? { name } : {}) + })) + } + + return new Promise((resolve) => { + let settled = false + const settle = (credentialId: string | null = null): void => { + if (settled) { + return + } + settled = true + const request = pendingAccountRequests.get(requestId) + if (request) { + cleanUpRequest(requestId, request) + closeRendererPrompt(requestId, request) + } + resolve(credentialId) + } + const request: PendingAccountRequest = { + browserPageId: context.browserPageId, + guest, + renderer: context.renderer, + credentialIds: new Set(details.accounts.map((account) => account.credentialId)), + timer: setTimeout(settle, BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS), + onDestroyed: () => settle(null), + settle + } + pendingAccountRequests.set(requestId, request) + guest.once('destroyed', request.onDestroyed) + context.renderer.once('destroyed', request.onDestroyed) + try { + context.renderer.send('browser:webauthn-account-requested', requestPayload) + } catch { + settle(null) + } + }) +} + +export function respondToBrowserWebAuthnAccountRequest( + sender: Electron.WebContents, + response: BrowserWebAuthnAccountResponse +): boolean { + if ( + !response || + typeof response.requestId !== 'string' || + (response.credentialId !== null && typeof response.credentialId !== 'string') + ) { + return false + } + const request = pendingAccountRequests.get(response.requestId) + if (!request || request.renderer.id !== sender.id) { + return false + } + if (response.credentialId !== null && !request.credentialIds.has(response.credentialId)) { + return false + } + request.settle(response.credentialId) + return true +} + +export function cancelBrowserWebAuthnAccountRequests(browserPageId: string): void { + for (const request of pendingAccountRequests.values()) { + if (request.browserPageId === browserPageId) { + request.settle(null) + } + } +} + +export function cancelAllBrowserWebAuthnAccountRequests(): void { + for (const request of pendingAccountRequests.values()) { + request.settle(null) + } +} diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 88c6e140afa..cdc5a1273d9 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -4,6 +4,10 @@ import { BrowserWindow, ipcMain, webContents } from 'electron' import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { browserSessionRegistry } from '../browser/browser-session-registry' +import { + cancelBrowserWebAuthnAccountRequests, + respondToBrowserWebAuthnAccountRequest +} from '../browser/browser-webauthn-account-picker' import { pickCookieFile, importCookiesFromFile, @@ -35,6 +39,7 @@ import { isValidBrowserAnnotationViewportBridgeToken, type BrowserSetAnnotationViewportBridgeArgs } from '../../shared/browser-annotation-viewport-bridge' +import type { BrowserWebAuthnAccountResponse } from '../../shared/browser-webauthn-account' let trustedBrowserRendererWebContentsId: number | null = null let agentBrowserBridgeRef: AgentBrowserBridge | null = null @@ -221,6 +226,7 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:extractHoverPayload') ipcMain.removeHandler('browser:activeTabChanged') ipcMain.removeHandler('browser:proceedCertificate') + ipcMain.removeHandler('browser:respondWebAuthnAccount') const registerGuest = ( event: Electron.IpcMainInvokeEvent, @@ -315,6 +321,7 @@ export function registerBrowserHandlers(): void { if (wcId !== null && agentBrowserBridgeRef) { agentBrowserBridgeRef.onTabClosed(wcId) } + cancelBrowserWebAuthnAccountRequests(args.browserPageId) browserManager.unregisterGuest(args.browserPageId) grabModeIntentByPageId.delete(args.browserPageId) // Why: don't let a reused browserPageId queue behind the destroyed guest's pending chain. @@ -322,6 +329,16 @@ export function registerBrowserHandlers(): void { return true }) + ipcMain.handle( + 'browser:respondWebAuthnAccount', + (event, response: BrowserWebAuthnAccountResponse): boolean => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return respondToBrowserWebAuthnAccountRequest(event.sender, response) + } + ) + ipcMain.handle( 'browser:proceedCertificate', ( diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index 6b352eff527..2810e8b35f2 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -1,4 +1,8 @@ import type { BrowserSetAnnotationViewportBridgeArgs } from '../../shared/browser-annotation-viewport-bridge' +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../../shared/browser-webauthn-account' import type { BrowserSetGrabModeArgs, BrowserSetGrabModeResult, @@ -48,6 +52,11 @@ export type BrowserApi = { webContentsId: number }) => Promise unregisterGuest: (args: { browserPageId: string }) => Promise + onWebAuthnAccountRequest: ( + callback: (request: BrowserWebAuthnAccountRequest) => void + ) => () => void + onWebAuthnAccountRequestClosed: (callback: (event: { requestId: string }) => void) => () => void + respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse) => Promise openDevTools: (args: { browserPageId: string }) => Promise setViewportOverride: (args: { browserPageId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index fb87c4f43f9..42847c9386f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -62,6 +62,10 @@ import type { import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' import type { BrowserViewportOverride } from '../shared/browser-workspace-types' +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../shared/browser-webauthn-account' import type { SearchResult } from '../shared/code-search-types' import type { FilesystemPathFlavor, @@ -2576,6 +2580,29 @@ const api = { unregisterGuest: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:unregisterGuest', args), + onWebAuthnAccountRequest: ( + callback: (request: BrowserWebAuthnAccountRequest) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: BrowserWebAuthnAccountRequest + ): void => callback(request) + ipcRenderer.on('browser:webauthn-account-requested', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-requested', listener) + }, + + onWebAuthnAccountRequestClosed: ( + callback: (event: { requestId: string }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }): void => + callback(data) + ipcRenderer.on('browser:webauthn-account-request-closed', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-request-closed', listener) + }, + + respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => + ipcRenderer.invoke('browser:respondWebAuthnAccount', response), + openDevTools: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:openDevTools', args), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b586b424b35..b6dfd73718f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { Toaster } from '@/components/ui/sonner' import { TooltipProvider } from '@/components/ui/tooltip' import { ConfirmationDialogProvider } from './components/confirmation-dialog' +import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-account-dialog' import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog' import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' @@ -75,6 +76,7 @@ function App(): React.JSX.Element { floatingWorkspace={floatingWorkspace} onboardingGate={onboardingGate} /> + diff --git a/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx b/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx new file mode 100644 index 00000000000..69120a43777 --- /dev/null +++ b/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { BrowserWebAuthnAccountRequest } from '../../../shared/browser-webauthn-account' + +const { respondMock, setBlockingSurfaceMock } = vi.hoisted(() => ({ + respondMock: vi.fn(), + setBlockingSurfaceMock: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ setContextualToursBlockingSurfaceVisible: setBlockingSurfaceMock }) +})) + +import { BrowserWebAuthnAccountDialog } from './browser-webauthn-account-dialog' + +describe('BrowserWebAuthnAccountDialog', () => { + let requestListener!: (request: BrowserWebAuthnAccountRequest) => void + let closedListener!: (event: { requestId: string }) => void + + afterEach(cleanup) + + beforeEach(() => { + respondMock.mockReset().mockResolvedValue(true) + setBlockingSurfaceMock.mockReset() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + browser: { + onWebAuthnAccountRequest: vi.fn((callback) => { + requestListener = callback + return vi.fn() + }), + onWebAuthnAccountRequestClosed: vi.fn((callback) => { + closedListener = callback + return vi.fn() + }), + respondWebAuthnAccount: respondMock + } + } + }) + }) + + function showRequest(): void { + act(() => { + requestListener({ + requestId: 'request-1', + browserPageId: 'page-1', + relyingPartyId: 'accounts.example.com', + accounts: [ + { credentialId: 'personal-id', displayName: 'Personal', name: 'me@example.com' }, + { credentialId: 'work-id', displayName: 'Work', name: 'me@work.example' } + ] + }) + }) + } + + it('returns the selected credential without displaying opaque credential IDs', async () => { + render() + showRequest() + + expect(screen.getByText('accounts.example.com')).toBeInTheDocument() + expect(screen.queryByText('work-id')).not.toBeInTheDocument() + await waitFor(() => expect(screen.getByRole('button', { name: /Personal/ })).toHaveFocus()) + fireEvent.click(screen.getByRole('button', { name: /Work/ })) + + expect(respondMock).toHaveBeenCalledWith({ + requestId: 'request-1', + credentialId: 'work-id' + }) + }) + + it('returns null only when the user cancels', () => { + render() + showRequest() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(respondMock).toHaveBeenCalledWith({ + requestId: 'request-1', + credentialId: null + }) + }) + + it('closes a timed-out prompt when main dispatches teardown', () => { + render() + showRequest() + + act(() => closedListener({ requestId: 'request-1' })) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/browser-webauthn-account-dialog.tsx b/src/renderer/src/components/browser-webauthn-account-dialog.tsx new file mode 100644 index 00000000000..03b58f515c0 --- /dev/null +++ b/src/renderer/src/components/browser-webauthn-account-dialog.tsx @@ -0,0 +1,171 @@ +import { KeyRound } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import type { + BrowserWebAuthnAccount, + BrowserWebAuthnAccountRequest +} from '../../../shared/browser-webauthn-account' + +function accountLabels( + account: BrowserWebAuthnAccount, + index: number +): { primary: string; secondary: string | null } { + const primary = + account.displayName?.trim() || + account.name?.trim() || + `${translate('auto.components.browser.webauthn.account.fallback', 'Passkey')} ${index + 1}` + const secondary = account.name?.trim() + return { primary, secondary: secondary && secondary !== primary ? secondary : null } +} + +export function BrowserWebAuthnAccountDialog(): React.JSX.Element { + const [requests, setRequests] = useState([]) + const [respondingRequestId, setRespondingRequestId] = useState(null) + const requestsRef = useRef(requests) + const firstAccountRef = useRef(null) + const setContextualToursBlockingSurfaceVisible = useAppStore( + (state) => state.setContextualToursBlockingSurfaceVisible + ) + const activeRequest = requests[0] ?? null + const lastRequestRef = useRef(activeRequest) + requestsRef.current = requests + if (activeRequest) { + lastRequestRef.current = activeRequest + } + const displayedRequest = activeRequest ?? lastRequestRef.current + + const removeRequest = useCallback((requestId: string) => { + setRequests((current) => current.filter((request) => request.requestId !== requestId)) + setRespondingRequestId((current) => (current === requestId ? null : current)) + }, []) + + useEffect(() => { + const stopRequests = window.api.browser.onWebAuthnAccountRequest((request) => { + setRequests((current) => [...current, request]) + }) + const stopClosures = window.api.browser.onWebAuthnAccountRequestClosed(({ requestId }) => { + removeRequest(requestId) + }) + return () => { + stopRequests() + stopClosures() + for (const request of requestsRef.current) { + void window.api.browser + .respondWebAuthnAccount({ + requestId: request.requestId, + credentialId: null + }) + .catch(() => {}) + } + } + }, [removeRequest]) + + useEffect(() => { + setContextualToursBlockingSurfaceVisible(activeRequest !== null) + return () => setContextualToursBlockingSurfaceVisible(false) + }, [activeRequest, setContextualToursBlockingSurfaceVisible]) + + useEffect(() => { + if (!activeRequest) { + return + } + const focusTimer = setTimeout(() => firstAccountRef.current?.focus()) + return () => clearTimeout(focusTimer) + }, [activeRequest]) + + const respond = useCallback( + (credentialId: string | null) => { + const request = requestsRef.current[0] + if (!request || respondingRequestId === request.requestId) { + return + } + setRespondingRequestId(request.requestId) + void window.api.browser + .respondWebAuthnAccount({ requestId: request.requestId, credentialId }) + .then((accepted) => { + if (accepted) { + removeRequest(request.requestId) + } else { + setRespondingRequestId(null) + } + }) + .catch(() => setRespondingRequestId(null)) + }, + [removeRequest, respondingRequestId] + ) + + return ( + !open && respond(null)}> + { + event.preventDefault() + firstAccountRef.current?.focus() + }} + > + + + {translate('auto.components.browser.webauthn.account.title', 'Choose a passkey')} + + + {translate( + 'auto.components.browser.webauthn.account.description', + 'Choose the account to use with this security key.' + )} + + + +
+ {displayedRequest?.accounts.map((account, index) => { + const labels = accountLabels(account, index) + return ( + + ) + })} +
+ +

+ {translate('auto.components.browser.webauthn.account.site', 'Site')}{' '} + {displayedRequest?.relyingPartyId} +

+ + + + +
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ca05c8f57d4..20d450af06a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14324,6 +14324,15 @@ } } } + }, + "webauthn": { + "account": { + "fallback": "Passkey", + "title": "Choose a passkey", + "description": "Choose the account to use with this security key.", + "site": "Site", + "cancel": "Cancel" + } } }, "automations": { diff --git a/src/shared/browser-webauthn-account.ts b/src/shared/browser-webauthn-account.ts new file mode 100644 index 00000000000..d80792bb8f3 --- /dev/null +++ b/src/shared/browser-webauthn-account.ts @@ -0,0 +1,17 @@ +export type BrowserWebAuthnAccount = { + credentialId: string + displayName?: string + name?: string +} + +export type BrowserWebAuthnAccountRequest = { + requestId: string + browserPageId: string + relyingPartyId: string + accounts: BrowserWebAuthnAccount[] +} + +export type BrowserWebAuthnAccountResponse = { + requestId: string + credentialId: string | null +}