From df9ebd418bce59715e8b8cc70d61954922ade259 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Jul 2026 10:35:47 -0400 Subject: [PATCH 001/162] feat(api): add credential-backed auto re-login for browser automations Browser automations can now sign back in on their own when a vendor session expires, instead of always requiring a person to re-authenticate. - Store a profile's login (username, password, optional authenticator setup key) in a per-organization 1Password vault via a new credential-storage service and a POST profiles/:id/credentials endpoint. - Resolve those credentials just-in-time during a run through the previously-stubbed credential vault adapter, now backed by 1Password, and drive sign-in with Stagehand variable substitution so secret values are never sent to the model. - Retry once with a freshly generated one-time code to cover the rotation boundary; fall back to human re-authentication when a login needs a step the agent cannot complete (SMS/email/push/SSO). - Let the scheduler attempt a run for a needs-reauth profile that has stored credentials, and mark a profile verified again after a successful run. - Load the 1Password SDK lazily and mark it external for Trigger.dev builds; the feature stays inert unless OP_SERVICE_ACCOUNT_TOKEN is set. Adds unit tests for the credential resolver and the re-login flow. --- apps/api/.env.example | 4 + apps/api/package.json | 1 + .../browser-auth-profile.service.ts | 18 ++ .../browser-auth-profiles.controller.ts | 25 +++ .../browser-automation-execution.service.ts | 17 +- .../browser-credential-login.spec.ts | 186 ++++++++++++++++++ .../browserbase/browser-credential-login.ts | 153 ++++++++++++++ .../browser-credential-storage.service.ts | 148 ++++++++++++++ .../browser-credential-vault.factory.ts | 18 ++ .../browserbase/browser-evidence-execution.ts | 41 +++- .../browser-evidence-runner.service.ts | 10 +- .../api/src/browserbase/browserbase.module.ts | 8 + .../browserbase/browserbase.service.spec.ts | 13 ++ .../src/browserbase/browserbase.service.ts | 12 ++ .../src/browserbase/dto/browserbase.dto.ts | 20 ++ .../api/src/browserbase/onepassword-client.ts | 57 ++++++ .../onepassword-credential-item.ts | 32 +++ ...epassword-credential-vault.adapter.spec.ts | 104 ++++++++++ .../onepassword-credential-vault.adapter.ts | 78 ++++++++ apps/api/trigger.config.ts | 3 + bun.lock | 5 + 21 files changed, 947 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/browserbase/browser-credential-login.spec.ts create mode 100644 apps/api/src/browserbase/browser-credential-login.ts create mode 100644 apps/api/src/browserbase/browser-credential-storage.service.ts create mode 100644 apps/api/src/browserbase/browser-credential-vault.factory.ts create mode 100644 apps/api/src/browserbase/onepassword-client.ts create mode 100644 apps/api/src/browserbase/onepassword-credential-item.ts create mode 100644 apps/api/src/browserbase/onepassword-credential-vault.adapter.spec.ts create mode 100644 apps/api/src/browserbase/onepassword-credential-vault.adapter.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index f2b813f1c1..19fe3dc066 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -48,6 +48,10 @@ OPENAI_API_KEY= ANTHROPIC_API_KEY= GROQ_API_KEY= +# 1Password service account token for browser-automation credential storage. +# Optional: when unset, browser automations fall back to human re-authentication. +OP_SERVICE_ACCOUNT_TOKEN= + # Inference.net Catalyst tracing (optional — no-op if CATALYST_OTLP_TOKEN is unset) CATALYST_OTLP_TOKEN= CATALYST_OTLP_ENDPOINT=https://telemetry.inference.net diff --git a/apps/api/package.json b/apps/api/package.json index 423679e678..2a23a80361 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,6 +4,7 @@ "version": "0.0.1", "author": "", "dependencies": { + "@1password/sdk": "0.4.0", "@ai-sdk/anthropic": "^3.0.75", "@ai-sdk/groq": "^3.0.38", "@ai-sdk/openai": "^3.0.62", diff --git a/apps/api/src/browserbase/browser-auth-profile.service.ts b/apps/api/src/browserbase/browser-auth-profile.service.ts index 0d18c60932..a21363be13 100644 --- a/apps/api/src/browserbase/browser-auth-profile.service.ts +++ b/apps/api/src/browserbase/browser-auth-profile.service.ts @@ -206,6 +206,24 @@ export class BrowserAuthProfileService { return { profile: updated, auth }; } + async markVerified(input: { organizationId: string; profileId: string }) { + const profile = await this.getProfile(input); + if (!profile) { + throw new NotFoundException('Browser auth profile not found'); + } + // Avoid a needless write when the profile is already verified. + if (profile.status === 'verified') return profile; + + return db.browserAuthProfile.update({ + where: { id: profile.id }, + data: { + status: 'verified', + lastVerifiedAt: new Date(), + blockedReason: null, + }, + }); + } + async markNeedsReauth(input: { organizationId: string; profileId: string; diff --git a/apps/api/src/browserbase/browser-auth-profiles.controller.ts b/apps/api/src/browserbase/browser-auth-profiles.controller.ts index 10d2c2b347..0c4f821a3f 100644 --- a/apps/api/src/browserbase/browser-auth-profiles.controller.ts +++ b/apps/api/src/browserbase/browser-auth-profiles.controller.ts @@ -18,6 +18,7 @@ import { ResolveAuthProfileDto, ResolveAuthProfileResponseDto, SessionResponseDto, + StoreAuthProfileCredentialsDto, VerifyAuthProfileResponseDto, VerifyAuthProfileSessionDto, } from './dto/browserbase.dto'; @@ -112,6 +113,30 @@ export class BrowserAuthProfilesController { })) as VerifyAuthProfileResponseDto; } + @Post('profiles/:profileId/credentials') + @RequirePermission('integration', 'update') + @ApiOperation({ + summary: 'Store browser auth profile credentials', + description: + 'Store the login (username, password, optional authenticator setup key) for a browser auth profile in the organization vault so scheduled and manual runs can sign in automatically when a session expires.', + }) + @ApiParam({ name: 'profileId', description: 'Browser auth profile ID' }) + @ApiBody({ type: StoreAuthProfileCredentialsDto }) + @ApiResponse({ status: 200, type: BrowserAuthProfileResponseDto }) + async storeProfileCredentials( + @OrganizationId() organizationId: string, + @Param('profileId') profileId: string, + @Body() dto: StoreAuthProfileCredentialsDto, + ): Promise { + return (await this.browserbaseService.storeAuthProfileCredentials({ + organizationId, + profileId, + username: dto.username, + password: dto.password, + totpSeed: dto.totpSeed, + })) as BrowserAuthProfileResponseDto; + } + @Post('profiles/:profileId/needs-reauth') @RequirePermission('integration', 'update') @ApiOperation({ diff --git a/apps/api/src/browserbase/browser-automation-execution.service.ts b/apps/api/src/browserbase/browser-automation-execution.service.ts index 098082fd50..fb752d0802 100644 --- a/apps/api/src/browserbase/browser-automation-execution.service.ts +++ b/apps/api/src/browserbase/browser-automation-execution.service.ts @@ -137,7 +137,12 @@ export class BrowserAutomationExecutionService { profileId: profile.id, }); - if (profile.status !== 'verified') { + // A profile with stored credentials can recover an expired session on its + // own, so let a needs_reauth profile attempt the run instead of blocking it. + // Profiles that are blocked or never verified still require a human. + const canAutoRelogin = + profile.status === 'needs_reauth' && Boolean(profile.vaultProvider); + if (profile.status !== 'verified' && !canAutoRelogin) { const result = this.profileBlockedResult(profile.status); await this.runs.finishRun({ runId: run.id, @@ -188,6 +193,16 @@ export class BrowserAutomationExecutionService { profileId: string; result: BrowserEvidenceRunResult; }) { + // A successful run proves the session is valid again — clear any prior + // needs_reauth/blocked state (e.g. after a credential-backed auto sign-in). + if (input.result.success) { + await this.profiles.markVerified({ + organizationId: input.organizationId, + profileId: input.profileId, + }); + return; + } + if (input.result.failureCode === 'needs_reauth') { await this.profiles.markNeedsReauth({ organizationId: input.organizationId, diff --git a/apps/api/src/browserbase/browser-credential-login.spec.ts b/apps/api/src/browserbase/browser-credential-login.spec.ts new file mode 100644 index 0000000000..415721a857 --- /dev/null +++ b/apps/api/src/browserbase/browser-credential-login.spec.ts @@ -0,0 +1,186 @@ +import { + performCredentialLogin, + reloginWithStoredCredentials, +} from './browser-credential-login'; +import type { BrowserCredentialVaultAdapter } from './credential-vault'; +import type { BrowserbaseSessionService } from './browserbase-session.service'; +import type { BrowserEvidenceSessionInput } from './browser-evidence-runner.service'; + +type Stagehand = import('@browserbasehq/stagehand').Stagehand; + +const makeStagehand = () => ({ act: jest.fn().mockResolvedValue(undefined) }); +const makePage = () => ({ goto: jest.fn().mockResolvedValue(undefined) }); +const makeSessions = (page: ReturnType) => ({ + ensureActivePage: jest.fn().mockResolvedValue(page), +}); + +const baseInput: BrowserEvidenceSessionInput = { + organizationId: 'org_1', + automationId: 'bau_1', + runId: 'bar_1', + targetUrl: 'https://vendor.example.com/app', + instruction: 'capture evidence', + profile: { + id: 'bap_1', + hostname: 'vendor.example.com', + contextId: 'ctx_1', + vaultProvider: '1password', + vaultExternalItemRef: 'op://v/i', + vaultConnectionId: 'v', + }, + sessionId: 'sess_1', +}; + +describe('performCredentialLogin', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('passes secrets through act variables and never in the prompt text', async () => { + const stagehand = makeStagehand(); + const password = 'sup3r-s3cret-passphrase'; + + const promise = performCredentialLogin({ + stagehand: stagehand as unknown as Stagehand, + credentials: { username: 'alice', password, totpCode: '424242' }, + log: jest.fn(), + }); + await jest.runAllTimersAsync(); + await promise; + + expect(stagehand.act).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('%username%'), + { variables: { username: 'alice', password } }, + ); + expect(stagehand.act).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('%code%'), + { variables: { code: '424242' } }, + ); + + // The actual secret values must not appear in the instruction sent to the LLM. + const credentialInstruction = stagehand.act.mock.calls[0][0] as string; + expect(credentialInstruction).not.toContain(password); + expect(credentialInstruction).not.toContain('alice'); + }); +}); + +describe('reloginWithStoredCredentials', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + const runRelogin = async (args: { + stagehand: ReturnType; + sessions: ReturnType; + vault: BrowserCredentialVaultAdapter; + verifyLoggedIn: jest.Mock; + }) => { + const promise = reloginWithStoredCredentials({ + stagehand: args.stagehand as unknown as Stagehand, + sessions: args.sessions as unknown as BrowserbaseSessionService, + vault: args.vault, + input: baseInput, + verifyLoggedIn: args.verifyLoggedIn, + log: jest.fn(), + }); + await jest.runAllTimersAsync(); + return promise; + }; + + it('does not attempt a login when no credentials are stored', async () => { + const stagehand = makeStagehand(); + const page = makePage(); + const vault: BrowserCredentialVaultAdapter = { + resolveCredentialReference: jest.fn().mockResolvedValue(null), + }; + + const result = await runRelogin({ + stagehand, + sessions: makeSessions(page), + vault, + verifyLoggedIn: jest.fn().mockResolvedValue(false), + }); + + expect(result.isLoggedIn).toBe(false); + expect(result.reason).toMatch(/no stored credentials/i); + expect(stagehand.act).not.toHaveBeenCalled(); + }); + + it('signs in and returns to the target URL when verification passes', async () => { + const stagehand = makeStagehand(); + const page = makePage(); + const vault: BrowserCredentialVaultAdapter = { + resolveCredentialReference: jest + .fn() + .mockResolvedValue({ username: 'alice', password: 'pw' }), + }; + + const result = await runRelogin({ + stagehand, + sessions: makeSessions(page), + vault, + verifyLoggedIn: jest.fn().mockResolvedValue(true), + }); + + expect(result.isLoggedIn).toBe(true); + expect(stagehand.act).toHaveBeenCalledTimes(1); + expect(page.goto).toHaveBeenCalledWith( + baseInput.targetUrl, + expect.objectContaining({ waitUntil: 'domcontentloaded' }), + ); + }); + + it('retries once with a freshly resolved TOTP code', async () => { + const stagehand = makeStagehand(); + const page = makePage(); + const resolveCredentialReference = jest + .fn() + .mockResolvedValueOnce({ + username: 'alice', + password: 'pw', + totpCode: '111111', + }) + .mockResolvedValueOnce({ + username: 'alice', + password: 'pw', + totpCode: '222222', + }); + const vault: BrowserCredentialVaultAdapter = { resolveCredentialReference }; + const verifyLoggedIn = jest + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const result = await runRelogin({ + stagehand, + sessions: makeSessions(page), + vault, + verifyLoggedIn, + }); + + expect(result.isLoggedIn).toBe(true); + expect(resolveCredentialReference).toHaveBeenCalledTimes(2); + // Two login attempts: each enters credentials + a TOTP code (2 acts each). + expect(stagehand.act).toHaveBeenCalledTimes(4); + }); + + it('gives up (user action required) when sign-in never authenticates', async () => { + const stagehand = makeStagehand(); + const page = makePage(); + const vault: BrowserCredentialVaultAdapter = { + resolveCredentialReference: jest + .fn() + .mockResolvedValue({ username: 'alice', password: 'pw' }), + }; + + const result = await runRelogin({ + stagehand, + sessions: makeSessions(page), + vault, + verifyLoggedIn: jest.fn().mockResolvedValue(false), + }); + + expect(result.isLoggedIn).toBe(false); + expect(result.reason).toMatch(/user action/i); + }); +}); diff --git a/apps/api/src/browserbase/browser-credential-login.ts b/apps/api/src/browserbase/browser-credential-login.ts new file mode 100644 index 0000000000..d7f6859bda --- /dev/null +++ b/apps/api/src/browserbase/browser-credential-login.ts @@ -0,0 +1,153 @@ +import type { + BrowserCredentialVaultAdapter, + RuntimeCredentialMaterial, +} from './credential-vault'; +import type { BrowserbaseSessionService } from './browserbase-session.service'; +import type { BrowserEvidenceSessionInput } from './browser-evidence-runner.service'; + +type Stagehand = import('@browserbasehq/stagehand').Stagehand; +type ActivePage = Awaited< + ReturnType +>; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Drives an automated sign-in using stored credentials. Secret values are passed + * through Stagehand's `variables` substitution, so they are injected into the page + * at execution time and are never sent to the model as prompt text. + */ +export async function performCredentialLogin({ + stagehand, + credentials, + log, +}: { + stagehand: Stagehand; + credentials: RuntimeCredentialMaterial; + log: (message: string) => void; +}): Promise { + const variables: Record = {}; + if (credentials.username) variables.username = credentials.username; + if (credentials.password) variables.password = credentials.password; + + if (credentials.username || credentials.password) { + log('Entering stored credentials.'); + await stagehand.act( + 'Enter the username %username% and the password %password% into the sign-in form and submit it. If only one field is visible at a time, fill it and continue to the next step.', + { variables }, + ); + await delay(2000); + } + + if (credentials.totpCode) { + log('Entering one-time passcode.'); + await stagehand.act( + 'If a one-time passcode, two-factor authentication, or verification code field is shown, enter %code% and submit. If no such field is present, do nothing.', + { variables: { code: credentials.totpCode } }, + ); + await delay(2000); + } +} + +export interface CredentialReloginResult { + isLoggedIn: boolean; + page: ActivePage; + reason?: string; +} + +/** + * Attempts to recover an expired session using credentials stored for the profile. + * Returns `isLoggedIn: false` (with a reason) when no credentials exist or the + * automated sign-in couldn't complete — e.g. a login step needs a human (SMS, + * email code, push approval), which is left to the existing re-auth fallback. + */ +export async function reloginWithStoredCredentials({ + stagehand, + sessions, + vault, + input, + verifyLoggedIn, + log, +}: { + stagehand: Stagehand; + sessions: BrowserbaseSessionService; + vault: BrowserCredentialVaultAdapter; + input: BrowserEvidenceSessionInput; + verifyLoggedIn: () => Promise; + log: (message: string) => void; +}): Promise { + const resolveCredentials = () => + vault.resolveCredentialReference({ + profileId: input.profile.id, + provider: input.profile.vaultProvider, + externalItemRef: input.profile.vaultExternalItemRef, + connectionId: input.profile.vaultConnectionId, + }); + + const credentials = await resolveCredentials(); + let page = await sessions.ensureActivePage(stagehand); + + if (!credentials) { + return { + isLoggedIn: false, + page, + reason: 'Session expired and no stored credentials are available.', + }; + } + + page = await runLoginAttempt({ + stagehand, + sessions, + credentials, + input, + log, + }); + if (await verifyLoggedIn()) return { isLoggedIn: true, page }; + + // Retry once with a freshly resolved TOTP code, which guards against the ~30s + // rotation boundary landing between resolve and submit. + if (credentials.totpCode) { + const fresh = await resolveCredentials(); + if (fresh?.totpCode) { + page = await runLoginAttempt({ + stagehand, + sessions, + credentials: fresh, + input, + log, + }); + if (await verifyLoggedIn()) return { isLoggedIn: true, page }; + } + } + + return { + isLoggedIn: false, + page, + reason: 'Automated sign-in did not complete; user action may be required.', + }; +} + +async function runLoginAttempt({ + stagehand, + sessions, + credentials, + input, + log, +}: { + stagehand: Stagehand; + sessions: BrowserbaseSessionService; + credentials: RuntimeCredentialMaterial; + input: BrowserEvidenceSessionInput; + log: (message: string) => void; +}): Promise { + await performCredentialLogin({ stagehand, credentials, log }); + const page = await sessions.ensureActivePage(stagehand); + // Return to the target URL so the evidence step always starts from the same + // place, regardless of where the login flow redirected. + await page.goto(input.targetUrl, { + waitUntil: 'domcontentloaded', + timeoutMs: 30000, + }); + await delay(1500); + return page; +} diff --git a/apps/api/src/browserbase/browser-credential-storage.service.ts b/apps/api/src/browserbase/browser-credential-storage.service.ts new file mode 100644 index 0000000000..39fd19ad66 --- /dev/null +++ b/apps/api/src/browserbase/browser-credential-storage.service.ts @@ -0,0 +1,148 @@ +import { + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { ItemField } from '@1password/sdk'; +import { + getOnePasswordClient, + isOnePasswordConfigured, + loadOnePasswordModule, + type OnePasswordClient, +} from './onepassword-client'; +import { + ONEPASSWORD_PROVIDER, + PASSWORD_FIELD_TITLE, + TOTP_FIELD_TITLE, + USERNAME_FIELD_TITLE, + buildItemReference, + buildOrgVaultTitle, +} from './onepassword-credential-item'; + +export interface StoreProfileCredentialsInput { + organizationId: string; + profileId: string; + username: string; + password: string; + totpSeed?: string; +} + +/** + * Writes a browser auth profile's login (username, password, optional TOTP seed) + * into a per-organization 1Password vault and points the profile at the created + * item. Comp never persists the raw secrets itself — only the `op://` reference. + */ +@Injectable() +export class BrowserCredentialStorageService { + private readonly logger = new Logger(BrowserCredentialStorageService.name); + + async storeProfileCredentials(input: StoreProfileCredentialsInput) { + if (!isOnePasswordConfigured()) { + throw new ServiceUnavailableException( + 'Credential storage is not configured for this environment.', + ); + } + + const profile = await db.browserAuthProfile.findFirst({ + where: { id: input.profileId, organizationId: input.organizationId }, + }); + if (!profile) { + throw new NotFoundException('Browser auth profile not found'); + } + + const client = await getOnePasswordClient(); + const vaultId = await this.ensureOrgVault({ + client, + organizationId: input.organizationId, + }); + + const fields = await this.buildLoginFields({ + username: input.username, + password: input.password, + totpSeed: input.totpSeed, + }); + + const { ItemCategory } = await loadOnePasswordModule(); + const item = await client.items.create({ + category: ItemCategory.Login, + vaultId, + title: `${profile.displayName} (${profile.hostname})`, + fields, + }); + + const itemRef = buildItemReference(vaultId, item.id); + this.logger.log( + `Stored browser credentials for profile ${profile.id} in 1Password.`, + ); + + return db.browserAuthProfile.update({ + where: { id: profile.id }, + data: { + vaultProvider: ONEPASSWORD_PROVIDER, + vaultExternalItemRef: itemRef, + vaultConnectionId: vaultId, + }, + }); + } + + private async ensureOrgVault({ + client, + organizationId, + }: { + client: OnePasswordClient; + organizationId: string; + }): Promise { + const title = buildOrgVaultTitle(organizationId); + const existing = await client.vaults.list(); + const match = existing.find((vault) => vault.title === title); + if (match) return match.id; + + const created = await client.vaults.create({ + title, + description: `Browser automation logins for organization ${organizationId}.`, + }); + this.logger.log( + `Created 1Password vault for organization ${organizationId}.`, + ); + return created.id; + } + + private async buildLoginFields({ + username, + password, + totpSeed, + }: { + username: string; + password: string; + totpSeed?: string; + }): Promise { + const { ItemFieldType } = await loadOnePasswordModule(); + const fields: ItemField[] = [ + { + id: USERNAME_FIELD_TITLE, + title: USERNAME_FIELD_TITLE, + fieldType: ItemFieldType.Text, + value: username, + }, + { + id: PASSWORD_FIELD_TITLE, + title: PASSWORD_FIELD_TITLE, + fieldType: ItemFieldType.Concealed, + value: password, + }, + ]; + + if (totpSeed?.trim()) { + fields.push({ + id: TOTP_FIELD_TITLE, + title: TOTP_FIELD_TITLE, + fieldType: ItemFieldType.Totp, + value: totpSeed.trim(), + }); + } + + return fields; + } +} diff --git a/apps/api/src/browserbase/browser-credential-vault.factory.ts b/apps/api/src/browserbase/browser-credential-vault.factory.ts new file mode 100644 index 0000000000..01a4795a69 --- /dev/null +++ b/apps/api/src/browserbase/browser-credential-vault.factory.ts @@ -0,0 +1,18 @@ +import { + type BrowserCredentialVaultAdapter, + NoopBrowserCredentialVaultAdapter, +} from './credential-vault'; +import { isOnePasswordConfigured } from './onepassword-client'; +import { OnePasswordCredentialVaultAdapter } from './onepassword-credential-vault.adapter'; + +/** + * Picks the credential vault adapter for the current runtime: the 1Password + * adapter when a service account token is configured, otherwise the Noop adapter + * (which resolves nothing, preserving today's human re-auth behavior). + */ +export function resolveBrowserCredentialVaultAdapter(): BrowserCredentialVaultAdapter { + if (isOnePasswordConfigured()) { + return new OnePasswordCredentialVaultAdapter(); + } + return new NoopBrowserCredentialVaultAdapter(); +} diff --git a/apps/api/src/browserbase/browser-evidence-execution.ts b/apps/api/src/browserbase/browser-evidence-execution.ts index 85cc9c6560..d7fe85ad59 100644 --- a/apps/api/src/browserbase/browser-evidence-execution.ts +++ b/apps/api/src/browserbase/browser-evidence-execution.ts @@ -14,6 +14,8 @@ import { classifyBrowserAutomationError, } from './browser-automation-errors'; import type { BrowserEvidenceSessionInput } from './browser-evidence-runner.service'; +import type { BrowserCredentialVaultAdapter } from './credential-vault'; +import { reloginWithStoredCredentials } from './browser-credential-login'; type Stagehand = import('@browserbasehq/stagehand').Stagehand; @@ -44,10 +46,12 @@ export async function executeBrowserEvidence({ input, sessions, logger, + vault, }: { input: BrowserEvidenceSessionInput; sessions: BrowserbaseSessionService; logger: Logger; + vault: BrowserCredentialVaultAdapter; }): Promise { const logs: BrowserEvidenceLog[] = []; const log = (stage: string, message: string) => { @@ -59,6 +63,9 @@ export async function executeBrowserEvidence({ try { log('session', 'Initializing Stagehand session.'); stagehand = await sessions.createStagehand(input.sessionId); + // Stable non-null handle for use inside async closures below, where the + // `let stagehand` binding would otherwise widen back to `Stagehand | null`. + const activeStagehand = stagehand; const initialPage = await sessions.ensureActivePage(stagehand); let page = initialPage; @@ -74,12 +81,38 @@ export async function executeBrowserEvidence({ const authCheck = await checkAuth(stagehand); if (!authCheck.isLoggedIn) { - const classified = classifyBrowserAutomationError( - new Error('Session expired. User is not logged in.'), + log( 'auth', + 'Session expired; attempting sign-in with stored credentials.', ); - log('auth', classified.userFacing); - return toExecutionFailure({ classified, logs }); + const relogin = await reloginWithStoredCredentials({ + stagehand: activeStagehand, + sessions, + vault, + input, + verifyLoggedIn: async () => + (await checkAuth(activeStagehand)).isLoggedIn, + log: (message) => log('auth', message), + }); + page = relogin.page ?? page; + + if (!relogin.isLoggedIn) { + // Classify our own known outcome directly rather than relying on string + // matching: auto sign-in couldn't establish a session, so the profile + // needs a human to reconnect (e.g. SMS/email/push/SSO login). + const classified: ClassifiedBrowserAutomationError = { + code: 'needs_reauth', + stage: 'auth', + userFacing: + relogin.reason ?? + 'Authentication is no longer valid. Reconnect this browser profile.', + needsReauth: true, + blockedReason: 'Automated sign-in could not establish a session.', + }; + log('auth', classified.userFacing); + return toExecutionFailure({ classified, logs }); + } + log('auth', 'Re-authenticated with stored credentials.'); } currentStage = 'action'; diff --git a/apps/api/src/browserbase/browser-evidence-runner.service.ts b/apps/api/src/browserbase/browser-evidence-runner.service.ts index 0e07b19582..84aa1b8487 100644 --- a/apps/api/src/browserbase/browser-evidence-runner.service.ts +++ b/apps/api/src/browserbase/browser-evidence-runner.service.ts @@ -1,7 +1,12 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@db'; import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; +import { + BROWSER_CREDENTIAL_VAULT_ADAPTER, + type BrowserCredentialVaultAdapter, +} from './credential-vault'; +import { resolveBrowserCredentialVaultAdapter } from './browser-credential-vault.factory'; import { type BrowserAutomationFailureCode, type BrowserAutomationFailureStage, @@ -68,6 +73,8 @@ export class BrowserEvidenceRunnerService { constructor( private readonly sessions: BrowserbaseSessionService = new BrowserbaseSessionService(), private readonly screenshots: BrowserbaseScreenshotService = new BrowserbaseScreenshotService(), + @Inject(BROWSER_CREDENTIAL_VAULT_ADAPTER) + private readonly vault: BrowserCredentialVaultAdapter = resolveBrowserCredentialVaultAdapter(), ) {} async runEvidence( @@ -112,6 +119,7 @@ export class BrowserEvidenceRunnerService { input, sessions: this.sessions, logger: this.logger, + vault: this.vault, }); let uploaded: { screenshotKey: string; screenshotUrl: string } | null = null; diff --git a/apps/api/src/browserbase/browserbase.module.ts b/apps/api/src/browserbase/browserbase.module.ts index f0497ab123..2346ad2130 100644 --- a/apps/api/src/browserbase/browserbase.module.ts +++ b/apps/api/src/browserbase/browserbase.module.ts @@ -11,6 +11,9 @@ import { BrowserbaseOrgContextService } from './browserbase-org-context.service' 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 { BROWSER_CREDENTIAL_VAULT_ADAPTER } from './credential-vault'; +import { resolveBrowserCredentialVaultAdapter } from './browser-credential-vault.factory'; import { AuthModule } from '../auth/auth.module'; @Module({ @@ -27,6 +30,11 @@ import { AuthModule } from '../auth/auth.module'; BrowserbaseOrgContextService, BrowserbaseScreenshotService, BrowserEvidenceRunnerService, + BrowserCredentialStorageService, + { + provide: BROWSER_CREDENTIAL_VAULT_ADAPTER, + useFactory: resolveBrowserCredentialVaultAdapter, + }, ], exports: [BrowserbaseService], }) diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts index 29d21ee2f0..62e7cc16b2 100644 --- a/apps/api/src/browserbase/browserbase.service.spec.ts +++ b/apps/api/src/browserbase/browserbase.service.spec.ts @@ -6,11 +6,14 @@ import { BrowserAutomationExecutionService } from './browser-automation-executio import { BrowserAutomationRunStoreService } from './browser-automation-run-store.service'; import { BrowserAuthProfileContextService } from './browser-auth-profile-context.service'; import { BrowserAuthProfileService } from './browser-auth-profile.service'; +import { BrowserCredentialStorageService } from './browser-credential-storage.service'; import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; import { BrowserbaseOrgContextService } from './browserbase-org-context.service'; import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserbaseService } from './browserbase.service'; +import { BROWSER_CREDENTIAL_VAULT_ADAPTER } from './credential-vault'; +import { resolveBrowserCredentialVaultAdapter } from './browser-credential-vault.factory'; jest.mock('@db', () => ({ db: { @@ -57,6 +60,11 @@ describe('BrowserbaseService.getScreenshotRedirectUrl', () => { BrowserbaseOrgContextService, BrowserbaseScreenshotService, BrowserEvidenceRunnerService, + BrowserCredentialStorageService, + { + provide: BROWSER_CREDENTIAL_VAULT_ADAPTER, + useFactory: resolveBrowserCredentialVaultAdapter, + }, ], }).compile(); service = moduleRef.get(BrowserbaseService); @@ -175,6 +183,11 @@ describe('BrowserbaseService schedule frequency passthrough', () => { BrowserbaseOrgContextService, BrowserbaseScreenshotService, BrowserEvidenceRunnerService, + BrowserCredentialStorageService, + { + provide: BROWSER_CREDENTIAL_VAULT_ADAPTER, + useFactory: resolveBrowserCredentialVaultAdapter, + }, ], }).compile(); service = moduleRef.get(BrowserbaseService); diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 696133553f..444b0e0fba 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -3,6 +3,7 @@ import { TaskFrequency } from '@db'; 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 { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; import { BrowserbaseSessionService } from './browserbase-session.service'; @@ -25,6 +26,7 @@ export class BrowserbaseService { ), private readonly automationExecution: BrowserAutomationExecutionService = new BrowserAutomationExecutionService(sessions, profiles, runner), + private readonly credentialStorage: BrowserCredentialStorageService = new BrowserCredentialStorageService(), ) {} async listAuthProfiles(organizationId: string) { @@ -67,6 +69,16 @@ export class BrowserbaseService { return this.profiles.markNeedsReauth(input); } + async storeAuthProfileCredentials(input: { + organizationId: string; + profileId: string; + username: string; + password: string; + totpSeed?: string; + }) { + return this.credentialStorage.storeProfileCredentials(input); + } + async getOrCreateOrgContext(organizationId: string) { return this.profiles.getOrCreateOrgContext(organizationId); } diff --git a/apps/api/src/browserbase/dto/browserbase.dto.ts b/apps/api/src/browserbase/dto/browserbase.dto.ts index 1fb7b29b18..bf927c4b42 100644 --- a/apps/api/src/browserbase/dto/browserbase.dto.ts +++ b/apps/api/src/browserbase/dto/browserbase.dto.ts @@ -121,6 +121,26 @@ export class MarkAuthProfileNeedsReauthDto { reason?: string; } +export class StoreAuthProfileCredentialsDto { + @ApiProperty({ description: 'Username or email for the vendor login' }) + @IsString() + @IsNotEmpty() + username: string; + + @ApiProperty({ description: 'Password for the vendor login' }) + @IsString() + @IsNotEmpty() + password: string; + + @ApiPropertyOptional({ + description: + 'Authenticator app setup key (TOTP secret or otpauth:// URI). When provided, one-time codes are generated for automated sign-in.', + }) + @IsString() + @IsOptional() + totpSeed?: string; +} + export class BrowserAuthProfileResponseDto { @ApiProperty() id: string; diff --git a/apps/api/src/browserbase/onepassword-client.ts b/apps/api/src/browserbase/onepassword-client.ts new file mode 100644 index 0000000000..5edd97e900 --- /dev/null +++ b/apps/api/src/browserbase/onepassword-client.ts @@ -0,0 +1,57 @@ +import { Logger } from '@nestjs/common'; + +type OnePasswordModule = typeof import('@1password/sdk'); +export type OnePasswordClient = Awaited< + ReturnType +>; + +const OP_INTEGRATION_NAME = 'Comp AI Browser Automations'; +const OP_INTEGRATION_VERSION = '1.0.0'; + +const logger = new Logger('OnePasswordClient'); +let clientPromise: Promise | null = null; + +export function getOnePasswordServiceAccountToken(): string | undefined { + const token = process.env.OP_SERVICE_ACCOUNT_TOKEN?.trim(); + return token ? token : undefined; +} + +export function isOnePasswordConfigured(): boolean { + return Boolean(getOnePasswordServiceAccountToken()); +} + +// Dynamic import keeps the WASM-backed SDK out of the module graph until it is +// actually needed, so runtimes that never touch 1Password don't pay to load it +// and bundlers don't have to resolve the WASM core eagerly. Exported so the +// write path can read the SDK's runtime enums (ItemCategory/ItemFieldType) +// without a static import that would defeat the lazy load. +export async function loadOnePasswordModule(): Promise { + return import('@1password/sdk'); +} + +export async function getOnePasswordClient(): Promise { + const token = getOnePasswordServiceAccountToken(); + if (!token) { + throw new Error( + 'OP_SERVICE_ACCOUNT_TOKEN is not configured; cannot reach 1Password.', + ); + } + + if (!clientPromise) { + clientPromise = (async () => { + const { createClient } = await loadOnePasswordModule(); + logger.log('Initializing 1Password service account client.'); + return createClient({ + auth: token, + integrationName: OP_INTEGRATION_NAME, + integrationVersion: OP_INTEGRATION_VERSION, + }); + })().catch((error: unknown) => { + // Reset so a transient failure doesn't permanently poison the singleton. + clientPromise = null; + throw error; + }); + } + + return clientPromise; +} diff --git a/apps/api/src/browserbase/onepassword-credential-item.ts b/apps/api/src/browserbase/onepassword-credential-item.ts new file mode 100644 index 0000000000..62a1c283ab --- /dev/null +++ b/apps/api/src/browserbase/onepassword-credential-item.ts @@ -0,0 +1,32 @@ +// Shared contract between the write path (storing a login into 1Password) and the +// read path (resolving it at run time). Both sides MUST use the same field titles +// and reference format, so they live here as the single source of truth. + +export const ONEPASSWORD_PROVIDER = '1password'; + +// Field titles match 1Password's built-in Login item fields so secret references +// (`op:////`) resolve them by label. +export const USERNAME_FIELD_TITLE = 'username'; +export const PASSWORD_FIELD_TITLE = 'password'; +export const TOTP_FIELD_TITLE = 'one-time password'; + +export function buildOrgVaultTitle(organizationId: string): string { + return `Comp AI Browser Automations — ${organizationId}`; +} + +export function buildItemReference(vaultId: string, itemId: string): string { + return `op://${vaultId}/${itemId}`; +} + +export function buildFieldReference( + itemRef: string, + fieldTitle: string, +): string { + return `${itemRef}/${fieldTitle}`; +} + +// The `?attribute=otp` modifier makes 1Password return the *computed* 6-digit +// code for a Totp field rather than the stored seed. +export function buildTotpReference(itemRef: string): string { + return `${itemRef}/${TOTP_FIELD_TITLE}?attribute=otp`; +} diff --git a/apps/api/src/browserbase/onepassword-credential-vault.adapter.spec.ts b/apps/api/src/browserbase/onepassword-credential-vault.adapter.spec.ts new file mode 100644 index 0000000000..3cac1a1171 --- /dev/null +++ b/apps/api/src/browserbase/onepassword-credential-vault.adapter.spec.ts @@ -0,0 +1,104 @@ +import { OnePasswordCredentialVaultAdapter } from './onepassword-credential-vault.adapter'; +import { + getOnePasswordClient, + type OnePasswordClient, +} from './onepassword-client'; + +jest.mock('./onepassword-client', () => ({ + getOnePasswordClient: jest.fn(), +})); + +const mockedGetClient = jest.mocked(getOnePasswordClient); + +function clientWith(resolve: jest.Mock): OnePasswordClient { + return { secrets: { resolve } } as unknown as OnePasswordClient; +} + +describe('OnePasswordCredentialVaultAdapter', () => { + const adapter = new OnePasswordCredentialVaultAdapter(); + + beforeEach(() => jest.clearAllMocks()); + + it('returns null (and never touches 1Password) when provider is not 1password', async () => { + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: 'lastpass', + externalItemRef: 'op://v/i', + }); + + expect(result).toBeNull(); + expect(mockedGetClient).not.toHaveBeenCalled(); + }); + + it('returns null when the item reference is missing', async () => { + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: '1password', + externalItemRef: ' ', + }); + + expect(result).toBeNull(); + expect(mockedGetClient).not.toHaveBeenCalled(); + }); + + it('resolves username, password, and the live TOTP code', async () => { + const resolve = jest.fn((reference: string) => { + if (reference.endsWith('/username')) return 'alice@example.com'; + if (reference.endsWith('/password')) return 's3cret'; + if (reference.includes('one-time password')) return '123456'; + throw new Error(`unexpected reference: ${reference}`); + }); + mockedGetClient.mockResolvedValue(clientWith(resolve)); + + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: '1password', + externalItemRef: 'op://vault123/item456', + }); + + expect(result).toEqual({ + username: 'alice@example.com', + password: 's3cret', + totpCode: '123456', + }); + expect(resolve).toHaveBeenCalledWith( + 'op://vault123/item456/one-time password?attribute=otp', + ); + }); + + it('treats a missing TOTP field as absent rather than failing', async () => { + const resolve = jest.fn((reference: string) => { + if (reference.endsWith('/username')) return 'alice'; + if (reference.endsWith('/password')) return 'pw'; + throw new Error('field not found'); + }); + mockedGetClient.mockResolvedValue(clientWith(resolve)); + + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: '1password', + externalItemRef: 'op://v/i', + }); + + expect(result).toEqual({ + username: 'alice', + password: 'pw', + totpCode: undefined, + }); + }); + + it('returns null when no fields resolve', async () => { + const resolve = jest.fn(() => { + throw new Error('nothing here'); + }); + mockedGetClient.mockResolvedValue(clientWith(resolve)); + + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: '1password', + externalItemRef: 'op://v/i', + }); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/browserbase/onepassword-credential-vault.adapter.ts b/apps/api/src/browserbase/onepassword-credential-vault.adapter.ts new file mode 100644 index 0000000000..959ff1cab5 --- /dev/null +++ b/apps/api/src/browserbase/onepassword-credential-vault.adapter.ts @@ -0,0 +1,78 @@ +import { Logger } from '@nestjs/common'; +import { + type BrowserCredentialVaultAdapter, + type RuntimeCredentialMaterial, +} from './credential-vault'; +import { + getOnePasswordClient, + type OnePasswordClient, +} from './onepassword-client'; +import { + ONEPASSWORD_PROVIDER, + buildFieldReference, + buildTotpReference, + PASSWORD_FIELD_TITLE, + USERNAME_FIELD_TITLE, +} from './onepassword-credential-item'; + +/** + * Resolves a browser auth profile's stored login (username, password, live TOTP) + * from 1Password at run time using its `op:///` reference. Secrets + * are fetched just-in-time and never persisted or logged. + */ +export class OnePasswordCredentialVaultAdapter implements BrowserCredentialVaultAdapter { + private readonly logger = new Logger(OnePasswordCredentialVaultAdapter.name); + + async resolveCredentialReference(params: { + profileId: string; + provider?: string | null; + externalItemRef?: string | null; + connectionId?: string | null; + }): Promise { + if (params.provider !== ONEPASSWORD_PROVIDER) return null; + + const itemRef = params.externalItemRef?.trim(); + if (!itemRef) return null; + + const client = await getOnePasswordClient(); + + const [username, password] = await Promise.all([ + this.resolveField( + client, + buildFieldReference(itemRef, USERNAME_FIELD_TITLE), + ), + this.resolveField( + client, + buildFieldReference(itemRef, PASSWORD_FIELD_TITLE), + ), + ]); + // Resolve the OTP separately so its rotation window is as fresh as possible. + const totpCode = await this.resolveField( + client, + buildTotpReference(itemRef), + ); + + if (!username && !password && !totpCode) return null; + + return { + username: username ?? undefined, + password: password ?? undefined, + totpCode: totpCode ?? undefined, + }; + } + + private async resolveField( + client: OnePasswordClient, + reference: string, + ): Promise { + try { + const value = await client.secrets.resolve(reference); + return value?.trim() ? value : null; + } catch { + // A missing optional field (e.g. a login with no TOTP configured) resolves + // as an error; treat it as absent rather than failing the whole sign-in. + this.logger.debug(`1Password reference not resolvable: ${reference}`); + return null; + } + } +} diff --git a/apps/api/trigger.config.ts b/apps/api/trigger.config.ts index 44f4e88364..1a54130e05 100644 --- a/apps/api/trigger.config.ts +++ b/apps/api/trigger.config.ts @@ -10,6 +10,9 @@ export default defineConfig({ logLevel: 'log', maxDuration: 300, // 5 minutes build: { + // The 1Password SDK ships a native WASM core; keep it external so it's + // resolved from node_modules at runtime instead of being bundled by esbuild. + external: ['@1password/sdk'], extensions: [ caBundleExtension(), prismaExtension({ diff --git a/bun.lock b/bun.lock index f882aecab8..f093923b25 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,7 @@ "name": "@trycompai/api", "version": "0.0.1", "dependencies": { + "@1password/sdk": "0.4.0", "@ai-sdk/anthropic": "^3.0.75", "@ai-sdk/groq": "^3.0.38", "@ai-sdk/openai": "^3.0.62", @@ -871,6 +872,10 @@ "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], + "@1password/sdk": ["@1password/sdk@0.4.0", "", { "dependencies": { "@1password/sdk-core": "0.4.0" } }, "sha512-RIypujc9R/UeUaobjyClTYokqRFpcaIkHq+EO/X9XoHId98Vg+SbjwGV+yygRC4MyHwYNo1KP1iEbZcqJ4ZTdw=="], + + "@1password/sdk-core": ["@1password/sdk-core@0.4.0", "", {}, "sha512-vjeI1o4wiONY+t1naA4dtUp6HktdLH1D2S+tN1Lh4l41S9XIUHxrljov9B5u6G+VHr7f2MUoxmzXA9zT3aokQQ=="], + "@actions/core": ["@actions/core@3.0.1", "", { "dependencies": { "@actions/exec": "^3.0.0", "@actions/http-client": "^4.0.0" } }, "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA=="], "@actions/exec": ["@actions/exec@3.0.0", "", { "dependencies": { "@actions/io": "^3.0.2" } }, "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw=="], From d641a32e7f2f884456fb9848140bbc3ae4568e26 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Jul 2026 11:46:34 -0400 Subject: [PATCH 002/162] 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 003/162] 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 004/162] 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 005/162] 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 006/162] 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 ? ( +
+