@@ -33,37 +33,42 @@ async function createConnectDraft(params: {
3333 workspaceId : string
3434 providerId : string
3535 credentialId ?: string
36+ /** Reconnect only: the credential's actual name, so audit records stay accurate. */
37+ displayName ?: string
3638} ) : Promise < void > {
3739 const { userId, workspaceId, providerId, credentialId } = params
3840
39- const service = getAllOAuthServices ( ) . find ( ( s ) => s . providerId === providerId )
40- const serviceName = service ?. name ?? providerId
41+ let displayName = params . displayName
42+ if ( ! displayName ) {
43+ const service = getAllOAuthServices ( ) . find ( ( s ) => s . providerId === providerId )
44+ const serviceName = service ?. name ?? providerId
45+
46+ let userName : string | null = null
47+ try {
48+ const [ row ] = await db . select ( { name : user . name } ) . from ( user ) . where ( eq ( user . id , userId ) )
49+ userName = row ?. name ?? null
50+ } catch {
51+ // Fall back to the "My {Service}" default
52+ }
4153
42- let userName : string | null = null
43- try {
44- const [ row ] = await db . select ( { name : user . name } ) . from ( user ) . where ( eq ( user . id , userId ) )
45- userName = row ?. name ?? null
46- } catch {
47- // Fall back to the "My {Service}" default
48- }
54+ // Auto-number against existing workspace credentials so repeat connects for
55+ // the same provider stay distinguishable — same behavior as the connect
56+ // modal, which computes this client-side. Best effort: on failure the name
57+ // simply skips deduplication.
58+ let takenNames : ReadonlySet < string > = new Set < string > ( )
59+ try {
60+ const rows = await db
61+ . select ( { displayName : credential . displayName } )
62+ . from ( credential )
63+ . where ( and ( eq ( credential . workspaceId , workspaceId ) , eq ( credential . type , 'oauth' ) ) )
64+ takenNames = new Set ( rows . map ( ( row ) => row . displayName . toLowerCase ( ) ) )
65+ } catch {
66+ // Best effort — proceed without collision numbering
67+ }
4968
50- // Auto-number against existing workspace credentials so repeat connects for
51- // the same provider stay distinguishable — same behavior as the connect
52- // modal, which computes this client-side. Best effort: on failure the name
53- // simply skips deduplication.
54- let takenNames : ReadonlySet < string > = new Set < string > ( )
55- try {
56- const rows = await db
57- . select ( { displayName : credential . displayName } )
58- . from ( credential )
59- . where ( and ( eq ( credential . workspaceId , workspaceId ) , eq ( credential . type , 'oauth' ) ) )
60- takenNames = new Set ( rows . map ( ( row ) => row . displayName . toLowerCase ( ) ) )
61- } catch {
62- // Best effort — proceed without collision numbering
69+ displayName = defaultCredentialDisplayName ( userName , serviceName , takenNames )
6370 }
6471
65- const displayName = defaultCredentialDisplayName ( userName , serviceName , takenNames )
66-
6772 const now = new Date ( )
6873 const expiresAt = new Date ( now . getTime ( ) + DRAFT_TTL_MS )
6974 await db
@@ -141,7 +146,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
141146 return NextResponse . redirect ( `${ baseUrl } /workspace?error=workspace_access_denied` )
142147 }
143148
149+ let reconnectDisplayName : string | undefined
144150 if ( credentialId ) {
151+ // Trello and Shopify authorize through their own custom flows that bypass
152+ // this endpoint, so a reconnect draft written here would linger unconsumed
153+ // and could later be picked up by their token-store callbacks, silently
154+ // rebinding the credential. Mirror the copilot tool and reject reconnect.
155+ if ( providerId === 'trello' || providerId === 'shopify' ) {
156+ logger . warn ( 'Reconnect not supported for custom-flow provider' , {
157+ userId,
158+ workspaceId,
159+ providerId,
160+ credentialId,
161+ } )
162+ return NextResponse . redirect ( `${ baseUrl } /workspace?error=credential_reconnect_unsupported` )
163+ }
164+
145165 // Reconnect: the OAuth callback will rebind this credential to the fresh
146166 // account, so require the same credential-admin access as the draft POST
147167 // route — workspace write alone must not be enough to swap someone's tokens.
@@ -172,12 +192,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
172192 } )
173193 return NextResponse . redirect ( `${ baseUrl } /workspace?error=credential_provider_mismatch` )
174194 }
195+ reconnectDisplayName = actor . credential . displayName
175196 }
176197
177198 // Create the draft before initiating the link so it is guaranteed to exist
178199 // (and freshly clocked) when the OAuth callback's `account.create.after`
179200 // hook runs. If this throws, we never start the OAuth flow.
180- await createConnectDraft ( { userId, workspaceId, providerId, credentialId } )
201+ await createConnectDraft ( {
202+ userId,
203+ workspaceId,
204+ providerId,
205+ credentialId,
206+ displayName : reconnectDisplayName ,
207+ } )
181208
182209 const linkResponse = await auth . api . oAuth2LinkAccount ( {
183210 body : { providerId, callbackURL } ,
0 commit comments