From d641a32e7f2f884456fb9848140bbc3ae4568e26 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Jul 2026 11:46:34 -0400 Subject: [PATCH 001/117] feat(app): capture vendor login credentials in the connect flow Adds the first step of the browser-automation connect wizard: a credentials form (username, password, optional authenticator setup key) shown before the live sign-in. On submit it resolves the auth profile and stores the login via the credentials endpoint so scheduled and manual runs can sign in on their own. Credential storage failures are non-fatal and fall back to manual sign-in. Part of the Browser Automations redesign (connect-a-login flow). Adds unit tests for the form (fields, validation, submit, password toggle). --- .../components/BrowserAutomations.tsx | 50 +++++- .../ConnectCredentialsForm.test.tsx | 109 +++++++++++++ .../ConnectCredentialsForm.tsx | 146 ++++++++++++++++++ .../components/browser-automations/index.ts | 4 +- .../[orgId]/tasks/[taskId]/hooks/types.ts | 6 + .../tasks/[taskId]/hooks/useBrowserContext.ts | 21 ++- 6 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.tsx diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx index 2506f33978..d10622a76e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx @@ -9,6 +9,8 @@ import { BrowserAutomationConfigDialog, BrowserAutomationsList, BrowserLiveView, + ConnectCredentialsForm, + type ConnectCredentialsFormData, EmptyWithContextState, NoContextState, } from './browser-automations'; @@ -26,6 +28,7 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto automation?: BrowserAutomation; }>({ open: false, mode: 'create' }); const [authUrl, setAuthUrl] = useState('https://github.com'); + const [connectOpen, setConnectOpen] = useState(false); const authHostname = (() => { try { return new URL(authUrl).hostname; @@ -53,6 +56,28 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto onComplete: automations.fetchAutomations, }); + const handleStartConnect = useCallback((url: string) => { + setAuthUrl(url); + setConnectOpen(true); + }, []); + + const handleSubmitCredentials = useCallback( + (data: ConnectCredentialsFormData) => { + setAuthUrl(data.url); + context.startAuth(data.url, { + username: data.username, + password: data.password, + totpSeed: data.totpSeed, + }); + }, + [context], + ); + + // Close the credentials step once the connection is established. + useEffect(() => { + if (context.status === 'has-context') setConnectOpen(false); + }, [context.status]); + // Initialize useEffect(() => { context.checkContextStatus(); @@ -90,7 +115,22 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto variant="auth" isChecking={context.status === 'checking'} onSave={() => context.checkAuth(authUrl)} - onCancel={context.cancelAuth} + onCancel={() => { + context.cancelAuth(); + setConnectOpen(false); + }} + /> + ); + } + + // Connect flow — step 1: credentials (before the live sign-in above) + if (connectOpen) { + return ( + setConnectOpen(false)} /> ); } @@ -103,13 +143,7 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto // No context - show setup prompt (only for non-manual tasks) if (!isManualTask && context.status === 'no-context' && automations.automations.length === 0) { return ( - { - setAuthUrl(url); - context.startAuth(url); - }} - /> + ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.test.tsx new file mode 100644 index 0000000000..38c800ff44 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.test.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import type { InputHTMLAttributes, LabelHTMLAttributes } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@trycompai/design-system', () => ({ + Input: (props: InputHTMLAttributes) => , + Label: ({ children, ...props }: LabelHTMLAttributes) => ( + + ), + // Forward only the props the tests exercise; ignore DS-only props (loading, variant). + Button: ({ + children, + type, + onClick, + disabled, + }: { + children?: ReactNode; + type?: 'button' | 'submit'; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock('@trycompai/design-system/icons', () => ({ + ArrowRight: () => , + Locked: () => , + View: () => , + ViewOff: () => , +})); + +import { ConnectCredentialsForm } from './ConnectCredentialsForm'; + +describe('ConnectCredentialsForm', () => { + it('renders the credential fields and the 1Password reassurance', () => { + render(); + + expect(screen.getByLabelText('Website URL')).toBeInTheDocument(); + expect(screen.getByLabelText('Username or email')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByLabelText('Authenticator app setup key')).toBeInTheDocument(); + expect(screen.getByText(/stored in/i)).toHaveTextContent('1Password'); + }); + + it('submits the entered credentials', async () => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByLabelText('Username or email'), { + target: { value: 'compliance@acme.com' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'sup3r-secret' }, + }); + fireEvent.change(screen.getByLabelText('Authenticator app setup key'), { + target: { value: 'JBSWY3DPEHPK3PXP' }, + }); + fireEvent.click(screen.getByText('Continue to sign-in')); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://github.com', + username: 'compliance@acme.com', + password: 'sup3r-secret', + totpSeed: 'JBSWY3DPEHPK3PXP', + }), + expect.anything(), + ); + }); + + it('does not submit when required fields are missing', async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Continue to sign-in')); + + await waitFor(() => expect(screen.getByText('Username is required')).toBeInTheDocument()); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('toggles password visibility', () => { + render(); + + const password = screen.getByLabelText('Password'); + expect(password).toHaveAttribute('type', 'password'); + fireEvent.click(screen.getByRole('button', { name: 'Show password' })); + expect(password).toHaveAttribute('type', 'text'); + }); + + it('calls onCancel when Cancel is clicked', () => { + const onCancel = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Cancel')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.tsx new file mode 100644 index 0000000000..a0338adcca --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Input, Label } from '@trycompai/design-system'; +import { ArrowRight, Locked, View, ViewOff } from '@trycompai/design-system/icons'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const connectCredentialsSchema = z.object({ + url: z.string().trim().url({ message: 'Enter a valid website URL' }), + username: z.string().trim().min(1, { message: 'Username is required' }), + password: z.string().min(1, { message: 'Password is required' }), + totpSeed: z.string().trim().optional(), +}); + +export type ConnectCredentialsFormData = z.infer; + +interface ConnectCredentialsFormProps { + initialUrl?: string; + isSubmitting: boolean; + onSubmit: (data: ConnectCredentialsFormData) => void; + onCancel: () => void; +} + +export function ConnectCredentialsForm({ + initialUrl, + isSubmitting, + onSubmit, + onCancel, +}: ConnectCredentialsFormProps) { + const [showPassword, setShowPassword] = useState(false); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(connectCredentialsSchema), + defaultValues: { + url: initialUrl ?? '', + username: '', + password: '', + totpSeed: '', + }, + }); + + return ( +
+
+
+

Connect a vendor login

+ 1·2 +
+
+
+
+
+
+ +
+

+ Enter the login once. Comp AI uses it to sign in for scheduled evidence runs — you + won't be asked again. +

+ +
+ + + {errors.url?.message &&

{errors.url.message}

} +
+ +
+ + + {errors.username?.message && ( +

{errors.username.message}

+ )} +
+ +
+ +
+ + +
+ {errors.password?.message && ( +

{errors.password.message}

+ )} +
+ +
+
+ + Optional — recommended +
+ +

+ When you set up two-factor in an authenticator app, the site shows a QR code and a text + key labeled “can't scan? enter this key.” Paste that key and we can + generate the 6-digit codes ourselves, so scheduled runs never wait on your phone. +

+
+ +
+ +

+ Encrypted end-to-end and stored in 1Password. + Used only to sign in for evidence collection — never shared, removable anytime. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/index.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/index.ts index d92f6aee4f..6bd5d700c2 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/index.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/index.ts @@ -1,4 +1,6 @@ +export { BrowserAutomationConfigDialog } from './BrowserAutomationConfigDialog'; export { BrowserAutomationsList } from './BrowserAutomationsList'; export { EmptyWithContextState, NoContextState } from './BrowserEmptyStates'; export { BrowserLiveView } from './BrowserLiveView'; -export { BrowserAutomationConfigDialog } from './BrowserAutomationConfigDialog'; +export { ConnectCredentialsForm } from './ConnectCredentialsForm'; +export type { ConnectCredentialsFormData } from './ConnectCredentialsForm'; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/types.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/types.ts index 0cdccdc611..cf344db1e6 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/types.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/types.ts @@ -54,6 +54,12 @@ export interface BrowserAuthProfile { vaultConnectionId?: string | null; } +export interface BrowserLoginCredentials { + username: string; + password: string; + totpSeed?: string; +} + export interface ResolveAuthProfileResponse { profile: BrowserAuthProfile; isNew: boolean; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useBrowserContext.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useBrowserContext.ts index b3abbffb71..7e1da05742 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useBrowserContext.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useBrowserContext.ts @@ -7,6 +7,7 @@ import type { AuthStatusResponse, BrowserAuthProfile, BrowserContextStatus, + BrowserLoginCredentials, NavigateResponse, ResolveAuthProfileResponse, SessionResponse, @@ -45,7 +46,7 @@ export function useBrowserContext() { } }, []); - const startAuth = useCallback(async (url: string) => { + const startAuth = useCallback(async (url: string, credentials?: BrowserLoginCredentials) => { let startedSessionId: string | null = null; try { setIsStartingAuth(true); @@ -59,6 +60,24 @@ export function useBrowserContext() { } setProfileId(profileRes.data.profile.id); + // Store the login so scheduled and manual runs can sign in on their own. + // Failure here is non-fatal — the user can still connect manually below. + if (credentials?.username && credentials?.password) { + const credRes = await apiClient.post( + `/v1/browserbase/profiles/${profileRes.data.profile.id}/credentials`, + { + username: credentials.username, + password: credentials.password, + totpSeed: credentials.totpSeed?.trim() || undefined, + }, + ); + if (credRes.error) { + toast.warning( + "Saved the site, but couldn't store the login for automatic sign-in. You can still connect manually.", + ); + } + } + const sessionRes = await apiClient.post( `/v1/browserbase/profiles/${profileRes.data.profile.id}/session`, {}, From 2c766e7d21b48abcb5a698d8b91c1a26462104e6 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Jul 2026 12:55:54 -0400 Subject: [PATCH 002/117] feat(app): redesign browser-automation first-time setup screen Replaces the bare "enter a URL" prompt with the three-step explainer (01 Connect a login / 02 Describe what to capture / 03 Evidence on schedule), a single "Connect a vendor login" action, and the 1Password reassurance line. The URL is now collected in the connect credentials form rather than here. Part of the Browser Automations redesign (first-time setup). --- .../components/BrowserAutomations.tsx | 5 +- .../BrowserEmptyStates.tsx | 95 +++++++++---------- 2 files changed, 49 insertions(+), 51 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx index d10622a76e..73ee25ceb4 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx @@ -143,7 +143,10 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto // No context - show setup prompt (only for non-manual tasks) if (!isManualTask && context.status === 'no-context' && automations.automations.length === 0) { return ( - + handleStartConnect('')} + /> ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserEmptyStates.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserEmptyStates.tsx index 5f49793f84..ec4ee24c42 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserEmptyStates.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserEmptyStates.tsx @@ -1,63 +1,58 @@ 'use client'; -import { Badge, Button, Input } from '@trycompai/design-system'; -import { Add, ArrowRight, Globe, Screen } from '@trycompai/design-system/icons'; -import { useState } from 'react'; +import { Badge, Button } from '@trycompai/design-system'; +import { Add, ArrowRight, Globe, Locked } from '@trycompai/design-system/icons'; + +const SETUP_STEPS = [ + { + n: '01', + title: 'Connect a login', + desc: 'Sign in to the vendor once. We keep it connected.', + }, + { + n: '02', + title: 'Describe what to capture', + desc: 'In plain English — like instructions to a colleague.', + }, + { + n: '03', + title: 'Evidence, on schedule', + desc: 'Screenshots land in this task automatically.', + }, +]; interface NoContextStateProps { isStartingAuth: boolean; - onStartAuth: (url: string) => void; + onConnect: () => void; } -export function NoContextState({ isStartingAuth, onStartAuth }: NoContextStateProps) { - const [authUrl, setAuthUrl] = useState('https://github.com'); - +export function NoContextState({ isStartingAuth, onConnect }: NoContextStateProps) { return ( -
-
-
-
- -
-
-

Browser Automations

-

- Capture screenshots from authenticated web pages -

+
+

Browser automations

+

+ Prove controls on vendor sites that have no API — Comp AI signs in, navigates to the right + page, and files a screenshot as evidence. +

+ +
+ {SETUP_STEPS.map((step) => ( +
+
{step.n}
+
{step.title}
+
{step.desc}
-
+ ))}
-
-
-
- -
-

Log in to the website first

-

- Enter the site you want to automate. We will save a browser profile for that hostname - and reuse it for future evidence runs. -

-
-
-
- setAuthUrl(e.target.value)} - /> -
- -
-

- Use a dedicated service account for automations when possible. -

-
+ +
+ +
+ + Encrypted, stored in 1Password, used only to collect evidence
From b882b09d3e9cf089f9bbc56c3971ce32bdd4c287 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Jul 2026 13:55:04 -0400 Subject: [PATCH 003/117] feat(app): use split-card layout for browser-automation setup screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the first-time setup screen to the designer's 2c variant — a split card with the pitch and "Connect a Vendor Login" action on the left and a quiet "How it works" rail (01/02/03) on the right. Stacks to one column on mobile. --- .../BrowserEmptyStates.tsx | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserEmptyStates.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserEmptyStates.tsx index ec4ee24c42..32f34c54af 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserEmptyStates.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserEmptyStates.tsx @@ -4,21 +4,9 @@ import { Badge, Button } from '@trycompai/design-system'; import { Add, ArrowRight, Globe, Locked } from '@trycompai/design-system/icons'; const SETUP_STEPS = [ - { - n: '01', - title: 'Connect a login', - desc: 'Sign in to the vendor once. We keep it connected.', - }, - { - n: '02', - title: 'Describe what to capture', - desc: 'In plain English — like instructions to a colleague.', - }, - { - n: '03', - title: 'Evidence, on schedule', - desc: 'Screenshots land in this task automatically.', - }, + { n: '01', title: 'Connect a login', desc: 'Sign in to the vendor once.' }, + { n: '02', title: 'Describe what to capture', desc: 'In plain English.' }, + { n: '03', title: 'Evidence, on schedule', desc: 'Screenshots land in this task.' }, ]; interface NoContextStateProps { @@ -28,31 +16,44 @@ interface NoContextStateProps { export function NoContextState({ isStartingAuth, onConnect }: NoContextStateProps) { return ( -
-

Browser automations

-

- Prove controls on vendor sites that have no API — Comp AI signs in, navigates to the right - page, and files a screenshot as evidence. -

+
+
+ {/* Left — the pitch + primary action */} +
+

+ Browser Automations +

+

+ When a vendor has no integration, Comp AI signs in to its website on a schedule and + captures a screenshot as audit evidence. +

-
- {SETUP_STEPS.map((step) => ( -
-
{step.n}
-
{step.title}
-
{step.desc}
+
+ +
+ + Encrypted · stored in 1Password · evidence only +
- ))} -
+
-
- -
- - Encrypted, stored in 1Password, used only to collect evidence + {/* Right — quiet "how it works" rail */} +
+
+ How it works +
+ {SETUP_STEPS.map((step) => ( +
+ {step.n} +
+ {step.title} +
+ {step.desc} +
+
+ ))}
From e7d63560919d2c3933ea887e48fa08692012e50d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Jul 2026 15:49:09 -0400 Subject: [PATCH 004/117] feat(api): detect vendor login methods to power smart connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a login-analysis backend: POST /v1/browserbase/analyze-login opens the vendor sign-in page in a throwaway cloud browser, detects the supported login methods (password / SSO / passkey, identifier type, extra fields), and returns a recommendation (ready / works-with-check-ins / manual). Reads a public page only — no credentials — and always degrades to a manual fallback if it can't be read. Foundation for the smart-connect flow. Unit tests for the recommendation logic and the analyzer service. --- .../browser-login-analysis.spec.ts | 81 +++++++++++++ .../src/browserbase/browser-login-analysis.ts | 114 ++++++++++++++++++ .../browser-login-analyzer.service.spec.ts | 68 +++++++++++ .../browser-login-analyzer.service.ts | 66 ++++++++++ .../src/browserbase/browserbase.controller.ts | 19 +++ .../api/src/browserbase/browserbase.module.ts | 2 + .../browserbase/browserbase.service.spec.ts | 3 + .../src/browserbase/browserbase.service.ts | 40 ++++-- .../src/browserbase/dto/browserbase.dto.ts | 47 +++++++- 9 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/browserbase/browser-login-analysis.spec.ts create mode 100644 apps/api/src/browserbase/browser-login-analysis.ts create mode 100644 apps/api/src/browserbase/browser-login-analyzer.service.spec.ts create mode 100644 apps/api/src/browserbase/browser-login-analyzer.service.ts diff --git a/apps/api/src/browserbase/browser-login-analysis.spec.ts b/apps/api/src/browserbase/browser-login-analysis.spec.ts new file mode 100644 index 0000000000..ff31f7c4a1 --- /dev/null +++ b/apps/api/src/browserbase/browser-login-analysis.spec.ts @@ -0,0 +1,81 @@ +import { + analyzeDetectedLogin, + manualLoginAnalysis, + type LoginDetection, +} from './browser-login-analysis'; + +const base: LoginDetection = { + reachable: true, + hasPasswordField: false, + identifierType: 'unknown', + ssoProviders: [], + hasPasskey: false, + extraFields: [], +}; + +describe('analyzeDetectedLogin', () => { + it('recommends "ready" when a password field is present', () => { + const result = analyzeDetectedLogin({ + ...base, + hasPasswordField: true, + identifierType: 'email', + }); + expect(result.recommendation.category).toBe('ready'); + expect(result.detectedMethods).toContain('password'); + }); + + it('recommends check-ins for SSO-only sites', () => { + const result = analyzeDetectedLogin({ ...base, ssoProviders: ['Google'] }); + expect(result.recommendation.category).toBe('works_with_checkins'); + expect(result.detectedMethods).toContain('sso'); + }); + + it('recommends check-ins for passkey-only sites', () => { + const result = analyzeDetectedLogin({ ...base, hasPasskey: true }); + expect(result.recommendation.category).toBe('works_with_checkins'); + expect(result.detectedMethods).toContain('passkey'); + }); + + it('prefers the password path (ready) when both password and SSO exist', () => { + const result = analyzeDetectedLogin({ + ...base, + hasPasswordField: true, + ssoProviders: ['Google'], + }); + expect(result.recommendation.category).toBe('ready'); + expect(result.detectedMethods).toEqual( + expect.arrayContaining(['password', 'sso']), + ); + }); + + it('falls back to manual when nothing is detected', () => { + expect(analyzeDetectedLogin(base).recommendation.category).toBe('manual'); + }); + + it('falls back to manual when the page is unreachable, even with a password field', () => { + const result = analyzeDetectedLogin({ + ...base, + reachable: false, + hasPasswordField: true, + }); + expect(result.recommendation.category).toBe('manual'); + }); + + it('passes extra fields through unchanged', () => { + const result = analyzeDetectedLogin({ + ...base, + hasPasswordField: true, + extraFields: [{ label: 'Workspace URL' }], + }); + expect(result.extraFields).toEqual([{ label: 'Workspace URL' }]); + }); +}); + +describe('manualLoginAnalysis', () => { + it('is an unreachable, manual recommendation', () => { + const result = manualLoginAnalysis(); + expect(result.reachable).toBe(false); + expect(result.recommendation.category).toBe('manual'); + expect(result.detectedMethods).toEqual([]); + }); +}); diff --git a/apps/api/src/browserbase/browser-login-analysis.ts b/apps/api/src/browserbase/browser-login-analysis.ts new file mode 100644 index 0000000000..65daf4bddb --- /dev/null +++ b/apps/api/src/browserbase/browser-login-analysis.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; + +/** + * What we can reliably read from a vendor's *first* sign-in page. Note: the + * specific 2FA method (authenticator vs SMS vs push) usually isn't visible until + * after the identifier/password step, so it is resolved during live sign-in — not + * here. This detection is best-effort and always degrades to a manual fallback. + */ +export const loginDetectionSchema = z.object({ + reachable: z + .boolean() + .describe('Whether this looks like a real sign-in page we could read'), + hasPasswordField: z + .boolean() + .describe('Whether a password input is present on the page'), + identifierType: z + .enum(['email', 'username', 'either', 'unknown']) + .describe('What the first login field accepts'), + ssoProviders: z + .array(z.string()) + .describe( + 'Third-party sign-in buttons offered, e.g. Google, Microsoft, SSO', + ), + hasPasskey: z + .boolean() + .describe('Whether passkey / security-key sign-in is offered'), + extraFields: z + .array(z.object({ label: z.string() })) + .describe( + 'Other required fields before the password, e.g. company, workspace, subdomain', + ), +}); + +export type LoginDetection = z.infer; + +export type LoginMethod = 'password' | 'sso' | 'passkey'; + +export type LoginRecommendationCategory = + 'ready' | 'works_with_checkins' | 'manual'; + +export interface LoginAnalysis { + reachable: boolean; + detectedMethods: LoginMethod[]; + identifierType: LoginDetection['identifierType']; + extraFields: { label: string }[]; + recommendation: { + category: LoginRecommendationCategory; + headline: string; + detail: string; + }; +} + +const READY = { + category: 'ready' as const, + headline: "You're set.", + detail: + 'Sign in once — we capture what the scheduler needs to sign in on its own from then on.', +}; + +const CHECKINS = { + category: 'works_with_checkins' as const, + headline: 'This will work — with an occasional check-in.', + detail: + 'Runs reuse your session; when it expires we email you to sign in again. Runs pause, never fail silently.', +}; + +const MANUAL = { + category: 'manual' as const, + headline: "We couldn't read this site automatically.", + detail: "Enter the sign-in details and we'll take it from there.", +}; + +/** + * Turns raw page detection into a recommendation for the connect flow. Pure and + * deterministic so it can be unit-tested without a browser. + */ +export function analyzeDetectedLogin(detection: LoginDetection): LoginAnalysis { + const detectedMethods: LoginMethod[] = []; + if (detection.hasPasswordField) detectedMethods.push('password'); + if (detection.ssoProviders.length > 0) detectedMethods.push('sso'); + if (detection.hasPasskey) detectedMethods.push('passkey'); + + let recommendation: LoginAnalysis['recommendation'] = MANUAL; + if (detection.reachable && detection.hasPasswordField) { + // A password form means we can capture credentials + an authenticator key + // and run fully unattended (the 2FA specifics surface during sign-in). + recommendation = READY; + } else if ( + detection.reachable && + (detection.ssoProviders.length > 0 || detection.hasPasskey) + ) { + // SSO / passkey can't be replayed unattended — session reuse + human re-auth. + recommendation = CHECKINS; + } + + return { + reachable: detection.reachable, + detectedMethods, + identifierType: detection.identifierType, + extraFields: detection.extraFields, + recommendation, + }; +} + +export function manualLoginAnalysis(): LoginAnalysis { + return analyzeDetectedLogin({ + reachable: false, + hasPasswordField: false, + identifierType: 'unknown', + ssoProviders: [], + hasPasskey: false, + extraFields: [], + }); +} diff --git a/apps/api/src/browserbase/browser-login-analyzer.service.spec.ts b/apps/api/src/browserbase/browser-login-analyzer.service.spec.ts new file mode 100644 index 0000000000..5b406e7379 --- /dev/null +++ b/apps/api/src/browserbase/browser-login-analyzer.service.spec.ts @@ -0,0 +1,68 @@ +import { BrowserLoginAnalyzerService } from './browser-login-analyzer.service'; +import type { BrowserbaseSessionService } from './browserbase-session.service'; +import type { LoginDetection } from './browser-login-analysis'; + +function makeSessions(extract: jest.Mock) { + const page = { goto: jest.fn().mockResolvedValue(undefined) }; + return { + createBrowserbaseContext: jest.fn().mockResolvedValue('ctx_1'), + createSessionWithContext: jest + .fn() + .mockResolvedValue({ sessionId: 'sess_1', liveViewUrl: '' }), + createStagehand: jest.fn().mockResolvedValue({ extract }), + ensureActivePage: jest.fn().mockResolvedValue(page), + safeCloseStagehand: jest.fn().mockResolvedValue(undefined), + closeSession: jest.fn().mockResolvedValue(undefined), + }; +} + +const passwordDetection: LoginDetection = { + reachable: true, + hasPasswordField: true, + identifierType: 'email', + ssoProviders: [], + hasPasskey: false, + extraFields: [], +}; + +describe('BrowserLoginAnalyzerService', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + const run = async ( + sessions: ReturnType, + url: string, + ) => { + const service = new BrowserLoginAnalyzerService( + sessions as unknown as BrowserbaseSessionService, + ); + const promise = service.analyzeLogin(url); + await jest.runAllTimersAsync(); + return promise; + }; + + it('returns a ready recommendation and cleans up the session', async () => { + const extract = jest.fn().mockResolvedValue(passwordDetection); + const sessions = makeSessions(extract); + + const result = await run( + sessions, + 'https://app.datadoghq.com/account/login', + ); + + expect(result.recommendation.category).toBe('ready'); + expect(sessions.safeCloseStagehand).toHaveBeenCalledTimes(1); + expect(sessions.closeSession).toHaveBeenCalledWith('sess_1'); + }); + + it('falls back to manual when the page cannot be read, and still cleans up', async () => { + const extract = jest.fn().mockRejectedValue(new Error('page unreadable')); + const sessions = makeSessions(extract); + + const result = await run(sessions, 'https://weird.example.com'); + + expect(result.reachable).toBe(false); + expect(result.recommendation.category).toBe('manual'); + expect(sessions.closeSession).toHaveBeenCalledWith('sess_1'); + }); +}); diff --git a/apps/api/src/browserbase/browser-login-analyzer.service.ts b/apps/api/src/browserbase/browser-login-analyzer.service.ts new file mode 100644 index 0000000000..921cae6705 --- /dev/null +++ b/apps/api/src/browserbase/browser-login-analyzer.service.ts @@ -0,0 +1,66 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { BrowserbaseSessionService } from './browserbase-session.service'; +import { + analyzeDetectedLogin, + loginDetectionSchema, + manualLoginAnalysis, + type LoginAnalysis, +} from './browser-login-analysis'; + +const EXTRACT_PROMPT = + 'Look at this sign-in page. Report: is it a real login page we can read; is a ' + + 'password field present; does the first login field accept an email, a username, ' + + 'or either; which third-party sign-in buttons are offered (e.g. Google, Microsoft, ' + + 'Okta, SSO); is passkey / security-key sign-in offered; and list any other fields ' + + 'required before the password (e.g. company, workspace, subdomain).'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Opens a vendor's sign-in page in a throwaway cloud browser and detects which + * login methods it supports, so the connect flow can recommend the most reliable + * path. Reads a public page only — no credentials involved. Always degrades to a + * manual fallback if the page can't be read. + */ +@Injectable() +export class BrowserLoginAnalyzerService { + private readonly logger = new Logger(BrowserLoginAnalyzerService.name); + + constructor( + private readonly sessions: BrowserbaseSessionService = new BrowserbaseSessionService(), + ) {} + + async analyzeLogin(url: string): Promise { + const contextId = await this.sessions.createBrowserbaseContext(); + const { sessionId } = + await this.sessions.createSessionWithContext(contextId); + + let stagehand: Awaited< + ReturnType + > | null = null; + try { + stagehand = await this.sessions.createStagehand(sessionId); + const page = await this.sessions.ensureActivePage(stagehand); + await page.goto(url, { waitUntil: 'domcontentloaded', timeoutMs: 30000 }); + await delay(1500); + + const detection = await stagehand.extract( + EXTRACT_PROMPT, + loginDetectionSchema, + ); + return analyzeDetectedLogin(detection); + } catch (err) { + // A page we can't read isn't an error the user needs to see — we just fall + // back to manual entry, so the connect flow always moves forward. + this.logger.warn('Login analysis failed; falling back to manual entry', { + error: err instanceof Error ? err.message : String(err), + }); + return manualLoginAnalysis(); + } finally { + if (stagehand) await this.sessions.safeCloseStagehand(stagehand); + await this.sessions + .closeSession(sessionId) + .catch(() => undefined /* best-effort cleanup */); + } + } +} diff --git a/apps/api/src/browserbase/browserbase.controller.ts b/apps/api/src/browserbase/browserbase.controller.ts index a02d1fb69c..5cd6e8057d 100644 --- a/apps/api/src/browserbase/browserbase.controller.ts +++ b/apps/api/src/browserbase/browserbase.controller.ts @@ -25,10 +25,12 @@ import { PermissionGuard } from '../auth/permission.guard'; import { RequirePermission } from '../auth/require-permission.decorator'; import { BrowserbaseService } from './browserbase.service'; import { + AnalyzeLoginDto, AuthStatusResponseDto, BrowserAutomationResponseDto, BrowserAutomationRunResponseDto, CheckAuthDto, + LoginAnalysisResponseDto, CloseSessionDto, ContextResponseDto, CreateBrowserAutomationDto, @@ -47,6 +49,23 @@ import { export class BrowserbaseController { constructor(private readonly browserbaseService: BrowserbaseService) {} + // ===== Login Analysis ===== + + @Post('analyze-login') + @RequirePermission('integration', 'create') + @ApiOperation({ + summary: 'Analyze a vendor sign-in page', + description: + 'Opens the vendor sign-in page in a throwaway cloud browser and detects which login methods it supports, so the connect flow can recommend the most reliable setup. Reads a public page only — no credentials. Always degrades to a manual fallback.', + }) + @ApiBody({ type: AnalyzeLoginDto }) + @ApiResponse({ status: 201, type: LoginAnalysisResponseDto }) + async analyzeLogin( + @Body() dto: AnalyzeLoginDto, + ): Promise { + return this.browserbaseService.analyzeLogin(dto.url); + } + // ===== Organization Context ===== @Post('org-context') diff --git a/apps/api/src/browserbase/browserbase.module.ts b/apps/api/src/browserbase/browserbase.module.ts index 2346ad2130..76eb7b3369 100644 --- a/apps/api/src/browserbase/browserbase.module.ts +++ b/apps/api/src/browserbase/browserbase.module.ts @@ -12,6 +12,7 @@ import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserbaseService } from './browserbase.service'; import { BrowserCredentialStorageService } from './browser-credential-storage.service'; +import { BrowserLoginAnalyzerService } from './browser-login-analyzer.service'; import { BROWSER_CREDENTIAL_VAULT_ADAPTER } from './credential-vault'; import { resolveBrowserCredentialVaultAdapter } from './browser-credential-vault.factory'; import { AuthModule } from '../auth/auth.module'; @@ -31,6 +32,7 @@ import { AuthModule } from '../auth/auth.module'; BrowserbaseScreenshotService, BrowserEvidenceRunnerService, BrowserCredentialStorageService, + BrowserLoginAnalyzerService, { provide: BROWSER_CREDENTIAL_VAULT_ADAPTER, useFactory: resolveBrowserCredentialVaultAdapter, diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts index 62e7cc16b2..204249ded4 100644 --- a/apps/api/src/browserbase/browserbase.service.spec.ts +++ b/apps/api/src/browserbase/browserbase.service.spec.ts @@ -7,6 +7,7 @@ import { BrowserAutomationRunStoreService } from './browser-automation-run-store import { BrowserAuthProfileContextService } from './browser-auth-profile-context.service'; import { BrowserAuthProfileService } from './browser-auth-profile.service'; import { BrowserCredentialStorageService } from './browser-credential-storage.service'; +import { BrowserLoginAnalyzerService } from './browser-login-analyzer.service'; import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; import { BrowserbaseOrgContextService } from './browserbase-org-context.service'; import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; @@ -61,6 +62,7 @@ describe('BrowserbaseService.getScreenshotRedirectUrl', () => { BrowserbaseScreenshotService, BrowserEvidenceRunnerService, BrowserCredentialStorageService, + BrowserLoginAnalyzerService, { provide: BROWSER_CREDENTIAL_VAULT_ADAPTER, useFactory: resolveBrowserCredentialVaultAdapter, @@ -184,6 +186,7 @@ describe('BrowserbaseService schedule frequency passthrough', () => { BrowserbaseScreenshotService, BrowserEvidenceRunnerService, BrowserCredentialStorageService, + BrowserLoginAnalyzerService, { provide: BROWSER_CREDENTIAL_VAULT_ADAPTER, useFactory: resolveBrowserCredentialVaultAdapter, diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 444b0e0fba..9aa7395158 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -4,6 +4,7 @@ import { BrowserAutomationCrudService } from './browser-automation-crud.service' import { BrowserAutomationExecutionService } from './browser-automation-execution.service'; import { BrowserAuthProfileService } from './browser-auth-profile.service'; import { BrowserCredentialStorageService } from './browser-credential-storage.service'; +import { BrowserLoginAnalyzerService } from './browser-login-analyzer.service'; import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; import { BrowserbaseSessionService } from './browserbase-session.service'; @@ -24,11 +25,21 @@ export class BrowserbaseService { private readonly automationCrud: BrowserAutomationCrudService = new BrowserAutomationCrudService( screenshots, ), - private readonly automationExecution: BrowserAutomationExecutionService = - new BrowserAutomationExecutionService(sessions, profiles, runner), + private readonly automationExecution: BrowserAutomationExecutionService = new BrowserAutomationExecutionService( + sessions, + profiles, + runner, + ), private readonly credentialStorage: BrowserCredentialStorageService = new BrowserCredentialStorageService(), + private readonly loginAnalyzer: BrowserLoginAnalyzerService = new BrowserLoginAnalyzerService( + sessions, + ), ) {} + async analyzeLogin(url: string) { + return this.loginAnalyzer.analyzeLogin(url); + } + async listAuthProfiles(organizationId: string) { return this.profiles.listProfiles(organizationId); } @@ -119,11 +130,17 @@ export class BrowserbaseService { } async getBrowserAutomation(automationId: string, organizationId?: string) { - return this.automationCrud.getBrowserAutomation(automationId, organizationId); + return this.automationCrud.getBrowserAutomation( + automationId, + organizationId, + ); } async getBrowserAutomationsForTask(taskId: string, organizationId?: string) { - return this.automationCrud.getBrowserAutomationsForTask(taskId, organizationId); + return this.automationCrud.getBrowserAutomationsForTask( + taskId, + organizationId, + ); } async updateBrowserAutomation( @@ -147,10 +164,16 @@ export class BrowserbaseService { } async deleteBrowserAutomation(automationId: string, organizationId?: string) { - return this.automationCrud.deleteBrowserAutomation(automationId, organizationId); + return this.automationCrud.deleteBrowserAutomation( + automationId, + organizationId, + ); } - async startAutomationWithLiveView(automationId: string, organizationId: string) { + async startAutomationWithLiveView( + automationId: string, + organizationId: string, + ) { return this.automationExecution.startAutomationWithLiveView( automationId, organizationId, @@ -190,7 +213,10 @@ export class BrowserbaseService { return this.automationCrud.getRunWithPresignedUrl(runId, organizationId); } - async getAutomationsWithPresignedUrls(taskId: string, organizationId?: string) { + async getAutomationsWithPresignedUrls( + taskId: string, + organizationId?: string, + ) { return this.automationCrud.getAutomationsWithPresignedUrls( taskId, organizationId, diff --git a/apps/api/src/browserbase/dto/browserbase.dto.ts b/apps/api/src/browserbase/dto/browserbase.dto.ts index bf927c4b42..c3ecf8a5fa 100644 --- a/apps/api/src/browserbase/dto/browserbase.dto.ts +++ b/apps/api/src/browserbase/dto/browserbase.dto.ts @@ -62,10 +62,51 @@ export class AuthStatusResponseDto { username?: string; } +// ===== Login analysis DTOs ===== + +export class AnalyzeLoginDto { + @ApiProperty({ description: 'Vendor sign-in URL to analyze' }) + @IsUrl({}, { message: 'url must be a valid URL' }) + @IsSafeUrl({ message: 'The provided URL is not allowed.' }) + @IsString() + @IsNotEmpty() + url: string; +} + +export class LoginRecommendationDto { + @ApiProperty({ enum: ['ready', 'works_with_checkins', 'manual'] }) + category: string; + + @ApiProperty() + headline: string; + + @ApiProperty() + detail: string; +} + +export class LoginAnalysisResponseDto { + @ApiProperty() + reachable: boolean; + + @ApiProperty({ type: [String] }) + detectedMethods: string[]; + + @ApiProperty({ enum: ['email', 'username', 'either', 'unknown'] }) + identifierType: string; + + @ApiProperty({ type: [Object] }) + extraFields: { label: string }[]; + + @ApiProperty({ type: () => LoginRecommendationDto }) + recommendation: LoginRecommendationDto; +} + // ===== Auth Profile DTOs ===== export class ResolveAuthProfileDto { - @ApiProperty({ description: 'Website URL to normalize into an auth profile hostname' }) + @ApiProperty({ + description: 'Website URL to normalize into an auth profile hostname', + }) @IsUrl({}, { message: 'url must be a valid URL' }) @IsSafeUrl({ message: 'The provided URL is not allowed.' }) @IsString() @@ -115,7 +156,9 @@ export class VerifyAuthProfileSessionDto { } export class MarkAuthProfileNeedsReauthDto { - @ApiPropertyOptional({ description: 'Reason the profile needs re-authentication' }) + @ApiPropertyOptional({ + description: 'Reason the profile needs re-authentication', + }) @IsString() @IsOptional() reason?: string; From 84da3888fd1a16ed89550f310fe164030e98183f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Jul 2026 15:56:31 -0400 Subject: [PATCH 005/117] feat(app): smart connect flow (analyze -> sign in -> capture) Replaces the connect flow with the "Browser-First Split" (1C) design: a stepper rail plus a browser stage that opens the vendor URL, runs AI login-method detection (/analyze-login), shows a recommendation (ready / works-with-check-ins / manual), opens the live sign-in, then captures the reusable credentials (username, password, authenticator setup key) after verification. Removes the superseded credentials-first form. Adds tests for the capture form. The SMS-only -> "enable an authenticator app" recommendation is deferred to the live sign-in step (2FA method isn't visible before sign-in). --- .../components/BrowserAutomations.tsx | 55 ++-- .../ConnectCaptureForm.test.tsx | 69 +++++ .../ConnectCaptureForm.tsx | 79 ++++++ .../ConnectCredentialsForm.test.tsx | 109 -------- .../ConnectCredentialsForm.tsx | 146 ---------- .../browser-automations/ConnectFlowRail.tsx | 91 +++++++ .../ConnectVendorLoginFlow.tsx | 253 ++++++++++++++++++ .../components/browser-automations/index.ts | 3 +- .../[orgId]/tasks/[taskId]/hooks/types.ts | 14 + .../tasks/[taskId]/hooks/useLoginAnalysis.ts | 33 +++ 10 files changed, 558 insertions(+), 294 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCaptureForm.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCaptureForm.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.test.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectFlowRail.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useLoginAnalysis.ts diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx index 73ee25ceb4..ee476faeea 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx @@ -9,8 +9,7 @@ import { BrowserAutomationConfigDialog, BrowserAutomationsList, BrowserLiveView, - ConnectCredentialsForm, - type ConnectCredentialsFormData, + ConnectVendorLoginFlow, EmptyWithContextState, NoContextState, } from './browser-automations'; @@ -56,27 +55,11 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto onComplete: automations.fetchAutomations, }); - const handleStartConnect = useCallback((url: string) => { - setAuthUrl(url); - setConnectOpen(true); - }, []); - - const handleSubmitCredentials = useCallback( - (data: ConnectCredentialsFormData) => { - setAuthUrl(data.url); - context.startAuth(data.url, { - username: data.username, - password: data.password, - totpSeed: data.totpSeed, - }); - }, - [context], - ); - - // Close the credentials step once the connection is established. - useEffect(() => { - if (context.status === 'has-context') setConnectOpen(false); - }, [context.status]); + const handleConnected = useCallback(() => { + setConnectOpen(false); + context.checkContextStatus(); + automations.fetchAutomations(); + }, [context, automations]); // Initialize useEffect(() => { @@ -105,7 +88,17 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto ); } - // Auth flow live view + // Connect flow — smart, self-contained: analyze → sign in → capture → connected + if (connectOpen) { + return ( + setConnectOpen(false)} + /> + ); + } + + // Auth flow live view (reconnect of an existing profile) if (context.showAuthFlow && context.liveViewUrl) { return ( setConnectOpen(false)} - /> - ); - } - // For manual tasks with no existing automations, don't show empty states if (isManualTask && automations.automations.length === 0) { return null; @@ -145,7 +126,7 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto return ( handleStartConnect('')} + onConnect={() => setConnectOpen(true)} /> ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCaptureForm.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCaptureForm.test.tsx new file mode 100644 index 0000000000..53f34eabbe --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCaptureForm.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { InputHTMLAttributes, LabelHTMLAttributes, ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@trycompai/design-system', () => ({ + Input: (props: InputHTMLAttributes) => , + Label: ({ children, ...props }: LabelHTMLAttributes) => ( + + ), + Button: ({ + children, + type, + onClick, + disabled, + }: { + children?: ReactNode; + type?: 'button' | 'submit'; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock('@trycompai/design-system/icons', () => ({ + Locked: () => , +})); + +import { ConnectCaptureForm } from './ConnectCaptureForm'; + +describe('ConnectCaptureForm', () => { + it('renders the credential fields', () => { + render(); + expect(screen.getByLabelText('Username or email')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByLabelText('Authenticator setup key')).toBeInTheDocument(); + }); + + it('submits the entered details', async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Username or email'), { + target: { value: 'sam@acme.com' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'sup3r-secret' }, + }); + fireEvent.click(screen.getByText('Save & Finish')); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ username: 'sam@acme.com', password: 'sup3r-secret' }), + expect.anything(), + ); + }); + + it('does not submit without the required fields', async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Save & Finish')); + + await waitFor(() => expect(screen.getByText('Username is required')).toBeInTheDocument()); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCaptureForm.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCaptureForm.tsx new file mode 100644 index 0000000000..d23b4f9106 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCaptureForm.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Input, Label } from '@trycompai/design-system'; +import { Locked } from '@trycompai/design-system/icons'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const captureSchema = z.object({ + username: z.string().trim().min(1, { message: 'Username is required' }), + password: z.string().min(1, { message: 'Password is required' }), + totpSeed: z.string().trim().optional(), +}); + +export type ConnectCaptureFormData = z.infer; + +interface ConnectCaptureFormProps { + isSubmitting: boolean; + onSubmit: (data: ConnectCaptureFormData) => void; +} + +export function ConnectCaptureForm({ isSubmitting, onSubmit }: ConnectCaptureFormProps) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(captureSchema), + defaultValues: { username: '', password: '', totpSeed: '' }, + }); + + return ( +
+
+

The details we can't see

+

+ The scheduler needs these to sign in on its own. Stored encrypted — never shared. +

+
+ +
+ + + {errors.username?.message && ( +

{errors.username.message}

+ )} +
+ +
+ + + {errors.password?.message && ( +

{errors.password.message}

+ )} +
+ +
+
+ + Optional — recommended +
+ +

+ Shown when you set up the authenticator app (“can't scan? enter this + key”). Lets scheduled runs generate the codes themselves. +

+
+ +
+ + Encrypted · stored in 1Password +
+ + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.test.tsx deleted file mode 100644 index 38c800ff44..0000000000 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import type { ReactNode } from 'react'; -import type { InputHTMLAttributes, LabelHTMLAttributes } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -vi.mock('@trycompai/design-system', () => ({ - Input: (props: InputHTMLAttributes) => , - Label: ({ children, ...props }: LabelHTMLAttributes) => ( - - ), - // Forward only the props the tests exercise; ignore DS-only props (loading, variant). - Button: ({ - children, - type, - onClick, - disabled, - }: { - children?: ReactNode; - type?: 'button' | 'submit'; - onClick?: () => void; - disabled?: boolean; - }) => ( - - ), -})); - -vi.mock('@trycompai/design-system/icons', () => ({ - ArrowRight: () => , - Locked: () => , - View: () => , - ViewOff: () => , -})); - -import { ConnectCredentialsForm } from './ConnectCredentialsForm'; - -describe('ConnectCredentialsForm', () => { - it('renders the credential fields and the 1Password reassurance', () => { - render(); - - expect(screen.getByLabelText('Website URL')).toBeInTheDocument(); - expect(screen.getByLabelText('Username or email')).toBeInTheDocument(); - expect(screen.getByLabelText('Password')).toBeInTheDocument(); - expect(screen.getByLabelText('Authenticator app setup key')).toBeInTheDocument(); - expect(screen.getByText(/stored in/i)).toHaveTextContent('1Password'); - }); - - it('submits the entered credentials', async () => { - const onSubmit = vi.fn(); - render( - , - ); - - fireEvent.change(screen.getByLabelText('Username or email'), { - target: { value: 'compliance@acme.com' }, - }); - fireEvent.change(screen.getByLabelText('Password'), { - target: { value: 'sup3r-secret' }, - }); - fireEvent.change(screen.getByLabelText('Authenticator app setup key'), { - target: { value: 'JBSWY3DPEHPK3PXP' }, - }); - fireEvent.click(screen.getByText('Continue to sign-in')); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://github.com', - username: 'compliance@acme.com', - password: 'sup3r-secret', - totpSeed: 'JBSWY3DPEHPK3PXP', - }), - expect.anything(), - ); - }); - - it('does not submit when required fields are missing', async () => { - const onSubmit = vi.fn(); - render(); - - fireEvent.click(screen.getByText('Continue to sign-in')); - - await waitFor(() => expect(screen.getByText('Username is required')).toBeInTheDocument()); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('toggles password visibility', () => { - render(); - - const password = screen.getByLabelText('Password'); - expect(password).toHaveAttribute('type', 'password'); - fireEvent.click(screen.getByRole('button', { name: 'Show password' })); - expect(password).toHaveAttribute('type', 'text'); - }); - - it('calls onCancel when Cancel is clicked', () => { - const onCancel = vi.fn(); - render(); - - fireEvent.click(screen.getByText('Cancel')); - expect(onCancel).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.tsx deleted file mode 100644 index a0338adcca..0000000000 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectCredentialsForm.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Input, Label } from '@trycompai/design-system'; -import { ArrowRight, Locked, View, ViewOff } from '@trycompai/design-system/icons'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -const connectCredentialsSchema = z.object({ - url: z.string().trim().url({ message: 'Enter a valid website URL' }), - username: z.string().trim().min(1, { message: 'Username is required' }), - password: z.string().min(1, { message: 'Password is required' }), - totpSeed: z.string().trim().optional(), -}); - -export type ConnectCredentialsFormData = z.infer; - -interface ConnectCredentialsFormProps { - initialUrl?: string; - isSubmitting: boolean; - onSubmit: (data: ConnectCredentialsFormData) => void; - onCancel: () => void; -} - -export function ConnectCredentialsForm({ - initialUrl, - isSubmitting, - onSubmit, - onCancel, -}: ConnectCredentialsFormProps) { - const [showPassword, setShowPassword] = useState(false); - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(connectCredentialsSchema), - defaultValues: { - url: initialUrl ?? '', - username: '', - password: '', - totpSeed: '', - }, - }); - - return ( -
-
-
-

Connect a vendor login

- 1·2 -
-
-
-
-
-
- -
-

- Enter the login once. Comp AI uses it to sign in for scheduled evidence runs — you - won't be asked again. -

- -
- - - {errors.url?.message &&

{errors.url.message}

} -
- -
- - - {errors.username?.message && ( -

{errors.username.message}

- )} -
- -
- -
- - -
- {errors.password?.message && ( -

{errors.password.message}

- )} -
- -
-
- - Optional — recommended -
- -

- When you set up two-factor in an authenticator app, the site shows a QR code and a text - key labeled “can't scan? enter this key.” Paste that key and we can - generate the 6-digit codes ourselves, so scheduled runs never wait on your phone. -

-
- -
- -

- Encrypted end-to-end and stored in 1Password. - Used only to sign in for evidence collection — never shared, removable anytime. -

-
- -
- - -
-
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectFlowRail.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectFlowRail.tsx new file mode 100644 index 0000000000..190ef4ad2b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectFlowRail.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { Checkmark, Locked } from '@trycompai/design-system/icons'; +import type { LoginAnalysis } from '../../hooks/types'; + +const RAIL_STEPS = ['Vendor site', 'Check', 'Sign in', 'Details', 'Done']; + +interface ConnectFlowRailProps { + title: string; + subtitle: string; + /** Index of the current rail step (0–4); steps before it render as done. */ + currentIndex: number; + allDone?: boolean; + detecting?: boolean; + analysis?: LoginAnalysis | null; +} + +export function ConnectFlowRail({ + title, + subtitle, + currentIndex, + allDone = false, + detecting = false, + analysis, +}: ConnectFlowRailProps) { + return ( +
+
+
{title}
+
{subtitle}
+
+ +
+ {RAIL_STEPS.map((label, index) => { + const done = allDone || index < currentIndex; + const current = !allDone && index === currentIndex; + return ( +
+ {done ? ( + + + + ) : ( + + {current && } + + )} + + {label} + +
+ ); + })} +
+ +
+ + {analysis ? 'Detected' : 'What we detect appears here'} + + {detecting && ( + <> +
+
+ + )} + {analysis?.detectedMethods.map((method) => ( +
+ + {method} +
+ ))} + {analysis && ( +
+ + {analysis.recommendation.category === 'ready' + ? 'Fully unattended — no check-ins expected' + : analysis.recommendation.category === 'works_with_checkins' + ? 'Occasional re-sign-in · we’ll email you' + : 'Manual setup'} +
+ )} +
+
+ ); +} 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 new file mode 100644 index 0000000000..474002be0f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/ConnectVendorLoginFlow.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { apiClient } from '@/lib/api-client'; +import { Button, Input } from '@trycompai/design-system'; +import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import type { LoginAnalysis } from '../../hooks/types'; +import { useBrowserContext } from '../../hooks/useBrowserContext'; +import { useLoginAnalysis } from '../../hooks/useLoginAnalysis'; +import { ConnectCaptureForm, type ConnectCaptureFormData } from './ConnectCaptureForm'; +import { ConnectFlowRail } from './ConnectFlowRail'; + +type Step = + 'enter-url' | 'checking' | 'recommendation' | 'signin' | 'capture' | 'connected' | 'error'; + +const RAIL_INDEX: Record = { + 'enter-url': 0, + checking: 1, + recommendation: 2, + signin: 2, + capture: 3, + connected: 4, + error: 0, +}; + +function hostnameOf(url: string): string { + try { + return new URL(url).hostname; + } catch { + return 'this site'; + } +} + +interface ConnectVendorLoginFlowProps { + onConnected: () => void; + onCancel: () => void; +} + +export function ConnectVendorLoginFlow({ onConnected, onCancel }: ConnectVendorLoginFlowProps) { + const [step, setStep] = useState('enter-url'); + const [urlInput, setUrlInput] = useState('https://github.com'); + const [url, setUrl] = useState(''); + const [analysis, setAnalysis] = useState(null); + const [isStoring, setIsStoring] = useState(false); + + const context = useBrowserContext(); + const { analyze, isAnalyzing } = useLoginAnalysis(); + + // Verify succeeded → move on to capturing the reusable credentials. + useEffect(() => { + if (step === 'signin' && context.status === 'has-context') setStep('capture'); + }, [step, context.status]); + + const handleAnalyze = useCallback(async () => { + setUrl(urlInput); + setStep('checking'); + const result = await analyze(urlInput); + if (!result) { + setStep('error'); + return; + } + setAnalysis(result); + setStep('recommendation'); + }, [analyze, urlInput]); + + const handleStartSignin = useCallback(() => { + setStep('signin'); + void context.startAuth(url); + }, [context, url]); + + const handleCapture = useCallback( + async (data: ConnectCaptureFormData) => { + if (!context.profileId) { + toast.error('Lost the connection — please reconnect.'); + setStep('error'); + return; + } + setIsStoring(true); + try { + const res = await apiClient.post( + `/v1/browserbase/profiles/${context.profileId}/credentials`, + { + username: data.username, + password: data.password, + totpSeed: data.totpSeed?.trim() || undefined, + }, + ); + if (res.error) { + toast.error(res.error); + return; + } + setStep('connected'); + } finally { + setIsStoring(false); + } + }, + [context.profileId], + ); + + const handleCancel = useCallback(() => { + void context.cancelAuth(); + onCancel(); + }, [context, onCancel]); + + const host = hostnameOf(url || urlInput); + const railSubtitle = + step === 'connected' + ? 'Connected' + : step === 'checking' + ? 'Checking the sign-in page' + : step === 'capture' + ? 'Signed in · a couple details left' + : step === 'signin' + ? 'Your turn — sign in once' + : 'So Comp AI can capture evidence on a schedule'; + + return ( +
+
+ + +
+ {step === 'enter-url' && ( +
+
Vendor sign-in URL
+ setUrlInput(e.target.value)} + placeholder="https://app.vendor.com/login" + /> +
+ The page where you normally sign in. +
+
+ + +
+
+ )} + + {step === 'checking' && ( +
+ + Reading the sign-in page — under 30 seconds +
+ )} + + {step === 'recommendation' && analysis && ( +
+
{analysis.recommendation.headline}
+
+ {analysis.recommendation.detail} +
+
+ + +
+
+ )} + + {step === 'signin' && ( +
+ {context.liveViewUrl ? ( +
+