fix(browser): prompt for WebAuthn account selection

This commit is contained in:
Brennan Benson
2026-08-14 19:03:21 -07:00
parent 73bbe20ca7
commit 0da71f8f7f
13 changed files with 712 additions and 5 deletions
+11
View File
@@ -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')
})
})
+17 -5
View File
@@ -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)
}
}
+17
View File
@@ -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',
(
+9
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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>
)
}
+9
View File
@@ -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": {
+17
View File
@@ -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
}