From 72b2816f0f38b522c091e2af145f34fb904c5997 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 28 Jul 2026 12:43:39 -0400 Subject: [PATCH 1/3] fix(api): switch a passkey 2FA prompt to the authenticator-code method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendors like GitHub default the 2FA step to a passkey / security-key prompt (github.com/sessions/two-factor/webauthn) — which we can't use and which has no 6-digit field, so the panel said 'enter the code' while the page showed no place to type it. After sign-in, best-effort switch to the authenticator-app option (More options -> Use authenticator app / Enter a code) so the code field is actually shown — for us to fill a stored code, or for the user to type one during take-over. +test. --- .../browser-credential-login.spec.ts | 22 +++++++++++++++++++ .../browserbase/browser-credential-login.ts | 10 +++++++++ 2 files changed, 32 insertions(+) diff --git a/apps/api/src/browserbase/browser-credential-login.spec.ts b/apps/api/src/browserbase/browser-credential-login.spec.ts index 18bc320fba..480e034e82 100644 --- a/apps/api/src/browserbase/browser-credential-login.spec.ts +++ b/apps/api/src/browserbase/browser-credential-login.spec.ts @@ -67,6 +67,28 @@ describe('performCredentialLogin', () => { expect(instruction).not.toContain('424242'); } }); + + it('switches a passkey / security-key prompt to the authenticator-code method', async () => { + const stagehand = makeStagehand(); + + const promise = performCredentialLogin({ + stagehand: stagehand as unknown as Stagehand, + credentials: { username: 'alice', password: 'pw' }, // no stored code → take-over + log: jest.fn(), + }); + await jest.runAllTimersAsync(); + await promise; + + // A best-effort step switches to the code method when a vendor defaults 2FA + // to a passkey, so a 6-digit field is actually shown (for us or the user). + const calls = stagehand.act.mock.calls as [string, unknown?][]; + const switchCall = calls.find( + ([instruction]) => + instruction.includes('passkey') && + instruction.includes('authenticator app'), + ); + expect(switchCall).toBeDefined(); + }); }); describe('reloginWithStoredCredentials', () => { diff --git a/apps/api/src/browserbase/browser-credential-login.ts b/apps/api/src/browserbase/browser-credential-login.ts index bcae5ea779..c1aa41b00c 100644 --- a/apps/api/src/browserbase/browser-credential-login.ts +++ b/apps/api/src/browserbase/browser-credential-login.ts @@ -111,6 +111,16 @@ export async function performCredentialLogin({ await delay(2000); } + // Some vendors (e.g. GitHub) default the two-factor step to a passkey / + // security-key prompt, which we can't use. If we've landed on one, switch to + // the authenticator-app option so a 6-digit code field is actually shown — for + // us to fill a stored code, or for the user to type one during take-over. + // Best-effort; a no-op when the page isn't a passkey prompt. + await stagehand.act( + "If this page is asking to authenticate with a passkey or security key (for example a 'Use passkey' button) instead of a code, switch to the authenticator app: click 'More options', then 'Use authenticator app', 'Enter a two-factor code', 'Use a security code', or a similar option so a six-digit code field appears. If a code field is already visible, or this is not a two-factor page, do nothing.", + ); + await delay(1500); + if (credentials.totpCode) { log('Entering one-time passcode.'); await stagehand.act( From e65f2cb08d63b3cbeb474b896da376b234665ebe Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 28 Jul 2026 13:00:46 -0400 Subject: [PATCH 2/3] fix: tailor the 2FA take-over to what the page actually asks for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the two-factor take-over universal instead of assuming an authenticator code: - Classify the verification page into how it wants the user to verify (a code, a passkey that can be switched away from, a passkey-only login, or another challenge) and return it from the sign-in result. - When Comp AI has a stored code, auto-switch off a passkey so the code field appears, then fill it. When a human is taking over, don't pick a method for them — just reveal the vendor's other options so they choose the one they can complete. - Show exact guidance in the take-over panel from that classification: enter the code, switch off the passkey, or a plain notice that a passkey-only login can't be automated (with the confirm button hidden, since there's nothing to complete). No vendor names are hard-coded. Adds unit coverage for the classifier, the take-over vs stored-code login behavior, and the panel copy for each method state. --- .../browser-credential-login.spec.ts | 69 +++++++++++++++- .../browserbase/browser-credential-login.ts | 82 ++++++++++++++++--- .../browser-credential-signin.service.spec.ts | 18 ++++ .../browser-credential-signin.service.ts | 20 ++++- .../ConnectLiveSignin.test.tsx | 71 ++++++++++++++++ .../browser-automations/ConnectLiveSignin.tsx | 65 ++++++++++++--- .../ConnectVendorLoginFlow.tsx | 50 ++++++++--- 7 files changed, 336 insertions(+), 39 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectLiveSignin.test.tsx diff --git a/apps/api/src/browserbase/browser-credential-login.spec.ts b/apps/api/src/browserbase/browser-credential-login.spec.ts index 480e034e82..3f220b6292 100644 --- a/apps/api/src/browserbase/browser-credential-login.spec.ts +++ b/apps/api/src/browserbase/browser-credential-login.spec.ts @@ -1,4 +1,5 @@ import { + classifyTwoFactorMethod, performCredentialLogin, reloginWithStoredCredentials, safeOriginAndPath, @@ -68,19 +69,19 @@ describe('performCredentialLogin', () => { } }); - it('switches a passkey / security-key prompt to the authenticator-code method', async () => { + it('switches a passkey prompt to the authenticator-code method when a code is stored', async () => { const stagehand = makeStagehand(); const promise = performCredentialLogin({ stagehand: stagehand as unknown as Stagehand, - credentials: { username: 'alice', password: 'pw' }, // no stored code → take-over + credentials: { username: 'alice', password: 'pw', totpCode: '424242' }, log: jest.fn(), }); await jest.runAllTimersAsync(); await promise; - // A best-effort step switches to the code method when a vendor defaults 2FA - // to a passkey, so a 6-digit field is actually shown (for us or the user). + // With a code to fill and no human present, we auto-switch off a passkey so + // the code field appears, then enter the stored code. const calls = stagehand.act.mock.calls as [string, unknown?][]; const switchCall = calls.find( ([instruction]) => @@ -89,6 +90,66 @@ describe('performCredentialLogin', () => { ); expect(switchCall).toBeDefined(); }); + + it('reveals other sign-in methods without choosing one when a passkey blocks a take-over', async () => { + const stagehand = makeStagehand(); + + const promise = performCredentialLogin({ + stagehand: stagehand as unknown as Stagehand, + credentials: { username: 'alice', password: 'pw' }, // no stored code → human take-over + log: jest.fn(), + }); + await jest.runAllTimersAsync(); + await promise; + + // With no code to fill, a human takes over — we only surface the other + // methods and never pick one, so the user chooses what they can complete. + const calls = stagehand.act.mock.calls as [string, unknown?][]; + const revealCall = calls.find( + ([instruction]) => + instruction.includes('passkey') && + instruction.includes('More options') && + instruction.includes('do NOT select'), + ); + expect(revealCall).toBeDefined(); + // And it must NOT force the authenticator method (that would pick for them). + const forcedSwitch = calls.find(([instruction]) => + instruction.includes('switch to the authenticator app'), + ); + expect(forcedSwitch).toBeUndefined(); + }); +}); + +describe('classifyTwoFactorMethod', () => { + const makeStagehandWithExtract = (result: unknown) => + ({ extract: jest.fn().mockResolvedValue(result) }) as unknown as Stagehand; + + it('returns the classified method for each valid response', async () => { + for (const method of [ + 'code', + 'passkey', + 'passkey_only', + 'other', + ] as const) { + await expect( + classifyTwoFactorMethod(makeStagehandWithExtract({ method })), + ).resolves.toBe(method); + } + }); + + it('degrades to "other" for a non-conforming or missing value', async () => { + // Wrong shape (e.g. an outcome payload) — must not leak undefined. + await expect( + classifyTwoFactorMethod(makeStagehandWithExtract({ state: 'needs_2fa' })), + ).resolves.toBe('other'); + }); + + it('degrades to "other" when extraction throws', async () => { + const stagehand = { + extract: jest.fn().mockRejectedValue(new Error('boom')), + } as unknown as Stagehand; + await expect(classifyTwoFactorMethod(stagehand)).resolves.toBe('other'); + }); }); describe('reloginWithStoredCredentials', () => { diff --git a/apps/api/src/browserbase/browser-credential-login.ts b/apps/api/src/browserbase/browser-credential-login.ts index c1aa41b00c..231ed6a813 100644 --- a/apps/api/src/browserbase/browser-credential-login.ts +++ b/apps/api/src/browserbase/browser-credential-login.ts @@ -111,17 +111,16 @@ export async function performCredentialLogin({ await delay(2000); } - // Some vendors (e.g. GitHub) default the two-factor step to a passkey / - // security-key prompt, which we can't use. If we've landed on one, switch to - // the authenticator-app option so a 6-digit code field is actually shown — for - // us to fill a stored code, or for the user to type one during take-over. - // Best-effort; a no-op when the page isn't a passkey prompt. - await stagehand.act( - "If this page is asking to authenticate with a passkey or security key (for example a 'Use passkey' button) instead of a code, switch to the authenticator app: click 'More options', then 'Use authenticator app', 'Enter a two-factor code', 'Use a security code', or a similar option so a six-digit code field appears. If a code field is already visible, or this is not a two-factor page, do nothing.", - ); - await delay(1500); - if (credentials.totpCode) { + // We have a code to enter. Some vendors (e.g. GitHub) default the two-factor + // step to a passkey / security key, which we can't use — switch to the + // authenticator-app method first so a six-digit code field appears, then fill + // it. Best-effort; a no-op when a code field is already shown. Vendor-agnostic: + // the model recognizes the prompt and the "use another method" control itself. + await stagehand.act( + "If this page is asking for a passkey or security key instead of a code, switch to the authenticator app: click 'More options', then 'Use authenticator app', 'Enter a two-factor code', 'Use a security code', or a similar option so a six-digit code field appears. If a code field is already visible, or this is not a two-factor page, do nothing.", + ); + await delay(1500); log('Entering one-time passcode.'); await stagehand.act( 'If a one-time passcode, two-factor, or verification code field is shown, enter %code% into it. If no such field is present, do nothing.', @@ -132,6 +131,69 @@ export async function performCredentialLogin({ 'If there is a button to submit or verify the code, click it. Otherwise do nothing.', ); await delay(2000); + } else { + // No stored code — if a two-factor step blocks us, a human takes over. Don't + // pick a method for them: if the page defaults to a passkey / security key + // (which can't be used here), just REVEAL the other options so they can choose + // the one they can actually complete (authenticator app, SMS, email). We never + // select a method — the choice is theirs. Best-effort no-op otherwise. + await stagehand.act( + "If this page is asking for a passkey or security key, reveal the other sign-in methods by clicking 'More options', 'Try another way', 'Use a different method', or a similar control — but do NOT select any specific method. If a verification-code field or the list of options is already visible, or this is not a two-factor page, do nothing.", + ); + await delay(1500); + } +} + +/** + * How a verification step is asking the user to prove identity, so a take-over + * UI can give exact guidance: + * - `code` — a one-time / authenticator / SMS / email code can be entered. + * - `passkey` — a passkey / security key is requested, but another method + * (a code) can be chosen instead. + * - `passkey_only` — a passkey / security key is the ONLY option; can't be + * completed in the automated browser. + * - `other` — a different verification (device approval, CAPTCHA, or an + * email/SMS link to click). + */ +export type TwoFactorMethod = 'code' | 'passkey' | 'passkey_only' | 'other'; + +/** + * Reads a verification page and classifies HOW it wants the user to verify, so + * the connect flow can tell them exactly what to do during a take-over. Runs + * only when a human take-over is imminent (a small extra cost on that path). + * Never throws — an unreadable or unexpected page degrades to 'other'. + */ +export async function classifyTwoFactorMethod( + stagehand: Stagehand, +): Promise { + try { + const { method } = await stagehand.extract( + 'This page is a sign-in verification step. Classify how it is asking the ' + + 'user to verify, and whether another method can be chosen. Return exactly one:\n' + + '"code" — a one-time / authenticator / SMS / email verification CODE can be ' + + 'entered right now (a code field is visible);\n' + + '"passkey" — it is asking for a passkey or security key, AND there is a way to ' + + "switch to another method (a 'More options', 'Try another way', or 'use a code' control);\n" + + '"passkey_only" — it is asking for a passkey or security key and NO other method ' + + 'is offered;\n' + + '"other" — a different verification (a device approval/notification, a CAPTCHA, ' + + 'or an email/SMS link to click).', + z.object({ + method: z.enum(['code', 'passkey', 'passkey_only', 'other']), + }), + ); + // Guard against a non-conforming value (e.g. a mocked/edge response) so the + // return type always holds. + if ( + method === 'code' || + method === 'passkey' || + method === 'passkey_only' + ) { + return method; + } + return 'other'; + } catch { + return 'other'; } } diff --git a/apps/api/src/browserbase/browser-credential-signin.service.spec.ts b/apps/api/src/browserbase/browser-credential-signin.service.spec.ts index 1aa5a0a20f..07de09eb73 100644 --- a/apps/api/src/browserbase/browser-credential-signin.service.spec.ts +++ b/apps/api/src/browserbase/browser-credential-signin.service.spec.ts @@ -175,6 +175,24 @@ describe('BrowserCredentialSigninService', () => { expect(profiles.markNeedsReauth).toHaveBeenCalledTimes(1); }); + it('classifies the verification method for a take-over so the UI can guide the user', async () => { + // 1) already-signed-in check → not in; 2) post-sign-in outcome → needs_2fa; + // 3) how the page verifies → a passkey the user can switch away from. + const extract = jest + .fn() + .mockResolvedValueOnce({ state: 'unknown' }) + .mockResolvedValueOnce({ state: 'needs_2fa' }) + .mockResolvedValueOnce({ method: 'passkey' }); + const sessions = makeSessions(extract, jest.fn().mockResolvedValue(undefined)); + const profiles = makeProfiles(profile); + withCredentials({ username: 'user@x.com', password: 'secret' }); // no totpCode + + const result = await run(sessions, profiles); + + expect(result.failure).toBe('needs_2fa'); + expect(result.twoFactorMethod).toBe('passkey'); + }); + it('re-points the live view after the sign-in attempt so a 2FA take-over sees the right tab', async () => { const extract = jest .fn() diff --git a/apps/api/src/browserbase/browser-credential-signin.service.ts b/apps/api/src/browserbase/browser-credential-signin.service.ts index e27f4b92bc..87954f37bc 100644 --- a/apps/api/src/browserbase/browser-credential-signin.service.ts +++ b/apps/api/src/browserbase/browser-credential-signin.service.ts @@ -3,7 +3,9 @@ import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserAuthProfileService } from './browser-auth-profile.service'; import { classifyLoginOutcome, + classifyTwoFactorMethod, signInAndClassify, + type TwoFactorMethod, } from './browser-credential-login'; import { navigateToSignIn } from './browser-login-navigation'; import { resolveBrowserCredentialVaultAdapter } from './browser-credential-vault.factory'; @@ -46,6 +48,13 @@ export interface AutoSignInResult { * (e.g. sites whose root always shows a login form). */ homeUrl?: string; + /** + * For a take-over (not signed in): HOW the verification step wants the user to + * verify, so the UI can give exact guidance — enter a code, switch off a + * passkey, or that a passkey-only login can't be automated. Omitted when the + * page couldn't be classified. + */ + twoFactorMethod?: TwoFactorMethod; } const FAILURE_REASON: Record = { @@ -249,7 +258,16 @@ export class BrowserCredentialSigninService { }); await record(outcome, FAILURE_REASON[outcome]); finish('warn'); - return { isLoggedIn: false, failure: outcome }; + + // A human is about to take over a verification step — read HOW the page + // wants them to verify (enter a code, switch off a passkey, or a + // passkey-only login we can't automate) so the UI gives exact guidance. + // Best-effort: undefined just falls back to the generic take-over copy. + let twoFactorMethod: TwoFactorMethod | undefined; + if (outcome === 'needs_2fa' || outcome === 'challenge') { + twoFactorMethod = await classifyTwoFactorMethod(activeStagehand); + } + return { isLoggedIn: false, failure: outcome, twoFactorMethod }; } catch (error) { // An unexpected error (session/navigation/model failure) is exactly the // kind of thing a "can't connect" ticket is about — record it, then let diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectLiveSignin.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectLiveSignin.test.tsx new file mode 100644 index 0000000000..ffd54fe188 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectLiveSignin.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@trycompai/design-system', () => ({ + Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => ( + + ), +})); + +vi.mock('@trycompai/design-system/icons', () => ({ + Checkmark: () => , + Close: () => , + Locked: () => , +})); + +vi.mock('./LiveActivityBorder', () => ({ LiveActivityBorder: () =>
})); +vi.mock('./StepList', () => ({ StepList: () =>
})); + +import { ConnectLiveSignin } from './ConnectLiveSignin'; + +const baseProps = { + host: 'github.com', + liveViewUrl: 'https://live-view/1', + steps: [], + onCancel: vi.fn(), + onConfirm: vi.fn(), +}; + +describe('ConnectLiveSignin — 2FA take-over guidance', () => { + it('defaults to entering a code, with a confirm button', () => { + render( + , + ); + expect(screen.getByText('Enter the code in the page')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /entered it/i }), + ).toBeInTheDocument(); + }); + + it('tells the user to switch off a passkey when another method is available', () => { + render( + , + ); + expect(screen.getByText('Switch to a code method')).toBeInTheDocument(); + // Points them at the vendor's own "use another method" control. + expect(screen.getByText(/More options/)).toBeInTheDocument(); + // They can still complete it, so the confirm button stays. + expect( + screen.getByRole('button', { name: /entered it/i }), + ).toBeInTheDocument(); + }); + + it('warns and hides the confirm button when the login is passkey-only', () => { + render( + , + ); + expect(screen.getByText('Passkey-only login')).toBeInTheDocument(); + expect(screen.getByText(/can't be completed/i)).toBeInTheDocument(); + // Nothing here for the user to complete — don't offer a misleading button. + expect( + screen.queryByRole('button', { name: /entered it/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectLiveSignin.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectLiveSignin.tsx index 55a2ab8a03..98ac8c07d1 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectLiveSignin.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectLiveSignin.tsx @@ -9,6 +9,42 @@ import { StepList, type SignInStep } from './StepList'; /** ai = automation drives; 2fa = user enters a code; finish = user completes it. */ export type LiveSigninVariant = 'ai' | '2fa' | 'finish'; +/** How the blocked verification step wants the user to verify (mirrors the API). */ +export type TwoFactorMethod = 'code' | 'passkey' | 'passkey_only' | 'other'; + +/** + * Exact take-over guidance for the 2fa panel, tailored to what the page is + * actually asking for — so we never tell someone to "enter the code" when the + * page is showing a passkey prompt, and we say plainly when a passkey-only login + * can't be automated. `showConfirm` is false when there's nothing for the user + * to complete here. + */ +function twoFactorCopy(method: TwoFactorMethod | undefined): { + heading: string; + body: string; + showConfirm: boolean; +} { + if (method === 'passkey') { + return { + heading: 'Switch to a code method', + body: "This site is asking for a passkey or security key, which can't be used here. In the live browser, click “More options” or “Try another way”, choose your authenticator app, SMS, or email, enter the code, then confirm.", + showConfirm: true, + }; + } + if (method === 'passkey_only') { + return { + heading: 'Passkey-only login', + body: "This login only offers a passkey or security key, which can't be completed in the automated browser. To automate it, add a code-based method (authenticator app, SMS, or email) to this account, then reconnect.", + showConfirm: false, + }; + } + return { + heading: 'Enter the code in the page', + body: 'Type the 6-digit code from your authenticator app (or the SMS/email code) into the live browser, then confirm. Passkeys can’t be used here.', + showConfirm: true, + }; +} + interface ConnectLiveSigninProps { host: string; liveViewUrl: string | null; @@ -20,6 +56,8 @@ interface ConnectLiveSigninProps { isConfirming?: boolean; /** Sign-in just succeeded — show a brief confirmation before moving on. */ success?: boolean; + /** How the verification step wants the user to verify — tailors the 2fa copy. */ + twoFactorMethod?: TwoFactorMethod; } function StatusPill({ variant }: { variant: LiveSigninVariant }) { @@ -66,11 +104,15 @@ export function ConnectLiveSignin({ onConfirm, isConfirming = false, success = false, + twoFactorMethod, }: ConnectLiveSigninProps) { // Gate the ring on the iframe's load so it appears with the page, not before. const [loaded, setLoaded] = useState(false); useEffect(() => setLoaded(false), [liveViewUrl]); + // Tailor the take-over instructions to what the page is actually asking for. + const guidance = twoFactorCopy(twoFactorMethod); + return (
@@ -159,19 +201,20 @@ export function ConnectLiveSignin({ background: 'color-mix(in oklab, var(--warning) 10%, transparent)', }} > -
Enter the code in the page
+
{guidance.heading}
- Type the 6-digit code from your authenticator app into the live browser - (passkeys can’t be used here). Then confirm. + {guidance.body}
- + {guidance.showConfirm && ( + + )}
)} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx index 6b87160bf5..c37e9b588d 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx @@ -24,7 +24,11 @@ import { normalizeUrl, stripScheme } from './connect-url'; import type { ConnectCaptureFormData } from './ConnectCaptureForm'; import { ConnectFlowRail } from './ConnectFlowRail'; import { ConnectFlowStage } from './ConnectFlowStage'; -import { ConnectLiveSignin, type LiveSigninVariant } from './ConnectLiveSignin'; +import { + ConnectLiveSignin, + type LiveSigninVariant, + type TwoFactorMethod, +} from './ConnectLiveSignin'; import type { ConnectMethodKind } from './ConnectMethodChooser'; import type { SignInStep } from './StepList'; @@ -73,6 +77,11 @@ export function ConnectVendorLoginFlow({ accessToken: string; } | null>(null); const [takeoverVariant, setTakeoverVariant] = useState('finish'); + // How the blocked verification wants the user to verify (code / passkey / + // passkey-only), so the take-over panel gives exact guidance. + const [takeoverMethod, setTakeoverMethod] = useState< + TwoFactorMethod | undefined + >(undefined); // The activity timeline, mirrored into flow state so it survives into the // take-over view after the run's realtime subscription is torn down. const [signinSteps, setSigninSteps] = useState([]); @@ -87,16 +96,25 @@ export function ConnectVendorLoginFlow({ useSigninSession(); // Hand the (still-open) browser to the user to finish the sign-in themselves. - const goToTakeover = useCallback((failure?: string) => { - setSigninRun(null); - toast.info( - failure === 'needs_2fa' - ? 'Enter your two-factor code to finish the sign-in.' - : 'Finish the sign-in in the browser.', - ); - setTakeoverVariant(failure === 'needs_2fa' ? '2fa' : 'finish'); - setStep('takeover'); - }, []); + const goToTakeover = useCallback( + (failure?: string, method?: TwoFactorMethod) => { + setSigninRun(null); + setTakeoverMethod(method); + toast.info( + method === 'passkey_only' + ? "This login requires a passkey, which can't be completed here." + : failure === 'needs_2fa' + ? 'Enter your two-factor code to finish the sign-in.' + : 'Finish the sign-in in the browser.', + ); + // A passkey/code verification step is a "your turn" 2FA take-over; other + // challenges (device approval, CAPTCHA, link) use the generic finish panel. + const is2fa = failure === 'needs_2fa' || (!!method && method !== 'other'); + setTakeoverVariant(is2fa ? '2fa' : 'finish'); + setStep('takeover'); + }, + [], + ); // Analysis (browser + AI) runs as a background task. Watching run/error also // handles resume, where the run may already be complete on subscribe. @@ -148,7 +166,12 @@ export function ConnectVendorLoginFlow({ if (signinRunState.status === 'COMPLETED') { const output = signinRunState.output as - | { isLoggedIn?: boolean; failure?: string; homeUrl?: string } + | { + isLoggedIn?: boolean; + failure?: string; + homeUrl?: string; + twoFactorMethod?: TwoFactorMethod; + } | undefined; if (output?.isLoggedIn) { @@ -163,7 +186,7 @@ export function ConnectVendorLoginFlow({ toast.error("That username or password wasn't accepted — check and try again."); setStep('capture'); } else { - goToTakeover(output?.failure); + goToTakeover(output?.failure, output?.twoFactorMethod); } } else if (FAILED_RUN_STATUSES.has(signinRunState.status)) { goToTakeover(); @@ -383,6 +406,7 @@ export function ConnectVendorLoginFlow({ variant={variant} success={success} steps={signinSteps} + twoFactorMethod={takeoverMethod} onConfirm={ step === 'signing-in' || success ? undefined From a7075994f9bf9d1296398b6985d7246f7460c323 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 28 Jul 2026 13:31:39 -0400 Subject: [PATCH 3/3] refactor: address review on the 2FA take-over changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split the passkey→code switch into two single-action steps (reveal the other options, then choose the authenticator/code method). act() performs one action per call, so a combined 'click More options, then Use authenticator app' could stop after the first click and never reach the code field. - Trust an explicit method classification when routing the take-over panel: an 'other' verification (device approval, CAPTCHA, link) now uses the generic finish panel instead of code-entry guidance, even when the outcome is needs_2fa. Falls back to the failure code only when no method was detected. - Extract the page classifiers (classifyLoginOutcome, classifyTwoFactorMethod, safeOriginAndPath) into a focused browser-login-classifier module, bringing the login file back under the 300-line limit. Tests moved/updated alongside; 186 browserbase tests green, typecheck clean. --- .../browser-credential-login.spec.ts | 78 ++-------- .../browserbase/browser-credential-login.ts | 146 ++---------------- .../browser-credential-signin.service.ts | 4 +- .../browser-login-classifier.spec.ts | 59 +++++++ .../browserbase/browser-login-classifier.ts | 131 ++++++++++++++++ .../ConnectVendorLoginFlow.tsx | 9 +- 6 files changed, 225 insertions(+), 202 deletions(-) create mode 100644 apps/api/src/browserbase/browser-login-classifier.spec.ts create mode 100644 apps/api/src/browserbase/browser-login-classifier.ts diff --git a/apps/api/src/browserbase/browser-credential-login.spec.ts b/apps/api/src/browserbase/browser-credential-login.spec.ts index 3f220b6292..b39226f4fc 100644 --- a/apps/api/src/browserbase/browser-credential-login.spec.ts +++ b/apps/api/src/browserbase/browser-credential-login.spec.ts @@ -1,8 +1,6 @@ import { - classifyTwoFactorMethod, performCredentialLogin, reloginWithStoredCredentials, - safeOriginAndPath, } from './browser-credential-login'; import type { BrowserCredentialVaultAdapter } from './credential-vault'; import type { BrowserbaseSessionService } from './browserbase-session.service'; @@ -81,14 +79,19 @@ describe('performCredentialLogin', () => { await promise; // With a code to fill and no human present, we auto-switch off a passkey so - // the code field appears, then enter the stored code. + // the code field appears, then enter the stored code. Reaching the code field + // can take two clicks, and act() does one thing per call, so it's two steps: + // first reveal the other options, then choose the authenticator/code method. const calls = stagehand.act.mock.calls as [string, unknown?][]; - const switchCall = calls.find( + const revealCall = calls.find( ([instruction]) => - instruction.includes('passkey') && - instruction.includes('authenticator app'), + instruction.includes('passkey') && instruction.includes('More options'), + ); + const selectCall = calls.find(([instruction]) => + instruction.includes('authenticator app'), ); - expect(switchCall).toBeDefined(); + expect(revealCall).toBeDefined(); + expect(selectCall).toBeDefined(); }); it('reveals other sign-in methods without choosing one when a passkey blocks a take-over', async () => { @@ -112,43 +115,11 @@ describe('performCredentialLogin', () => { instruction.includes('do NOT select'), ); expect(revealCall).toBeDefined(); - // And it must NOT force the authenticator method (that would pick for them). - const forcedSwitch = calls.find(([instruction]) => - instruction.includes('switch to the authenticator app'), + // And it must NOT pick a method for them (no authenticator-app selection). + const forcedSelect = calls.find(([instruction]) => + instruction.includes('authenticator app'), ); - expect(forcedSwitch).toBeUndefined(); - }); -}); - -describe('classifyTwoFactorMethod', () => { - const makeStagehandWithExtract = (result: unknown) => - ({ extract: jest.fn().mockResolvedValue(result) }) as unknown as Stagehand; - - it('returns the classified method for each valid response', async () => { - for (const method of [ - 'code', - 'passkey', - 'passkey_only', - 'other', - ] as const) { - await expect( - classifyTwoFactorMethod(makeStagehandWithExtract({ method })), - ).resolves.toBe(method); - } - }); - - it('degrades to "other" for a non-conforming or missing value', async () => { - // Wrong shape (e.g. an outcome payload) — must not leak undefined. - await expect( - classifyTwoFactorMethod(makeStagehandWithExtract({ state: 'needs_2fa' })), - ).resolves.toBe('other'); - }); - - it('degrades to "other" when extraction throws', async () => { - const stagehand = { - extract: jest.fn().mockRejectedValue(new Error('boom')), - } as unknown as Stagehand; - await expect(classifyTwoFactorMethod(stagehand)).resolves.toBe('other'); + expect(forcedSelect).toBeUndefined(); }); }); @@ -269,24 +240,3 @@ describe('reloginWithStoredCredentials', () => { expect(result.reason).toMatch(/user action/i); }); }); - -describe('safeOriginAndPath (keeps auth secrets out of the LLM prompt)', () => { - it('drops the query and fragment (OAuth code/state/tokens)', () => { - expect( - safeOriginAndPath( - 'https://login.example.com/callback?code=SECRET&state=xyz#access_token=abc', - ), - ).toBe('https://login.example.com/callback'); - }); - - it('drops userinfo', () => { - expect(safeOriginAndPath('https://user:pass@example.com/app')).toBe( - 'https://example.com/app', - ); - }); - - it('returns empty string for an unparseable or empty URL', () => { - expect(safeOriginAndPath('not a url')).toBe(''); - expect(safeOriginAndPath('')).toBe(''); - }); -}); diff --git a/apps/api/src/browserbase/browser-credential-login.ts b/apps/api/src/browserbase/browser-credential-login.ts index 231ed6a813..fdfaf32b1d 100644 --- a/apps/api/src/browserbase/browser-credential-login.ts +++ b/apps/api/src/browserbase/browser-credential-login.ts @@ -1,9 +1,12 @@ -import { z } from 'zod'; import type { BrowserCredentialVaultAdapter, RuntimeCredentialMaterial, } from './credential-vault'; import type { BrowserbaseSessionService } from './browserbase-session.service'; +import { + classifyLoginOutcome, + type SignInOutcome, +} from './browser-login-classifier'; type Stagehand = import('@browserbasehq/stagehand').Stagehand; type ActivePage = Awaited< @@ -28,21 +31,6 @@ export interface CredentialLoginTarget { const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -/** - * Origin + path only — drops the query, fragment, and userinfo so secrets that - * land in an auth redirect (OAuth `code`/`state`, tokens) are never forwarded to - * the model. Returns '' for an unparseable URL. - */ -export function safeOriginAndPath(rawUrl: string): string { - if (!rawUrl) return ''; - try { - const url = new URL(rawUrl); - return `${url.origin}${url.pathname}`; - } catch { - return ''; - } -} - /** * Drives an automated sign-in using stored credentials. Secret values are passed * through Stagehand's `variables` substitution, so they are injected into the page @@ -114,11 +102,16 @@ export async function performCredentialLogin({ if (credentials.totpCode) { // We have a code to enter. Some vendors (e.g. GitHub) default the two-factor // step to a passkey / security key, which we can't use — switch to the - // authenticator-app method first so a six-digit code field appears, then fill - // it. Best-effort; a no-op when a code field is already shown. Vendor-agnostic: - // the model recognizes the prompt and the "use another method" control itself. + // authenticator/code method so a six-digit field appears, then fill it. + // Reaching a code field can take two clicks (expand the options, then choose + // the code method), and act() does ONE thing per call, so these are separate + // steps. Both are best-effort no-ops when a code field is already shown. await stagehand.act( - "If this page is asking for a passkey or security key instead of a code, switch to the authenticator app: click 'More options', then 'Use authenticator app', 'Enter a two-factor code', 'Use a security code', or a similar option so a six-digit code field appears. If a code field is already visible, or this is not a two-factor page, do nothing.", + "If this page is asking for a passkey or security key instead of a verification code, click a control that reveals the other sign-in options — 'More options', 'Try another way', 'Use a different method', or similar. If a code field or the list of options is already visible, or this is not a two-factor page, do nothing.", + ); + await delay(1000); + await stagehand.act( + "If there is an option to verify using an authenticator app or a two-factor code — 'Use authenticator app', 'Enter a two-factor code', 'Use a security code', 'Authenticator app', or similar — click it so a six-digit code field appears. If a code field is already visible, do nothing.", ); await delay(1500); log('Entering one-time passcode.'); @@ -144,59 +137,6 @@ export async function performCredentialLogin({ } } -/** - * How a verification step is asking the user to prove identity, so a take-over - * UI can give exact guidance: - * - `code` — a one-time / authenticator / SMS / email code can be entered. - * - `passkey` — a passkey / security key is requested, but another method - * (a code) can be chosen instead. - * - `passkey_only` — a passkey / security key is the ONLY option; can't be - * completed in the automated browser. - * - `other` — a different verification (device approval, CAPTCHA, or an - * email/SMS link to click). - */ -export type TwoFactorMethod = 'code' | 'passkey' | 'passkey_only' | 'other'; - -/** - * Reads a verification page and classifies HOW it wants the user to verify, so - * the connect flow can tell them exactly what to do during a take-over. Runs - * only when a human take-over is imminent (a small extra cost on that path). - * Never throws — an unreadable or unexpected page degrades to 'other'. - */ -export async function classifyTwoFactorMethod( - stagehand: Stagehand, -): Promise { - try { - const { method } = await stagehand.extract( - 'This page is a sign-in verification step. Classify how it is asking the ' + - 'user to verify, and whether another method can be chosen. Return exactly one:\n' + - '"code" — a one-time / authenticator / SMS / email verification CODE can be ' + - 'entered right now (a code field is visible);\n' + - '"passkey" — it is asking for a passkey or security key, AND there is a way to ' + - "switch to another method (a 'More options', 'Try another way', or 'use a code' control);\n" + - '"passkey_only" — it is asking for a passkey or security key and NO other method ' + - 'is offered;\n' + - '"other" — a different verification (a device approval/notification, a CAPTCHA, ' + - 'or an email/SMS link to click).', - z.object({ - method: z.enum(['code', 'passkey', 'passkey_only', 'other']), - }), - ); - // Guard against a non-conforming value (e.g. a mocked/edge response) so the - // return type always holds. - if ( - method === 'code' || - method === 'passkey' || - method === 'passkey_only' - ) { - return method; - } - return 'other'; - } catch { - return 'other'; - } -} - export interface CredentialReloginResult { isLoggedIn: boolean; page: ActivePage; @@ -294,66 +234,6 @@ async function runLoginAttempt({ return sessions.ensureActivePage(stagehand); } -export type SignInOutcome = - | 'logged_in' - | 'invalid_credentials' - | 'needs_2fa' - | 'challenge' - | 'unknown'; - -/** - * Reads the current page after a sign-in attempt and classifies the outcome, so - * the connect flow can tell the user what happened and route them correctly. - * Never throws — an unreadable page degrades to 'unknown'. - */ -export async function classifyLoginOutcome( - stagehand: Stagehand, -): Promise { - try { - // Give the model where the browser actually is, so it can judge for itself - // whether we're on the real app or still on a sign-in / identity-provider - // page (it knows hosts like signin.aws.amazon.com or login.microsoftonline.com - // without us hardcoding URL patterns). A hint only — falls back to content. - let currentUrl = ''; - try { - const pages = stagehand.context?.pages?.() ?? []; - const rawUrl = pages[pages.length - 1]?.url() ?? ''; - // Only the origin + path is needed to judge "is this a sign-in page". Strip - // the query, fragment, and userinfo so OAuth codes / tokens / state that - // land in an auth redirect are never forwarded to the model. - currentUrl = safeOriginAndPath(rawUrl); - } catch { - // URL unavailable — classify from page content alone. - } - const { state } = await stagehand.extract( - (currentUrl ? `The browser is currently at this URL: ${currentUrl}\n\n` : '') + - 'Classify this page after a sign-in attempt, using BOTH the page content AND the URL. ' + - 'Return exactly one value: ' + - '"logged_in" — there is clear evidence the user is signed in to the actual application ' + - '(a dashboard, an account/avatar menu, or a "Sign out" control) AND the browser is on the ' + - 'application itself. If the URL is a sign-in, login, SSO, or identity-provider page, or the ' + - 'page is blank, loading, redirecting, or still shows a sign-in form or a "Sign in" / ' + - '"Log in" button, it is NOT logged_in; ' + - '"invalid_credentials" — it shows an incorrect username/email/password error; ' + - '"needs_2fa" — it asks for a two-factor, one-time, authenticator, or verification code; ' + - '"challenge" — it shows a CAPTCHA, a "verify it\'s you", a device-approval, or an email/SMS link step; ' + - '"unknown" — none of the above clearly apply.', - z.object({ - state: z.enum([ - 'logged_in', - 'invalid_credentials', - 'needs_2fa', - 'challenge', - 'unknown', - ]), - }), - ); - return state; - } catch { - return 'unknown'; - } -} - /** * The connect flow's first sign-in: fill the stored credentials and classify * where we land. Unlike reloginWithStoredCredentials (used by the scheduler), diff --git a/apps/api/src/browserbase/browser-credential-signin.service.ts b/apps/api/src/browserbase/browser-credential-signin.service.ts index 87954f37bc..06a077cf05 100644 --- a/apps/api/src/browserbase/browser-credential-signin.service.ts +++ b/apps/api/src/browserbase/browser-credential-signin.service.ts @@ -1,12 +1,12 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserAuthProfileService } from './browser-auth-profile.service'; +import { signInAndClassify } from './browser-credential-login'; import { classifyLoginOutcome, classifyTwoFactorMethod, - signInAndClassify, type TwoFactorMethod, -} from './browser-credential-login'; +} from './browser-login-classifier'; import { navigateToSignIn } from './browser-login-navigation'; import { resolveBrowserCredentialVaultAdapter } from './browser-credential-vault.factory'; diff --git a/apps/api/src/browserbase/browser-login-classifier.spec.ts b/apps/api/src/browserbase/browser-login-classifier.spec.ts new file mode 100644 index 0000000000..724078e217 --- /dev/null +++ b/apps/api/src/browserbase/browser-login-classifier.spec.ts @@ -0,0 +1,59 @@ +import { + classifyTwoFactorMethod, + safeOriginAndPath, +} from './browser-login-classifier'; + +type Stagehand = import('@browserbasehq/stagehand').Stagehand; + +describe('classifyTwoFactorMethod', () => { + const makeStagehandWithExtract = (result: unknown) => + ({ extract: jest.fn().mockResolvedValue(result) }) as unknown as Stagehand; + + it('returns the classified method for each valid response', async () => { + for (const method of [ + 'code', + 'passkey', + 'passkey_only', + 'other', + ] as const) { + await expect( + classifyTwoFactorMethod(makeStagehandWithExtract({ method })), + ).resolves.toBe(method); + } + }); + + it('degrades to "other" for a non-conforming or missing value', async () => { + // Wrong shape (e.g. an outcome payload) — must not leak undefined. + await expect( + classifyTwoFactorMethod(makeStagehandWithExtract({ state: 'needs_2fa' })), + ).resolves.toBe('other'); + }); + + it('degrades to "other" when extraction throws', async () => { + const stagehand = { + extract: jest.fn().mockRejectedValue(new Error('boom')), + } as unknown as Stagehand; + await expect(classifyTwoFactorMethod(stagehand)).resolves.toBe('other'); + }); +}); + +describe('safeOriginAndPath (keeps auth secrets out of the LLM prompt)', () => { + it('drops the query and fragment (OAuth code/state/tokens)', () => { + expect( + safeOriginAndPath( + 'https://login.example.com/callback?code=SECRET&state=xyz#access_token=abc', + ), + ).toBe('https://login.example.com/callback'); + }); + + it('drops userinfo', () => { + expect(safeOriginAndPath('https://user:pass@example.com/app')).toBe( + 'https://example.com/app', + ); + }); + + it('returns empty string for an unparseable or empty URL', () => { + expect(safeOriginAndPath('not a url')).toBe(''); + expect(safeOriginAndPath('')).toBe(''); + }); +}); diff --git a/apps/api/src/browserbase/browser-login-classifier.ts b/apps/api/src/browserbase/browser-login-classifier.ts new file mode 100644 index 0000000000..e6e61c163d --- /dev/null +++ b/apps/api/src/browserbase/browser-login-classifier.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; + +type Stagehand = import('@browserbasehq/stagehand').Stagehand; + +/** + * Origin + path only — drops the query, fragment, and userinfo so secrets that + * land in an auth redirect (OAuth `code`/`state`, tokens) are never forwarded to + * the model. Returns '' for an unparseable URL. + */ +export function safeOriginAndPath(rawUrl: string): string { + if (!rawUrl) return ''; + try { + const url = new URL(rawUrl); + return `${url.origin}${url.pathname}`; + } catch { + return ''; + } +} + +export type SignInOutcome = + | 'logged_in' + | 'invalid_credentials' + | 'needs_2fa' + | 'challenge' + | 'unknown'; + +/** + * Reads the current page after a sign-in attempt and classifies the outcome, so + * the connect flow can tell the user what happened and route them correctly. + * Never throws — an unreadable page degrades to 'unknown'. + */ +export async function classifyLoginOutcome( + stagehand: Stagehand, +): Promise { + try { + // Give the model where the browser actually is, so it can judge for itself + // whether we're on the real app or still on a sign-in / identity-provider + // page (it knows hosts like signin.aws.amazon.com or login.microsoftonline.com + // without us hardcoding URL patterns). A hint only — falls back to content. + let currentUrl = ''; + try { + const pages = stagehand.context?.pages?.() ?? []; + const rawUrl = pages[pages.length - 1]?.url() ?? ''; + // Only the origin + path is needed to judge "is this a sign-in page". Strip + // the query, fragment, and userinfo so OAuth codes / tokens / state that + // land in an auth redirect are never forwarded to the model. + currentUrl = safeOriginAndPath(rawUrl); + } catch { + // URL unavailable — classify from page content alone. + } + const { state } = await stagehand.extract( + (currentUrl ? `The browser is currently at this URL: ${currentUrl}\n\n` : '') + + 'Classify this page after a sign-in attempt, using BOTH the page content AND the URL. ' + + 'Return exactly one value: ' + + '"logged_in" — there is clear evidence the user is signed in to the actual application ' + + '(a dashboard, an account/avatar menu, or a "Sign out" control) AND the browser is on the ' + + 'application itself. If the URL is a sign-in, login, SSO, or identity-provider page, or the ' + + 'page is blank, loading, redirecting, or still shows a sign-in form or a "Sign in" / ' + + '"Log in" button, it is NOT logged_in; ' + + '"invalid_credentials" — it shows an incorrect username/email/password error; ' + + '"needs_2fa" — it asks for a two-factor, one-time, authenticator, or verification code; ' + + '"challenge" — it shows a CAPTCHA, a "verify it\'s you", a device-approval, or an email/SMS link step; ' + + '"unknown" — none of the above clearly apply.', + z.object({ + state: z.enum([ + 'logged_in', + 'invalid_credentials', + 'needs_2fa', + 'challenge', + 'unknown', + ]), + }), + ); + return state; + } catch { + return 'unknown'; + } +} + +/** + * How a verification step is asking the user to prove identity, so a take-over + * UI can give exact guidance: + * - `code` — a one-time / authenticator / SMS / email code can be entered. + * - `passkey` — a passkey / security key is requested, but another method + * (a code) can be chosen instead. + * - `passkey_only` — a passkey / security key is the ONLY option; can't be + * completed in the automated browser. + * - `other` — a different verification (device approval, CAPTCHA, or an + * email/SMS link to click). + */ +export type TwoFactorMethod = 'code' | 'passkey' | 'passkey_only' | 'other'; + +/** + * Reads a verification page and classifies HOW it wants the user to verify, so + * the connect flow can tell them exactly what to do during a take-over. Runs + * only when a human take-over is imminent (a small extra cost on that path). + * Never throws — an unreadable or unexpected page degrades to 'other'. + */ +export async function classifyTwoFactorMethod( + stagehand: Stagehand, +): Promise { + try { + const { method } = await stagehand.extract( + 'This page is a sign-in verification step. Classify how it is asking the ' + + 'user to verify, and whether another method can be chosen. Return exactly one:\n' + + '"code" — a one-time / authenticator / SMS / email verification CODE can be ' + + 'entered right now (a code field is visible);\n' + + '"passkey" — it is asking for a passkey or security key, AND there is a way to ' + + "switch to another method (a 'More options', 'Try another way', or 'use a code' control);\n" + + '"passkey_only" — it is asking for a passkey or security key and NO other method ' + + 'is offered;\n' + + '"other" — a different verification (a device approval/notification, a CAPTCHA, ' + + 'or an email/SMS link to click).', + z.object({ + method: z.enum(['code', 'passkey', 'passkey_only', 'other']), + }), + ); + // Guard against a non-conforming value (e.g. a mocked/edge response) so the + // return type always holds. + if ( + method === 'code' || + method === 'passkey' || + method === 'passkey_only' + ) { + return method; + } + return 'other'; + } catch { + return 'other'; + } +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx index c37e9b588d..66b6f070f4 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx @@ -107,9 +107,12 @@ export function ConnectVendorLoginFlow({ ? 'Enter your two-factor code to finish the sign-in.' : 'Finish the sign-in in the browser.', ); - // A passkey/code verification step is a "your turn" 2FA take-over; other - // challenges (device approval, CAPTCHA, link) use the generic finish panel. - const is2fa = failure === 'needs_2fa' || (!!method && method !== 'other'); + // Trust an explicit classification: a passkey/code step is a "your turn" + // 2FA take-over, while 'other' (device approval, CAPTCHA, link) uses the + // generic finish panel. Only when no method was detected do we fall back to + // the raw failure code. + const is2fa = + method === undefined ? failure === 'needs_2fa' : method !== 'other'; setTakeoverVariant(is2fa ? '2fa' : 'finish'); setStep('takeover'); },