From 85824185125f858a440cf8374f76beb364ea2a7f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 06:55:09 +0000 Subject: [PATCH 1/8] Add a copy-prompt to connect another account A second account on an existing integration should not dump the user into OAuth setup. Copy a prompt that keeps current connections intact and lets an agent walk the extra account through. Co-authored-by: me --- .../client/routes/account-integrations.tsx | 33 +++++++++++ .../integration-provider-catalog.node.test.ts | 40 +++++++++++++ .../routes/integration-provider-catalog.ts | 58 +++++++++++++++++++ .../worker/src/app/ssr-render.node.test.ts | 3 + 4 files changed, 134 insertions(+) diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index 0495e2c3e4..c18d8f1e49 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -39,6 +39,7 @@ import { } from '#client/routes/record-table.tsx' import { renderByokExplainer } from '#client/routes/byok-explainer.tsx' import { + buildAddAccountPrompt, buildCustomIntegrationSetupPrompt, buildIntegrationSetupPrompt, integrationProviderSuggestions, @@ -913,6 +914,38 @@ export function AccountIntegrationsRoute(handle: Handle) { ) })} +
+

+ Need another account on this integration? Copy a + prompt — your agent can connect it without + replacing this one. +

+ +
)} diff --git a/packages/worker/client/routes/integration-provider-catalog.node.test.ts b/packages/worker/client/routes/integration-provider-catalog.node.test.ts index 04c7dfbcea..58ecedad34 100644 --- a/packages/worker/client/routes/integration-provider-catalog.node.test.ts +++ b/packages/worker/client/routes/integration-provider-catalog.node.test.ts @@ -1,8 +1,10 @@ import { expect, test } from 'vitest' import { getGuideBySlug } from '#worker/guides/catalog.ts' import { + buildAddAccountPrompt, buildIntegrationSetupPrompt, integrationProviderSuggestions, + nextSuggestedConnectionName, } from './integration-provider-catalog.ts' test('integration provider suggestions resolve guide-backed prompts and keep a generic fallback', () => { @@ -36,3 +38,41 @@ test('integration provider suggestions resolve guide-backed prompts and keep a g expect(prompt.length).toBeGreaterThan(0) expect(prompt).not.toContain('coding_guide_get') }) + +test('add-account prompt keeps existing connections and suggests a free name', () => { + expect(nextSuggestedConnectionName('google', ['google'])).toBe('google-2') + expect(nextSuggestedConnectionName('google', ['google', 'google-2'])).toBe( + 'google-3', + ) + expect(nextSuggestedConnectionName('google', ['google', 'google-work'])).toBe( + 'google-2', + ) + + const builtIn = buildAddAccountPrompt({ + label: 'Google', + slug: 'google', + provider: 'google', + platform: true, + connections: [ + { name: 'google', accountLabel: 'me@example.com' }, + { name: 'google-work', accountLabel: 'work@example.com' }, + ], + }) + expect(builtIn).toContain('Keep the existing connections intact') + expect(builtIn).toContain('google (me@example.com)') + expect(builtIn).toContain('/connect/oauth?provider=google-2&platform=google') + expect(builtIn).toContain("coding_guide_get({ guide: 'provider_google' })") + expect(builtIn).toContain('Do not replace, rotate, or overwrite') + + const byo = buildAddAccountPrompt({ + label: 'GitHub', + slug: 'github', + provider: 'github', + platform: false, + connections: [{ name: 'github', accountLabel: null }], + }) + expect(byo).toContain('Keep the existing connection intact: github.') + expect(byo).toContain('do not register a new OAuth app') + expect(byo).toContain('/connect/oauth?provider=github-2') + expect(byo).not.toContain('platform=github') +}) diff --git a/packages/worker/client/routes/integration-provider-catalog.ts b/packages/worker/client/routes/integration-provider-catalog.ts index 9b3345c318..792b97211f 100644 --- a/packages/worker/client/routes/integration-provider-catalog.ts +++ b/packages/worker/client/routes/integration-provider-catalog.ts @@ -96,3 +96,61 @@ export function buildCustomIntegrationSetupPrompt() { 'and completing the OAuth authorization flow.', ].join(' ') } + +export function nextSuggestedConnectionName( + slug: string, + existingNames: ReadonlyArray, +) { + const taken = new Set(existingNames.map((name) => name.toLowerCase())) + if (!taken.has(slug.toLowerCase())) return slug + let n = 2 + while (taken.has(`${slug}-${n}`.toLowerCase())) n += 1 + return `${slug}-${n}` +} + +/** + * Prompt for a second (or later) account on an integration that already + * exists. The OAuth app is already saved — this must not recreate it or + * replace the current connection's tokens. + */ +export function buildAddAccountPrompt(input: { + label: string + slug: string + provider: string + platform: boolean + connections: ReadonlyArray<{ name: string; accountLabel: string | null }> +}) { + const suggestedName = nextSuggestedConnectionName( + input.slug, + input.connections.map((connection) => connection.name), + ) + const existing = input.connections + .map((connection) => { + const label = connection.accountLabel?.trim() + return label && label !== connection.name + ? `${connection.name} (${label})` + : connection.name + }) + .join(', ') + const suggestion = integrationProviderSuggestions.find( + (provider) => provider.id === input.provider || provider.id === input.slug, + ) + const guideClause = suggestion?.guideSlug + ? `Load the official Kody setup guide with coding_guide_get({ guide: 'provider_${suggestion.guideSlug}' }) if you need provider-specific steps.` + : '' + const connectPath = input.platform + ? `Open /connect/oauth?provider=${suggestedName}&platform=${input.slug} so the new account uses the built-in ${input.label} integration under a new connection name.` + : `Reuse my existing ${input.label} OAuth app. Connect under the name ${suggestedName} at /connect/oauth?provider=${suggestedName} — do not register a new OAuth app.` + + return [ + `Add another ${input.label} account to my Kody integrations.`, + `Keep the existing connection${input.connections.length === 1 ? '' : 's'} intact: ${existing}.`, + 'Do not replace, rotate, or overwrite those tokens.', + connectPath, + 'Ask me what this second account is for and choose the minimal extra access.', + 'Then walk me through authorization and verify the new connection without touching the old one.', + guideClause, + ] + .filter(Boolean) + .join(' ') +} diff --git a/packages/worker/src/app/ssr-render.node.test.ts b/packages/worker/src/app/ssr-render.node.test.ts index d5f9f8ff97..8e43ad389c 100644 --- a/packages/worker/src/app/ssr-render.node.test.ts +++ b/packages/worker/src/app/ssr-render.node.test.ts @@ -952,6 +952,8 @@ test('renderAppPage server-renders simplified integration and secret-approval pa expect(connectionResponse.status).toBe(200) const connectionHtml = await readResponseText(connectionResponse) expect(connectionHtml).toContain('1 account connected.') + expect(connectionHtml).toContain('data-testid="add-account-prompt"') + expect(connectionHtml).toContain('Copy add-account prompt') expect(connectionHtml).toContain('>Reconnect<') expect(connectionHtml).toContain('data-testid="provider-mark"') expect(connectionHtml).toContain('data-testid="integration-advanced"') @@ -1028,6 +1030,7 @@ test('renderAppPage server-renders simplified integration and secret-approval pa expect(builtInHtml).toContain('data-testid="built-in-indicator"') expect(builtInHtml).toContain('Provided by Kody') expect(builtInHtml).toContain('2 accounts connected.') + expect(builtInHtml).toContain('data-testid="add-account-prompt"') expect(builtInHtml).toContain('Needs setup') expect(builtInHtml).toContain('>Connect<') expect(builtInHtml).toContain( From 8fa56803db40710f9108b9f0c40635a40118db60 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 07:08:16 +0000 Subject: [PATCH 2/8] Ask for a connection name when adding another account A second account on an existing integration is just a new connection name, defaulting to {slug}-{n}. Copying an agent prompt was more ceremony than the connect flow needs. Co-authored-by: me --- .../client/routes/account-integrations.tsx | 119 +++++++++++++----- .../integration-provider-catalog.node.test.ts | 31 +---- .../routes/integration-provider-catalog.ts | 47 ------- .../worker/src/app/ssr-render.node.test.ts | 8 +- 4 files changed, 92 insertions(+), 113 deletions(-) diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index c18d8f1e49..e8399df4eb 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -1,3 +1,4 @@ +import { normalizeProviderKey } from '@kody-internal/shared/url-hosts.ts' import { formatTimestamp } from '#client/format-timestamp.ts' import { type AccountIntegrationListItem, @@ -39,10 +40,10 @@ import { } from '#client/routes/record-table.tsx' import { renderByokExplainer } from '#client/routes/byok-explainer.tsx' import { - buildAddAccountPrompt, buildCustomIntegrationSetupPrompt, buildIntegrationSetupPrompt, integrationProviderSuggestions, + nextSuggestedConnectionName, } from '#client/routes/integration-provider-catalog.ts' import { integrationDisplayName } from '#client/routes/integration-filter.ts' import { matchesSearchQuery } from '#client/search-filter.ts' @@ -269,6 +270,83 @@ function connectActionLabel(status: 'Connected' | 'Needs setup') { return status === 'Connected' ? 'Reconnect' : 'Connect' } +function AddAccountForm( + handle: Handle<{ + slug: string + platform: boolean + existingNames: ReadonlyArray + }>, +) { + let name = nextSuggestedConnectionName( + handle.props.slug, + handle.props.existingNames, + ) + + function connectHref(connectionName: string) { + return buildConnectOauthHref({ + name: connectionName, + platform: handle.props.platform, + appSlug: handle.props.slug, + }) + } + + return () => { + const suggested = nextSuggestedConnectionName( + handle.props.slug, + handle.props.existingNames, + ) + return ( +
{ + event.preventDefault() + const next = normalizeProviderKey(name.trim()) || suggested + window.location.assign(connectHref(next)) + }), + css({ + display: 'grid', + gap: spacing.sm, + justifyItems: 'start', + }), + ]} + > +

+ Add another account on this integration. Existing connections stay + put. +

+ + +
+ ) + } +} + function PlugIcon() { return ( ) })} -
-

- Need another account on this integration? Copy a - prompt — your agent can connect it without - replacing this one. -

- -
+ connection.name, + )} + /> )} diff --git a/packages/worker/client/routes/integration-provider-catalog.node.test.ts b/packages/worker/client/routes/integration-provider-catalog.node.test.ts index 58ecedad34..1aa2077262 100644 --- a/packages/worker/client/routes/integration-provider-catalog.node.test.ts +++ b/packages/worker/client/routes/integration-provider-catalog.node.test.ts @@ -1,7 +1,6 @@ import { expect, test } from 'vitest' import { getGuideBySlug } from '#worker/guides/catalog.ts' import { - buildAddAccountPrompt, buildIntegrationSetupPrompt, integrationProviderSuggestions, nextSuggestedConnectionName, @@ -39,7 +38,7 @@ test('integration provider suggestions resolve guide-backed prompts and keep a g expect(prompt).not.toContain('coding_guide_get') }) -test('add-account prompt keeps existing connections and suggests a free name', () => { +test('next suggested connection name skips taken {slug}-{n} keys', () => { expect(nextSuggestedConnectionName('google', ['google'])).toBe('google-2') expect(nextSuggestedConnectionName('google', ['google', 'google-2'])).toBe( 'google-3', @@ -47,32 +46,4 @@ test('add-account prompt keeps existing connections and suggests a free name', ( expect(nextSuggestedConnectionName('google', ['google', 'google-work'])).toBe( 'google-2', ) - - const builtIn = buildAddAccountPrompt({ - label: 'Google', - slug: 'google', - provider: 'google', - platform: true, - connections: [ - { name: 'google', accountLabel: 'me@example.com' }, - { name: 'google-work', accountLabel: 'work@example.com' }, - ], - }) - expect(builtIn).toContain('Keep the existing connections intact') - expect(builtIn).toContain('google (me@example.com)') - expect(builtIn).toContain('/connect/oauth?provider=google-2&platform=google') - expect(builtIn).toContain("coding_guide_get({ guide: 'provider_google' })") - expect(builtIn).toContain('Do not replace, rotate, or overwrite') - - const byo = buildAddAccountPrompt({ - label: 'GitHub', - slug: 'github', - provider: 'github', - platform: false, - connections: [{ name: 'github', accountLabel: null }], - }) - expect(byo).toContain('Keep the existing connection intact: github.') - expect(byo).toContain('do not register a new OAuth app') - expect(byo).toContain('/connect/oauth?provider=github-2') - expect(byo).not.toContain('platform=github') }) diff --git a/packages/worker/client/routes/integration-provider-catalog.ts b/packages/worker/client/routes/integration-provider-catalog.ts index 792b97211f..d7fa30db8c 100644 --- a/packages/worker/client/routes/integration-provider-catalog.ts +++ b/packages/worker/client/routes/integration-provider-catalog.ts @@ -107,50 +107,3 @@ export function nextSuggestedConnectionName( while (taken.has(`${slug}-${n}`.toLowerCase())) n += 1 return `${slug}-${n}` } - -/** - * Prompt for a second (or later) account on an integration that already - * exists. The OAuth app is already saved — this must not recreate it or - * replace the current connection's tokens. - */ -export function buildAddAccountPrompt(input: { - label: string - slug: string - provider: string - platform: boolean - connections: ReadonlyArray<{ name: string; accountLabel: string | null }> -}) { - const suggestedName = nextSuggestedConnectionName( - input.slug, - input.connections.map((connection) => connection.name), - ) - const existing = input.connections - .map((connection) => { - const label = connection.accountLabel?.trim() - return label && label !== connection.name - ? `${connection.name} (${label})` - : connection.name - }) - .join(', ') - const suggestion = integrationProviderSuggestions.find( - (provider) => provider.id === input.provider || provider.id === input.slug, - ) - const guideClause = suggestion?.guideSlug - ? `Load the official Kody setup guide with coding_guide_get({ guide: 'provider_${suggestion.guideSlug}' }) if you need provider-specific steps.` - : '' - const connectPath = input.platform - ? `Open /connect/oauth?provider=${suggestedName}&platform=${input.slug} so the new account uses the built-in ${input.label} integration under a new connection name.` - : `Reuse my existing ${input.label} OAuth app. Connect under the name ${suggestedName} at /connect/oauth?provider=${suggestedName} — do not register a new OAuth app.` - - return [ - `Add another ${input.label} account to my Kody integrations.`, - `Keep the existing connection${input.connections.length === 1 ? '' : 's'} intact: ${existing}.`, - 'Do not replace, rotate, or overwrite those tokens.', - connectPath, - 'Ask me what this second account is for and choose the minimal extra access.', - 'Then walk me through authorization and verify the new connection without touching the old one.', - guideClause, - ] - .filter(Boolean) - .join(' ') -} diff --git a/packages/worker/src/app/ssr-render.node.test.ts b/packages/worker/src/app/ssr-render.node.test.ts index 8e43ad389c..d103372da3 100644 --- a/packages/worker/src/app/ssr-render.node.test.ts +++ b/packages/worker/src/app/ssr-render.node.test.ts @@ -952,8 +952,9 @@ test('renderAppPage server-renders simplified integration and secret-approval pa expect(connectionResponse.status).toBe(200) const connectionHtml = await readResponseText(connectionResponse) expect(connectionHtml).toContain('1 account connected.') - expect(connectionHtml).toContain('data-testid="add-account-prompt"') - expect(connectionHtml).toContain('Copy add-account prompt') + expect(connectionHtml).toContain('data-testid="add-account-form"') + expect(connectionHtml).toContain('Connection name') + expect(connectionHtml).toContain('value="google-2"') expect(connectionHtml).toContain('>Reconnect<') expect(connectionHtml).toContain('data-testid="provider-mark"') expect(connectionHtml).toContain('data-testid="integration-advanced"') @@ -1030,7 +1031,8 @@ test('renderAppPage server-renders simplified integration and secret-approval pa expect(builtInHtml).toContain('data-testid="built-in-indicator"') expect(builtInHtml).toContain('Provided by Kody') expect(builtInHtml).toContain('2 accounts connected.') - expect(builtInHtml).toContain('data-testid="add-account-prompt"') + expect(builtInHtml).toContain('data-testid="add-account-form"') + expect(builtInHtml).toContain('value="google-2"') expect(builtInHtml).toContain('Needs setup') expect(builtInHtml).toContain('>Connect<') expect(builtInHtml).toContain( From 1810472a33cf365e691e9eb2eeea734757335331 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 07:12:37 +0000 Subject: [PATCH 3/8] Reveal the add-account form from a link-style control Keep the extra name field out of the way until someone clicks Add another account. Co-authored-by: me --- .../client/routes/account-integrations.tsx | 33 ++++++++++++++++--- .../worker/src/app/ssr-render.node.test.ts | 11 ++++--- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index e8399df4eb..7d95b6943d 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -270,6 +270,16 @@ function connectActionLabel(status: 'Connected' | 'Needs setup') { return status === 'Connected' ? 'Reconnect' : 'Connect' } +const addAccountLinkCss = { + ...primaryLinkCss, + padding: 0, + border: 0, + background: 'none', + font: 'inherit', + cursor: 'pointer', + appearance: 'none' as const, +} + function AddAccountForm( handle: Handle<{ slug: string @@ -277,6 +287,7 @@ function AddAccountForm( existingNames: ReadonlyArray }>, ) { + let open = false let name = nextSuggestedConnectionName( handle.props.slug, handle.props.existingNames, @@ -295,6 +306,24 @@ function AddAccountForm( handle.props.slug, handle.props.existingNames, ) + if (!open) { + return ( + + ) + } return (
-

- Add another account on this integration. Existing connections stay - put. -

+ ) } return ( { @@ -351,6 +343,7 @@ function AddAccountForm( display: 'grid', gap: spacing.sm, justifyItems: 'start', + scrollMarginTop: '5.5rem', }), ]} > @@ -1054,6 +1047,8 @@ export function AccountIntegrationsRoute(handle: Handle) { ...integrations.map((entry) => entry.name), ...apps.map((app) => app.slug), ]} + open={isAddAccountFormOpen(getCurrentHref())} + openHref={buildAddAccountHref(getCurrentHref())} /> )} diff --git a/packages/worker/client/routes/integration-provider-catalog.node.test.ts b/packages/worker/client/routes/integration-provider-catalog.node.test.ts index 56f9559f38..b639e8640a 100644 --- a/packages/worker/client/routes/integration-provider-catalog.node.test.ts +++ b/packages/worker/client/routes/integration-provider-catalog.node.test.ts @@ -1,8 +1,10 @@ import { expect, test } from 'vitest' import { getGuideBySlug } from '#worker/guides/catalog.ts' import { + buildAddAccountHref, buildIntegrationSetupPrompt, integrationProviderSuggestions, + isAddAccountFormOpen, isTakenConnectionName, nextSuggestedConnectionName, resolveAddAccountConnectionName, @@ -89,3 +91,16 @@ test('add-account name resolution rejects names already used by any connection o }), ).toEqual({ ok: true, name: 'google-3' }) }) + +test('add-account href keeps the current path and search, then opens the form anchor', () => { + expect(isAddAccountFormOpen('/account/integrations/google')).toBe(false) + expect( + isAddAccountFormOpen('/account/integrations/google?add-account=1'), + ).toBe(true) + expect(buildAddAccountHref('/account/integrations/google?q=goo')).toBe( + '/account/integrations/google?q=goo&add-account=1#add-account', + ) + expect(buildAddAccountHref('/account/integrations/apps/google')).toBe( + '/account/integrations/apps/google?add-account=1#add-account', + ) +}) diff --git a/packages/worker/client/routes/integration-provider-catalog.ts b/packages/worker/client/routes/integration-provider-catalog.ts index 7cddd27cb8..257e14634f 100644 --- a/packages/worker/client/routes/integration-provider-catalog.ts +++ b/packages/worker/client/routes/integration-provider-catalog.ts @@ -142,3 +142,19 @@ export function nextSuggestedConnectionName( while (taken.has(`${slugKey}-${n}`)) n += 1 return `${slugKey}-${n}` } + +export const addAccountQueryParam = 'add-account' +export const addAccountAnchorId = 'add-account' + +export function isAddAccountFormOpen(href: string) { + return new URL(href, 'http://localhost').searchParams.has( + addAccountQueryParam, + ) +} + +export function buildAddAccountHref(href: string) { + const url = new URL(href, 'http://localhost') + url.searchParams.set(addAccountQueryParam, '1') + url.hash = addAccountAnchorId + return `${url.pathname}${url.search}${url.hash}` +} diff --git a/packages/worker/src/app/ssr-render.node.test.ts b/packages/worker/src/app/ssr-render.node.test.ts index 9b0af02d56..74cc6673b8 100644 --- a/packages/worker/src/app/ssr-render.node.test.ts +++ b/packages/worker/src/app/ssr-render.node.test.ts @@ -954,6 +954,10 @@ test('renderAppPage server-renders simplified integration and secret-approval pa expect(connectionHtml).toContain('1 account connected.') expect(connectionHtml).toContain('data-testid="add-account-open"') expect(connectionHtml).toContain('Add another account') + expect(connectionHtml).toContain( + 'href="/account/integrations/google?add-account=1#add-account"', + ) + expect(connectionHtml).toContain('data-prevent-scroll-reset') expect(connectionHtml).not.toContain('data-testid="add-account-form"') expect(connectionHtml).toContain('>Reconnect<') expect(connectionHtml).toContain('data-testid="provider-mark"') @@ -1034,6 +1038,30 @@ test('renderAppPage server-renders simplified integration and secret-approval pa expect(builtInHtml).toContain('data-testid="add-account-open"') expect(builtInHtml).toContain('Add another account') expect(builtInHtml).not.toContain('data-testid="add-account-form"') + + const addAccountResponse = await renderAppPage({ + request: new Request( + 'https://example.com/account/integrations/google?add-account=1#add-account', + { headers: { Cookie: cookie } }, + ), + env, + loaderData: { + accountIntegrations: { + ok: true, + email: 'user@example.com', + username: 'account-user', + integrations: [googleConnection], + apps: [googleApp], + }, + }, + }) + expect(addAccountResponse.status).toBe(200) + const addAccountHtml = await readResponseText(addAccountResponse) + expect(addAccountHtml).toContain('data-testid="add-account-form"') + expect(addAccountHtml).toContain('id="add-account"') + expect(addAccountHtml).toContain('Connection name') + expect(addAccountHtml).toContain('value="google-2"') + expect(addAccountHtml).not.toContain('data-testid="add-account-open"') expect(builtInHtml).toContain('Needs setup') expect(builtInHtml).toContain('>Connect<') expect(builtInHtml).toContain( From 0c5097c1ef4834a68458b9440a894be7bb683b43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 07:37:02 +0000 Subject: [PATCH 7/8] Pin BYO add-account to the selected OAuth app Pass app= so a custom connection name still reuses the saved app, and reset the suggested name when the selected integration changes. Co-authored-by: me --- .../client/routes/account-integrations.tsx | 19 +++++++--- .../worker/client/routes/connect-oauth.tsx | 28 ++++++++++---- .../account-integrations-data.node.test.ts | 13 +++++++ .../src/app/account-integrations-data.ts | 38 +++++++++++++++++++ .../src/app/handlers/account-integrations.ts | 16 ++------ .../app/handlers/connect-oauth.node.test.ts | 30 +++++++++++++-- .../worker/src/app/handlers/connect-oauth.ts | 11 ++---- packages/worker/src/integrations/service.ts | 4 +- 8 files changed, 121 insertions(+), 38 deletions(-) diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index 0aa82c3a33..925218e979 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -263,8 +263,11 @@ function buildConnectOauthHref(input: { appSlug?: string }) { const params = new URLSearchParams({ provider: input.name }) + const appSlug = input.appSlug?.trim() if (input.platform) { - params.set('platform', input.appSlug?.trim() || '1') + params.set('platform', appSlug || '1') + } else if (appSlug) { + params.set('app', appSlug) } return `/connect/oauth?${params.toString()}` } @@ -289,10 +292,8 @@ function AddAccountForm( }>, ) { let nameError: string | null = null - let name = nextSuggestedConnectionName( - handle.props.slug, - handle.props.existingNames, - ) + let editedName: string | null = null + let boundSlug = handle.props.slug function connectHref(connectionName: string) { return buildConnectOauthHref({ @@ -303,10 +304,16 @@ function AddAccountForm( } return () => { + if (handle.props.slug !== boundSlug) { + boundSlug = handle.props.slug + editedName = null + nameError = null + } const suggested = nextSuggestedConnectionName( handle.props.slug, handle.props.existingNames, ) + const name = editedName ?? suggested if (!handle.props.open) { return ( { - name = event.currentTarget.value + editedName = event.currentTarget.value nameError = null handle.update() }), diff --git a/packages/worker/client/routes/connect-oauth.tsx b/packages/worker/client/routes/connect-oauth.tsx index 2f233d19f3..d5c796c982 100644 --- a/packages/worker/client/routes/connect-oauth.tsx +++ b/packages/worker/client/routes/connect-oauth.tsx @@ -105,6 +105,18 @@ const emptyConnectOauthLoaderData: ConnectOauthLoaderData = { integration: null, } +function buildConnectOauthIntegrationLookupHref( + providerKey: string, + searchParams: URLSearchParams, +) { + const params = new URLSearchParams({ name: providerKey }) + const platform = searchParams.get('platform')?.trim() + if (platform) params.set('platform', platform) + const app = searchParams.get('app')?.trim() + if (app) params.set('app', app) + return `/account/integrations.json?${params.toString()}` +} + /** * SPA-navigation prefetch mirroring the server handler's SSR embed: the * stored or built-in record for `?provider=` visits, resolved before the @@ -125,9 +137,8 @@ export async function connectOauthRouteLoader( if (!providerKey) { return { connectOauth: emptyConnectOauthLoaderData } } - const platformParam = params.get('platform')?.trim() const response = await fetch( - `/account/integrations.json?name=${encodeURIComponent(providerKey)}${platformParam ? `&platform=${encodeURIComponent(platformParam)}` : ''}`, + buildConnectOauthIntegrationLookupHref(providerKey, params), { headers: { Accept: 'application/json' }, credentials: 'include', @@ -521,14 +532,15 @@ export function ConnectOauthRoute(handle: Handle) { const readExistingIntegrationConfig = async ( queryConfig: ConnectOauthQueryConfig, ): Promise => { - const platformParam = + const lookupSearch = typeof window !== 'undefined' - ? (new URLSearchParams(window.location.search) - .get('platform') - ?.trim() ?? '') - : '' + ? new URLSearchParams(window.location.search) + : new URLSearchParams() const response = await fetch( - `/account/integrations.json?name=${encodeURIComponent(queryConfig.providerKey)}${platformParam ? `&platform=${encodeURIComponent(platformParam)}` : ''}`, + buildConnectOauthIntegrationLookupHref( + queryConfig.providerKey, + lookupSearch, + ), { method: 'GET', headers: { Accept: 'application/json' }, diff --git a/packages/worker/src/app/account-integrations-data.node.test.ts b/packages/worker/src/app/account-integrations-data.node.test.ts index e6110ec771..50199b4095 100644 --- a/packages/worker/src/app/account-integrations-data.node.test.ts +++ b/packages/worker/src/app/account-integrations-data.node.test.ts @@ -318,6 +318,19 @@ test('endpoint-incomplete user records defer to an enabled built-in of the same ) expect(noFallback?.clientId).toBe('user-linear-client') expect(noFallback?.platform ?? false).toBe(false) + + const pinnedByo = await loadAccountIntegrationByName( + env, + fakeUser(userId), + 'work', + { appSlug: 'github' }, + ) + expect(pinnedByo).toMatchObject({ + name: 'work', + appSlug: 'github', + clientId: 'user-github-client', + }) + expect(pinnedByo?.platform ?? false).toBe(false) }) test('loadAccountIntegrationsData includes OAuth apps with their connections', async () => { diff --git a/packages/worker/src/app/account-integrations-data.ts b/packages/worker/src/app/account-integrations-data.ts index 17898c28f6..f1d375c478 100644 --- a/packages/worker/src/app/account-integrations-data.ts +++ b/packages/worker/src/app/account-integrations-data.ts @@ -15,6 +15,7 @@ import { getOauthApp, listJoinedIntegrations, listOauthApps, + oauthAppToSetupPrefill, toJoinedIntegrationConfig, type OauthAppSetupPrefill, type PlatformOauthApp, @@ -286,6 +287,21 @@ export async function loadAccountOauthAppBySlug( * (agents typically save tokenUrl and apiBaseUrl but no authorize URL) * cannot, and would dead-end the page on "missing configuration". */ +export function readConnectOauthLookupOptions(searchParams: URLSearchParams) { + const platformParam = searchParams.get('platform')?.trim() + const appParam = searchParams.get('app')?.trim() + return { + preferPlatform: platformParam === '1', + platformSlug: + platformParam && platformParam !== '1' + ? (normalizeProviderKey(platformParam) ?? undefined) + : undefined, + appSlug: appParam + ? (normalizeProviderKey(appParam) ?? undefined) + : undefined, + } +} + function recordCanDriveConnectFlow(record: AccountIntegrationRecord): boolean { return Boolean( record.authorization?.authorizeUrl?.trim() && record.tokenUrl?.trim(), @@ -310,6 +326,12 @@ export async function loadAccountIntegrationByName( * google-2 keeps an existing bring-your-own google connection intact. */ platformSlug?: string + /** + * Saved bring-your-own app to reuse under a new connection name + * (`app=`): connecting `work` on the google app must not depend + * on inferring that app from the typed name. + */ + appSlug?: string }, ): Promise { // A user-lane record still wins when it can actually drive the flow (the @@ -337,6 +359,22 @@ export async function loadAccountIntegrationByName( return (await platformFallback()) ?? record } + if (options?.appSlug) { + const app = await getOauthApp({ + env, + userId: user.mcpUser.userId, + slug: options.appSlug, + }) + if (app) { + const record = toAppOnlyIntegrationRecord( + oauthAppToSetupPrefill(app), + name, + ) + if (recordCanDriveConnectFlow(record)) return record + return (await platformFallback()) ?? record + } + } + // 2–3. Exact app slug, else field-wise provider-family prefill (shared // client id across github/github-kent, shared google app, etc.). const prefill = await findOauthAppForProviderSetup({ diff --git a/packages/worker/src/app/handlers/account-integrations.ts b/packages/worker/src/app/handlers/account-integrations.ts index 10a79437b4..4db35337f2 100644 --- a/packages/worker/src/app/handlers/account-integrations.ts +++ b/packages/worker/src/app/handlers/account-integrations.ts @@ -1,14 +1,12 @@ import { z } from 'zod' import { jsonResponse } from '#worker/json-response.ts' import { type Action } from 'remix/router' -import { - normalizeProviderKey, - safeParseHost, -} from '@kody-internal/shared/url-hosts.ts' +import { safeParseHost } from '@kody-internal/shared/url-hosts.ts' import { hasAlternativeBuiltInApp, hasStoredConnectClientSecret, loadAccountIntegrationByName, + readConnectOauthLookupOptions, loadAccountIntegrationsData, loadExistingConnectionSummary, loadAccountOauthAppBySlug, @@ -84,18 +82,12 @@ export function createAccountIntegrationsApiHandler(env: Env) { // `platform=1` forces the built-in of the same name; // `platform=` connects that built-in under a // different connection name (rename-instead-of-replace). - const platformParam = searchParams.get('platform')?.trim() + // `app=` reuses a saved bring-your-own app under `name`. const integration = await loadAccountIntegrationByName( env, user, name, - { - preferPlatform: platformParam === '1', - platformSlug: - platformParam && platformParam !== '1' - ? (normalizeProviderKey(platformParam) ?? undefined) - : undefined, - }, + readConnectOauthLookupOptions(searchParams), ) const [builtInAvailable, existingConnection, hasStoredClientSecret] = await Promise.all([ diff --git a/packages/worker/src/app/handlers/connect-oauth.node.test.ts b/packages/worker/src/app/handlers/connect-oauth.node.test.ts index 2213663489..2a2f365c9d 100644 --- a/packages/worker/src/app/handlers/connect-oauth.node.test.ts +++ b/packages/worker/src/app/handlers/connect-oauth.node.test.ts @@ -12,6 +12,16 @@ const mockModule = vi.hoisted(() => ({ hasAlternativeBuiltInApp: vi.fn<() => Promise>(), loadExistingConnectionSummary: vi.fn<() => Promise>(), hasStoredConnectClientSecret: vi.fn<() => Promise>(), + readConnectOauthLookupOptions: (searchParams: URLSearchParams) => { + const platformParam = searchParams.get('platform')?.trim() + const appParam = searchParams.get('app')?.trim() + return { + preferPlatform: platformParam === '1', + platformSlug: + platformParam && platformParam !== '1' ? platformParam : undefined, + appSlug: appParam || undefined, + } + }, renderAppPage: vi.fn<(input: unknown) => Promise>(), })) @@ -34,6 +44,8 @@ vi.mock('#app/account-integrations-data.ts', () => ({ mockModule.loadExistingConnectionSummary(...args), hasStoredConnectClientSecret: (...args: Array) => mockModule.hasStoredConnectClientSecret(...args), + readConnectOauthLookupOptions: (searchParams: URLSearchParams) => + mockModule.readConnectOauthLookupOptions(searchParams), })) vi.mock('#app/ssr-render.tsx', () => ({ @@ -94,7 +106,7 @@ test('provider visits embed SSR loader data and honor platform lookup flags', as env, expect.anything(), 'github', - { preferPlatform: false, platformSlug: undefined }, + { preferPlatform: false, platformSlug: undefined, appSlug: undefined }, ) expect(mockModule.renderAppPage).toHaveBeenCalledWith( expect.objectContaining({ @@ -129,7 +141,7 @@ test('provider visits embed SSR loader data and honor platform lookup flags', as env, expect.anything(), 'google', - { preferPlatform: true, platformSlug: undefined }, + { preferPlatform: true, platformSlug: undefined, appSlug: undefined }, ) await createConnectOauthHandler(env).handler( @@ -143,7 +155,19 @@ test('provider visits embed SSR loader data and honor platform lookup flags', as env, expect.anything(), 'google-2', - { preferPlatform: false, platformSlug: 'google' }, + { preferPlatform: false, platformSlug: 'google', appSlug: undefined }, + ) + + await createConnectOauthHandler(env).handler( + new RequestContext( + new Request('https://example.com/connect/oauth?provider=work&app=google'), + ), + ) + expect(mockModule.loadAccountIntegrationByName).toHaveBeenLastCalledWith( + env, + expect.anything(), + 'work', + { preferPlatform: false, platformSlug: undefined, appSlug: 'google' }, ) }) diff --git a/packages/worker/src/app/handlers/connect-oauth.ts b/packages/worker/src/app/handlers/connect-oauth.ts index 2461ce0670..1ae66eb771 100644 --- a/packages/worker/src/app/handlers/connect-oauth.ts +++ b/packages/worker/src/app/handlers/connect-oauth.ts @@ -5,6 +5,7 @@ import { hasStoredConnectClientSecret, loadAccountIntegrationByName, loadExistingConnectionSummary, + readConnectOauthLookupOptions, } from '#app/account-integrations-data.ts' import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { requirePageSession } from '#app/page-auth.ts' @@ -57,18 +58,12 @@ async function loadConnectOauthLoaderData( } // `platform=1` forces the built-in of the same name; `platform=` // connects that built-in under a different connection name. - const platformParam = requestUrl.searchParams.get('platform')?.trim() + // `app=` reuses a saved bring-your-own app under `provider`. const integration = await loadAccountIntegrationByName( env, user, providerKey, - { - preferPlatform: platformParam === '1', - platformSlug: - platformParam && platformParam !== '1' - ? (normalizeProviderKey(platformParam) ?? undefined) - : undefined, - }, + readConnectOauthLookupOptions(requestUrl.searchParams), ) const [builtInAvailable, existingConnection, hasStoredClientSecret] = await Promise.all([ diff --git a/packages/worker/src/integrations/service.ts b/packages/worker/src/integrations/service.ts index 95d26d511c..778b8a6851 100644 --- a/packages/worker/src/integrations/service.ts +++ b/packages/worker/src/integrations/service.ts @@ -612,7 +612,9 @@ export async function findOauthAppForProviderSetup(input: { return setupPrefillHasAgreedField(merged) ? merged : null } -function oauthAppToSetupPrefill(app: UserOauthApp): OauthAppSetupPrefill { +export function oauthAppToSetupPrefill( + app: UserOauthApp, +): OauthAppSetupPrefill { return { userId: app.userId, slug: app.slug, From 71e4964747356204d350d0072663cba15cd532f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 07:41:19 +0000 Subject: [PATCH 8/8] Keep pinned BYO apps from falling back by typed name An incomplete app= lookup now returns that saved app instead of a built-in that happens to match the connection name. Co-authored-by: me --- .../app/account-integrations-data.node.test.ts | 15 +++++++++++++++ .../worker/src/app/account-integrations-data.ts | 10 ++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/worker/src/app/account-integrations-data.node.test.ts b/packages/worker/src/app/account-integrations-data.node.test.ts index 50199b4095..39aba9429b 100644 --- a/packages/worker/src/app/account-integrations-data.node.test.ts +++ b/packages/worker/src/app/account-integrations-data.node.test.ts @@ -331,6 +331,21 @@ test('endpoint-incomplete user records defer to an enabled built-in of the same clientId: 'user-github-client', }) expect(pinnedByo?.platform ?? false).toBe(false) + + // An incomplete pinned app must not fall back to a built-in that + // happens to share the typed connection name. + const pinnedIncomplete = await loadAccountIntegrationByName( + env, + fakeUser(userId), + 'github-platform', + { appSlug: 'linear' }, + ) + expect(pinnedIncomplete).toMatchObject({ + name: 'github-platform', + appSlug: 'linear', + clientId: 'user-linear-client', + }) + expect(pinnedIncomplete?.platform ?? false).toBe(false) }) test('loadAccountIntegrationsData includes OAuth apps with their connections', async () => { diff --git a/packages/worker/src/app/account-integrations-data.ts b/packages/worker/src/app/account-integrations-data.ts index f1d375c478..c605697c0f 100644 --- a/packages/worker/src/app/account-integrations-data.ts +++ b/packages/worker/src/app/account-integrations-data.ts @@ -366,12 +366,10 @@ export async function loadAccountIntegrationByName( slug: options.appSlug, }) if (app) { - const record = toAppOnlyIntegrationRecord( - oauthAppToSetupPrefill(app), - name, - ) - if (recordCanDriveConnectFlow(record)) return record - return (await platformFallback()) ?? record + // Keep the pinned app even when it cannot drive authorize yet. + // Falling back by the typed connection name can land on a + // different built-in (`app=linear&provider=github-platform`). + return toAppOnlyIntegrationRecord(oauthAppToSetupPrefill(app), name) } }