mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(browser): prompt for WebAuthn account selection
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<void>)
|
||||
| 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')
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
onDestroyed: () => void
|
||||
settle: (credentialId: string | null) => void
|
||||
}
|
||||
|
||||
const pendingAccountRequests = new Map<string, PendingAccountRequest>()
|
||||
|
||||
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<string | null> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
(
|
||||
|
||||
@@ -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<boolean>
|
||||
unregisterGuest: (args: { browserPageId: string }) => Promise<void>
|
||||
onWebAuthnAccountRequest: (
|
||||
callback: (request: BrowserWebAuthnAccountRequest) => void
|
||||
) => () => void
|
||||
onWebAuthnAccountRequestClosed: (callback: (event: { requestId: string }) => void) => () => void
|
||||
respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse) => Promise<boolean>
|
||||
openDevTools: (args: { browserPageId: string }) => Promise<boolean>
|
||||
setViewportOverride: (args: {
|
||||
browserPageId: string
|
||||
|
||||
@@ -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<void> =>
|
||||
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<boolean> =>
|
||||
ipcRenderer.invoke('browser:respondWebAuthnAccount', response),
|
||||
|
||||
openDevTools: (args: { browserPageId: string }): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser:openDevTools', args),
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<BrowserWebAuthnAccountDialog />
|
||||
</LinkRoutingPreferenceDialogProvider>
|
||||
</ConfirmationDialogProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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(<BrowserWebAuthnAccountDialog />)
|
||||
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(<BrowserWebAuthnAccountDialog />)
|
||||
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(<BrowserWebAuthnAccountDialog />)
|
||||
showRequest()
|
||||
|
||||
act(() => closedListener({ requestId: 'request-1' }))
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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<BrowserWebAuthnAccountRequest[]>([])
|
||||
const [respondingRequestId, setRespondingRequestId] = useState<string | null>(null)
|
||||
const requestsRef = useRef(requests)
|
||||
const firstAccountRef = useRef<HTMLButtonElement | null>(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 (
|
||||
<Dialog open={activeRequest !== null} onOpenChange={(open) => !open && respond(null)}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="sm:max-w-md"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
firstAccountRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.browser.webauthn.account.title', 'Choose a passkey')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.browser.webauthn.account.description',
|
||||
'Choose the account to use with this security key.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
{displayedRequest?.accounts.map((account, index) => {
|
||||
const labels = accountLabels(account, index)
|
||||
return (
|
||||
<Button
|
||||
key={`${account.credentialId}-${index}`}
|
||||
ref={index === 0 ? firstAccountRef : undefined}
|
||||
autoFocus={index === 0}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start gap-3 px-3 py-3 text-left"
|
||||
disabled={respondingRequestId === displayedRequest.requestId}
|
||||
onClick={() => respond(account.credentialId)}
|
||||
>
|
||||
<KeyRound className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{labels.primary}</span>
|
||||
{labels.secondary ? (
|
||||
<span className="block truncate text-xs font-normal text-muted-foreground">
|
||||
{labels.secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.browser.webauthn.account.site', 'Site')}{' '}
|
||||
<span className="font-mono text-foreground">{displayedRequest?.relyingPartyId}</span>
|
||||
</p>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => respond(null)}>
|
||||
{translate('auto.components.browser.webauthn.account.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user