Resolves the slug against the tenant catalog and forwards into the integration detail page's existing add-connection flow. Unknown slugs get a not-found state. Makes the link survive sign-in: reserve connect as an org slug so cloud's callback stops reading it as an org selector, and return self-host's login to the requested path (its gate swaps the page in without ever writing a returnTo).
Unified · Split
| @@ -23,6 +23,7 @@ import { Route as ApiKeysRouteImport } from './routes/app/api-keys' · 22 unmodified lines | ||
| 23 | 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 24 | 24 | import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' |
| 25 | 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 26 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' | |
| 26 | 27 | import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' |
| 27 | 28 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' |
| 28 | 29 | |
| @@ -107,6 +108,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 78 unmodified lines | ||
| 107 | 108 | getParentRoute: () => rootRouteImport, |
| 108 | 109 | } as any, |
| 109 | 110 | ) |
| 111 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute = | |
| 112 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update( | |
| 113 | + { | |
| 114 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 115 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 116 | + getParentRoute: () => rootRouteImport, | |
| 117 | + } as any, | |
| 118 | + ) | |
| 110 | 119 | const Billing_DotplansRoute = Billing_DotplansRouteImport.update({ |
| 111 | 120 | id: '/{-$orgSlug}/billing_/plans', |
| 112 | 121 | path: '/{-$orgSlug}/billing/plans', |
| @@ -134,6 +143,7 @@ export interface FileRoutesByFullPath { · 21 unmodified lines | ||
| 134 | 143 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 135 | 144 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 136 | 145 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 146 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 137 | 147 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 138 | 148 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 139 | 149 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -152,6 +162,7 @@ export interface FileRoutesByTo { · 12 unmodified lines | ||
| 152 | 162 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 153 | 163 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 154 | 164 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 165 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 155 | 166 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 156 | 167 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 157 | 168 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -171,6 +182,7 @@ export interface FileRoutesById { · 13 unmodified lines | ||
| 171 | 182 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 172 | 183 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 173 | 184 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute |
| 185 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 174 | 186 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 175 | 187 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 176 | 188 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -191,6 +203,7 @@ export interface FileRouteTypes { · 14 unmodified lines | ||
| 191 | 203 | | '/{-$orgSlug}/tools' |
| 192 | 204 | | '/{-$orgSlug}/' |
| 193 | 205 | | '/{-$orgSlug}/billing/plans' |
| 206 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 194 | 207 | | '/{-$orgSlug}/integrations/$namespace' |
| 195 | 208 | | '/{-$orgSlug}/resume/$executionId' |
| 196 | 209 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -209,6 +222,7 @@ export interface FileRouteTypes { · 12 unmodified lines | ||
| 209 | 222 | | '/{-$orgSlug}/tools' |
| 210 | 223 | | '/{-$orgSlug}' |
| 211 | 224 | | '/{-$orgSlug}/billing/plans' |
| 225 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 212 | 226 | | '/{-$orgSlug}/integrations/$namespace' |
| 213 | 227 | | '/{-$orgSlug}/resume/$executionId' |
| 214 | 228 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -227,6 +241,7 @@ export interface FileRouteTypes { · 12 unmodified lines | ||
| 227 | 241 | | '/{-$orgSlug}/tools' |
| 228 | 242 | | '/{-$orgSlug}/' |
| 229 | 243 | | '/{-$orgSlug}/billing_/plans' |
| 244 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 230 | 245 | | '/{-$orgSlug}/integrations/$namespace' |
| 231 | 246 | | '/{-$orgSlug}/resume/$executionId' |
| 232 | 247 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -246,6 +261,7 @@ export interface RootRouteChildren { · 13 unmodified lines | ||
| 246 | 261 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 247 | 262 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 248 | 263 | Billing_DotplansRoute: typeof Billing_DotplansRoute |
| 264 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 249 | 265 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 250 | 266 | ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute |
| 251 | 267 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -351,6 +367,13 @@ declare module '@tanstack/react-router' { · 99 unmodified lines | ||
| 351 | 367 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport |
| 352 | 368 | parentRoute: typeof rootRouteImport |
| 353 | 369 | } |
| 370 | + '/{-$orgSlug}/connect/$integrationSlug': { | |
| 371 | + id: '/{-$orgSlug}/connect/$integrationSlug' | |
| 372 | + path: '/{-$orgSlug}/connect/$integrationSlug' | |
| 373 | + fullPath: '/{-$orgSlug}/connect/$integrationSlug' | |
| 374 | + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport | |
| 375 | + parentRoute: typeof rootRouteImport | |
| 376 | + } | |
| 354 | 377 | '/{-$orgSlug}/billing_/plans': { |
| 355 | 378 | id: '/{-$orgSlug}/billing_/plans' |
| 356 | 379 | path: '/{-$orgSlug}/billing/plans' |
| @@ -400,6 +423,8 @@ const rootRouteChildren: RootRouteChildren = { · 43 unmodified lines | ||
| 400 | 423 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| 401 | 424 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, |
| 402 | 425 | Billing_DotplansRoute: Billing_DotplansRoute, |
| 426 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: | |
| 427 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, | |
| 403 | 428 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 404 | 429 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 405 | 430 | ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute, |
| @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines | ||
| 17 | 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 18 | 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 19 | 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 20 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' | |
| 20 | 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' |
| 21 | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' |
| 22 | 23 | |
| @@ -75,6 +76,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 52 unmodified lines | ||
| 75 | 76 | getParentRoute: () => rootRouteImport, |
| 76 | 77 | } as any, |
| 77 | 78 | ) |
| 79 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute = | |
| 80 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update( | |
| 81 | + { | |
| 82 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 83 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 84 | + getParentRoute: () => rootRouteImport, | |
| 85 | + } as any, | |
| 86 | + ) | |
| 78 | 87 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = |
| 79 | 88 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( |
| 80 | 89 | { |
| @@ -98,6 +107,7 @@ export interface FileRoutesByFullPath { · 17 unmodified lines | ||
| 98 | 107 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 99 | 108 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 100 | 109 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 110 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 101 | 111 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 102 | 112 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 103 | 113 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -110,6 +120,7 @@ export interface FileRoutesByTo { · 6 unmodified lines | ||
| 110 | 120 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 111 | 121 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 112 | 122 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 123 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 113 | 124 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 114 | 125 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 115 | 126 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -123,6 +134,7 @@ export interface FileRoutesById { · 7 unmodified lines | ||
| 123 | 134 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 124 | 135 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 125 | 136 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 137 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 126 | 138 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 127 | 139 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 128 | 140 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -137,6 +149,7 @@ export interface FileRouteTypes { · 8 unmodified lines | ||
| 137 | 149 | | '/{-$orgSlug}/toolkits' |
| 138 | 150 | | '/{-$orgSlug}/tools' |
| 139 | 151 | | '/{-$orgSlug}/' |
| 152 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 140 | 153 | | '/{-$orgSlug}/integrations/$namespace' |
| 141 | 154 | | '/{-$orgSlug}/resume/$executionId' |
| 142 | 155 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -149,6 +162,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 149 | 162 | | '/{-$orgSlug}/toolkits' |
| 150 | 163 | | '/{-$orgSlug}/tools' |
| 151 | 164 | | '/{-$orgSlug}' |
| 165 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 152 | 166 | | '/{-$orgSlug}/integrations/$namespace' |
| 153 | 167 | | '/{-$orgSlug}/resume/$executionId' |
| 154 | 168 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -161,6 +175,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 161 | 175 | | '/{-$orgSlug}/toolkits' |
| 162 | 176 | | '/{-$orgSlug}/tools' |
| 163 | 177 | | '/{-$orgSlug}/' |
| 178 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 164 | 179 | | '/{-$orgSlug}/integrations/$namespace' |
| 165 | 180 | | '/{-$orgSlug}/resume/$executionId' |
| 166 | 181 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -174,6 +189,7 @@ export interface RootRouteChildren { · 7 unmodified lines | ||
| 174 | 189 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 175 | 190 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 176 | 191 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 192 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 177 | 193 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 178 | 194 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 179 | 195 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -238,6 +254,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines | ||
| 238 | 254 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport |
| 239 | 255 | parentRoute: typeof rootRouteImport |
| 240 | 256 | } |
| 257 | + '/{-$orgSlug}/connect/$integrationSlug': { | |
| 258 | + id: '/{-$orgSlug}/connect/$integrationSlug' | |
| 259 | + path: '/{-$orgSlug}/connect/$integrationSlug' | |
| 260 | + fullPath: '/{-$orgSlug}/connect/$integrationSlug' | |
| 261 | + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport | |
| 262 | + parentRoute: typeof rootRouteImport | |
| 263 | + } | |
| 241 | 264 | '/{-$orgSlug}/plugins/$pluginId/$': { |
| 242 | 265 | id: '/{-$orgSlug}/plugins/$pluginId/$' |
| 243 | 266 | path: '/{-$orgSlug}/plugins/$pluginId/$' |
| @@ -281,6 +304,8 @@ const rootRouteChildren: RootRouteChildren = { · 37 unmodified lines | ||
| 281 | 304 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 282 | 305 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| 283 | 306 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, |
| 307 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: | |
| 308 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, | |
| 284 | 309 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 285 | 310 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 286 | 311 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: |
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | −import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to"; | |
| 3 | +import { | |
| 4 | + isSafeReturnTo, | |
| 5 | + loginPath, | |
| 6 | + mcpAuthorizeResumeTarget, | |
| 7 | + postLoginTarget, | |
| 8 | + safeReturnTo, | |
| 9 | +} from "./return-to"; | |
| 4 | 10 | |
| 5 | 11 | describe("isSafeReturnTo", () => { |
| 6 | 12 | const safe = [ |
| @@ -73,6 +79,44 @@ describe("mcpAuthorizeResumeTarget", () => { · 66 unmodified lines | ||
| 73 | 79 | }); |
| 74 | 80 | }); |
| 75 | 81 | |
| 82 | +describe("postLoginTarget", () => { | |
| 83 | + // Self-host renders the login page IN PLACE of the requested route without | |
| 84 | + // navigating, so the live location is the only record of where the person was | |
| 85 | + // headed. A connect deep link must survive sign-in on that basis alone. | |
| 86 | + it("returns to the deep link the person actually opened", () => { | |
| 87 | + expect(postLoginTarget({ pathname: "/connect/linear", search: "" })).toBe("/connect/linear"); | |
| 88 | + expect(postLoginTarget({ pathname: "/integrations/sentry", search: "?addAccount=1" })).toBe( | |
| 89 | + "/integrations/sentry?addAccount=1", | |
| 90 | + ); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("lands on the dashboard when signing in from the bare login page", () => { | |
| 94 | + // No deep link to resume, and echoing /login back would re-render the form. | |
| 95 | + expect(postLoginTarget({ pathname: "/login", search: "" })).toBe("/"); | |
| 96 | + }); | |
| 97 | + | |
| 98 | + it("prefers an explicit returnTo over the current location", () => { | |
| 99 | + expect(postLoginTarget({ pathname: "/login", search: "?returnTo=%2Fconnect%2Flinear" })).toBe( | |
| 100 | + "/connect/linear", | |
| 101 | + ); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + it("prefers an MCP authorize resume over everything else", () => { | |
| 105 | + const target = postLoginTarget({ | |
| 106 | + pathname: "/login", | |
| 107 | + search: | |
| 108 | + "?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback", | |
| 109 | + }); | |
| 110 | + expect(target.startsWith("/api/auth/mcp/authorize?")).toBe(true); | |
| 111 | + }); | |
| 112 | + | |
| 113 | + it("never honors an unsafe location", () => { | |
| 114 | + // `safeReturnTo` vets the echoed path too — an app-plane path is not a | |
| 115 | + // place to land a freshly signed-in browser. | |
| 116 | + expect(postLoginTarget({ pathname: "/api/auth/logout", search: "" })).toBe("/"); | |
| 117 | + }); | |
| 118 | +}); | |
| 119 | + | |
| 76 | 120 | describe("loginPath", () => { |
| 77 | 121 | it("omits returnTo for the root", () => { |
| 78 | 122 | expect(loginPath("/")).toBe("/login"); |
| @@ -32,3 +32,32 @@ export const mcpAuthorizeResumeTarget = (search: string): string | null => { · 31 unmodified lines | ||
| 32 | 32 | if (!params.get("client_id") || !params.get("redirect_uri")) return null; |
| 33 | 33 | return `${MCP_AUTHORIZE_PATH}?${params.toString()}`; |
| 34 | 34 | }; |
| 35 | + | |
| 36 | +const LOGIN_PATH = "/login"; | |
| 37 | + | |
| 38 | +/** | |
| 39 | + * Where the self-host login page sends someone after a successful sign-in, in | |
| 40 | + * priority order: | |
| 41 | + * | |
| 42 | + * 1. an interrupted MCP OAuth authorize (the params ARE the request), | |
| 43 | + * 2. an explicit safe `returnTo` (e.g. the integration OAuth callback), | |
| 44 | + * 3. the URL they actually opened, and | |
| 45 | + * 4. the dashboard. | |
| 46 | + * | |
| 47 | + * Step 3 is what makes a deep link like `/connect/linear` survive sign-in here. | |
| 48 | + * Unlike cloud — which redirects to `/login?returnTo=…` — self-host's gate | |
| 49 | + * swaps the login page in WITHOUT navigating, so the address bar still holds | |
| 50 | + * the requested URL and no `returnTo` was ever written. `/login` itself is | |
| 51 | + * excluded so signing in from the bare login page lands on the dashboard rather | |
| 52 | + * than looping back to the form. | |
| 53 | + */ | |
| 54 | +export const postLoginTarget = (location: { | |
| 55 | + readonly pathname: string; | |
| 56 | + readonly search: string; | |
| 57 | +}): string => | |
| 58 | + mcpAuthorizeResumeTarget(location.search) ?? | |
| 59 | + safeReturnTo(new URLSearchParams(location.search).get("returnTo")) ?? | |
| 60 | + (location.pathname === LOGIN_PATH | |
| 61 | + ? null | |
| 62 | + : safeReturnTo(`${location.pathname}${location.search}`)) ?? | |
| 63 | + "/"; | |
| @@ -6,7 +6,7 @@ import { Label } from "@executor-js/react/components/label"; · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | import { authClient } from "./auth-client"; |
| 8 | 8 | import { AuthLayout } from "./auth-layout"; |
| 9 | −import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to"; | |
| 9 | +import { postLoginTarget } from "../src/auth/return-to"; | |
| 10 | 10 | |
| 11 | 11 | // Self-host login: email + password sign-in via Better Auth. On success we |
| 12 | 12 | // reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps |
| @@ -17,15 +17,10 @@ import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to"; · 4 unmodified lines | ||
| 17 | 17 | // redeeming an invite — either the full /join/<code> link, or by entering the |
| 18 | 18 | // code here ("Have an invite code?"), which forwards to the same join page. |
| 19 | 19 | export const LoginPage = () => { |
| 20 | − const search = window.location.search; | |
| 21 | − // Where to go after sign-in: resume an interrupted MCP OAuth authorize if we | |
| 22 | − // arrived from one (Better Auth redirects it here with the OAuth params), | |
| 23 | − // otherwise honor a safe returnTo (e.g. an integration OAuth callback), else | |
| 24 | − // land on the dashboard. | |
| 25 | − const postLogin = | |
| 26 | − mcpAuthorizeResumeTarget(search) ?? | |
| 27 | − safeReturnTo(new URLSearchParams(search).get("returnTo")) ?? | |
| 28 | − "/"; | |
| 20 | + // Where to go after sign-in. The gate renders this page IN PLACE of the | |
| 21 | + // requested route without navigating, so the live location is what carries a | |
| 22 | + // deep link (`/connect/linear`) across sign-in — see `postLoginTarget`. | |
| 23 | + const postLogin = postLoginTarget(window.location); | |
| 29 | 24 | const [mode, setMode] = useState<"signin" | "code">("signin"); |
| 30 | 25 | const [email, setEmail] = useState(""); |
| 31 | 26 | const [password, setPassword] = useState(""); |
| @@ -20,6 +20,7 @@ import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' · 19 unmodified lines | ||
| 20 | 20 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 21 | 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 22 | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 23 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' | |
| 23 | 24 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' |
| 24 | 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' |
| 25 | 26 | |
| @@ -93,6 +94,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 67 unmodified lines | ||
| 93 | 94 | getParentRoute: () => rootRouteImport, |
| 94 | 95 | } as any, |
| 95 | 96 | ) |
| 97 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute = | |
| 98 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update( | |
| 99 | + { | |
| 100 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 101 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 102 | + getParentRoute: () => rootRouteImport, | |
| 103 | + } as any, | |
| 104 | + ) | |
| 96 | 105 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = |
| 97 | 106 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( |
| 98 | 107 | { |
| @@ -119,6 +128,7 @@ export interface FileRoutesByFullPath { · 20 unmodified lines | ||
| 119 | 128 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 120 | 129 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 121 | 130 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 131 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 122 | 132 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 123 | 133 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 124 | 134 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -134,6 +144,7 @@ export interface FileRoutesByTo { · 9 unmodified lines | ||
| 134 | 144 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 135 | 145 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 136 | 146 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 147 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 137 | 148 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 138 | 149 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 139 | 150 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -150,6 +161,7 @@ export interface FileRoutesById { · 10 unmodified lines | ||
| 150 | 161 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 151 | 162 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 152 | 163 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 164 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 153 | 165 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 154 | 166 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 155 | 167 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -167,6 +179,7 @@ export interface FileRouteTypes { · 11 unmodified lines | ||
| 167 | 179 | | '/{-$orgSlug}/toolkits' |
| 168 | 180 | | '/{-$orgSlug}/tools' |
| 169 | 181 | | '/{-$orgSlug}/' |
| 182 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 170 | 183 | | '/{-$orgSlug}/integrations/$namespace' |
| 171 | 184 | | '/{-$orgSlug}/resume/$executionId' |
| 172 | 185 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -182,6 +195,7 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 182 | 195 | | '/{-$orgSlug}/toolkits' |
| 183 | 196 | | '/{-$orgSlug}/tools' |
| 184 | 197 | | '/{-$orgSlug}' |
| 198 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 185 | 199 | | '/{-$orgSlug}/integrations/$namespace' |
| 186 | 200 | | '/{-$orgSlug}/resume/$executionId' |
| 187 | 201 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -197,6 +211,7 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 197 | 211 | | '/{-$orgSlug}/toolkits' |
| 198 | 212 | | '/{-$orgSlug}/tools' |
| 199 | 213 | | '/{-$orgSlug}/' |
| 214 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 200 | 215 | | '/{-$orgSlug}/integrations/$namespace' |
| 201 | 216 | | '/{-$orgSlug}/resume/$executionId' |
| 202 | 217 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -213,6 +228,7 @@ export interface RootRouteChildren { · 10 unmodified lines | ||
| 213 | 228 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 214 | 229 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 215 | 230 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 231 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | |
| 216 | 232 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 217 | 233 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 218 | 234 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -298,6 +314,13 @@ declare module '@tanstack/react-router' { · 79 unmodified lines | ||
| 298 | 314 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport |
| 299 | 315 | parentRoute: typeof rootRouteImport |
| 300 | 316 | } |
| 317 | + '/{-$orgSlug}/connect/$integrationSlug': { | |
| 318 | + id: '/{-$orgSlug}/connect/$integrationSlug' | |
| 319 | + path: '/{-$orgSlug}/connect/$integrationSlug' | |
| 320 | + fullPath: '/{-$orgSlug}/connect/$integrationSlug' | |
| 321 | + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport | |
| 322 | + parentRoute: typeof rootRouteImport | |
| 323 | + } | |
| 301 | 324 | '/{-$orgSlug}/plugins/$pluginId/$': { |
| 302 | 325 | id: '/{-$orgSlug}/plugins/$pluginId/$' |
| 303 | 326 | path: '/{-$orgSlug}/plugins/$pluginId/$' |
| @@ -344,6 +367,8 @@ const rootRouteChildren: RootRouteChildren = { · 40 unmodified lines | ||
| 344 | 367 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 345 | 368 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| 346 | 369 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, |
| 370 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: | |
| 371 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, | |
| 347 | 372 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 348 | 373 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 349 | 374 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: |
| @@ -0,0 +1,168 @@ | ||
| 1 | +// The connect deep link: a customer sends an end user a direct link for ONE | |
| 2 | +// integration ("connect your Linear") instead of pointing them at the dashboard | |
| 3 | +// to hunt for it. `/connect/<integration slug>` resolves the slug against the | |
| 4 | +// tenant's catalog and lands the person in the SAME connect flow the | |
| 5 | +// integration detail page's own button opens. | |
| 6 | +// | |
| 7 | +// The slug — not a plugin id — is the stable identity here: it is what a | |
| 8 | +// customer can write down and template into an email. | |
| 9 | +// | |
| 10 | +// Covered: | |
| 11 | +// 1. Signed in, known slug → the Add connection flow is open on the right | |
| 12 | +// integration, with the auth methods the spec declares | |
| 13 | +// 2. Unknown slug → a clear not-found state, not a blank page or a | |
| 14 | +// crash (an end user WILL mistype these, and links outlive integrations) | |
| 15 | +// 3. Already connected → the deep link shows the existing connection and | |
| 16 | +// still offers to add another account | |
| 17 | +import { randomBytes, randomUUID } from "node:crypto"; | |
| 18 | + | |
| 19 | +import { expect } from "@effect/vitest"; | |
| 20 | +import { Effect } from "effect"; | |
| 21 | +import { AccountHttpApi } from "@executor-js/api"; | |
| 22 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 23 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 24 | +import { | |
| 25 | + AuthTemplateSlug, | |
| 26 | + ConnectionName, | |
| 27 | + IntegrationSlug, | |
| 28 | + ProviderItemId, | |
| 29 | +} from "@executor-js/sdk/shared"; | |
| 30 | + | |
| 31 | +import { scenario } from "../src/scenario"; | |
| 32 | +import { Api, Browser, Target } from "../src/services"; | |
| 33 | + | |
| 34 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 35 | + | |
| 36 | +/** OpenAPI 3 spec with a single operation — enough to be a real catalog entry. */ | |
| 37 | +const greetSpec = (baseUrl: string): string => | |
| 38 | + JSON.stringify({ | |
| 39 | + openapi: "3.0.3", | |
| 40 | + info: { title: "Deep Link API", version: "1.0.0" }, | |
| 41 | + servers: [{ url: baseUrl }], | |
| 42 | + paths: { | |
| 43 | + "/greet": { | |
| 44 | + get: { | |
| 45 | + operationId: "getGreeting", | |
| 46 | + summary: "Return a greeting message", | |
| 47 | + responses: { "200": { description: "A greeting" } }, | |
| 48 | + }, | |
| 49 | + }, | |
| 50 | + }, | |
| 51 | + }); | |
| 52 | + | |
| 53 | +scenario( | |
| 54 | + "Connect · /connect/<slug> lands an end user in that integration's connect flow", | |
| 55 | + { timeout: 180_000 }, | |
| 56 | + Effect.gen(function* () { | |
| 57 | + const target = yield* Target; | |
| 58 | + const browser = yield* Browser; | |
| 59 | + const { client } = yield* Api; | |
| 60 | + | |
| 61 | + const identity = yield* target.newIdentity(); | |
| 62 | + const apiClient = yield* client(api, identity); | |
| 63 | + | |
| 64 | + // Selfhost shares one bootstrap-admin identity, so a unique slug keeps | |
| 65 | + // repeated and parallel runs out of each other's catalogs. | |
| 66 | + const slug = `connect-dl-${randomBytes(4).toString("hex")}`; | |
| 67 | + const specBaseUrl = "http://127.0.0.1:59999"; // never contacted here | |
| 68 | + | |
| 69 | + // Org-scoped hosts canonicalize console URLs onto the active org's slug, so | |
| 70 | + // the deep link may legitimately arrive at either `/connect/x` or | |
| 71 | + // `/<org>/connect/x`. Read the slug the shell will canonicalize onto. | |
| 72 | + const accountClient = yield* client(AccountHttpApi, identity); | |
| 73 | + const me = yield* accountClient.account.me(); | |
| 74 | + const orgSlug = me.organization?.slug ?? null; | |
| 75 | + const detailPath = orgSlug ? `/${orgSlug}/integrations/${slug}` : `/integrations/${slug}`; | |
| 76 | + | |
| 77 | + yield* Effect.ensuring( | |
| 78 | + Effect.gen(function* () { | |
| 79 | + const added = yield* apiClient.openapi.addSpec({ | |
| 80 | + payload: { | |
| 81 | + spec: { kind: "blob", value: greetSpec(specBaseUrl) }, | |
| 82 | + slug, | |
| 83 | + name: "Deep Link API", | |
| 84 | + baseUrl: specBaseUrl, | |
| 85 | + authenticationTemplate: [ | |
| 86 | + { | |
| 87 | + slug: "apiKey", | |
| 88 | + type: "apiKey", | |
| 89 | + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, | |
| 90 | + }, | |
| 91 | + ], | |
| 92 | + }, | |
| 93 | + }); | |
| 94 | + expect(added.slug, "the integration keeps the requested slug").toBe(slug); | |
| 95 | + | |
| 96 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 97 | + await step("Open the connect deep link for the seeded integration", async () => { | |
| 98 | + await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" }); | |
| 99 | + // It forwards into the integration's OWN detail route rather than | |
| 100 | + // rendering a parallel connect UI, carrying the add-account handoff. | |
| 101 | + await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); | |
| 102 | + expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1"); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + await step("The Add connection flow is open for that integration", async () => { | |
| 106 | + await page | |
| 107 | + .getByRole("heading", { name: /Add connection/ }) | |
| 108 | + .waitFor({ timeout: 20_000 }); | |
| 109 | + // The right integration, and the declared auth method is offered — | |
| 110 | + // proof it resolved the slug rather than opening a generic dialog. | |
| 111 | + await page | |
| 112 | + .getByRole("dialog") | |
| 113 | + .getByRole("textbox", { name: "x-api-key" }) | |
| 114 | + .waitFor({ timeout: 20_000 }); | |
| 115 | + }); | |
| 116 | + | |
| 117 | + await step("An unknown slug is a clear not-found, not a blank page", async () => { | |
| 118 | + await page.goto("/connect/zz-no-such-integration", { waitUntil: "networkidle" }); | |
| 119 | + await page | |
| 120 | + .getByText("Unknown integration: zz-no-such-integration") | |
| 121 | + .waitFor({ timeout: 30_000 }); | |
| 122 | + await page.getByRole("link", { name: "Back to integrations" }).waitFor(); | |
| 123 | + }); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + // Already connected: the deep link must show the existing connection | |
| 127 | + // and still let the person add another account. | |
| 128 | + const providers = yield* apiClient.providers.list(); | |
| 129 | + expect(providers.length, "a credential provider is available").toBeGreaterThan(0); | |
| 130 | + yield* apiClient.connections.create({ | |
| 131 | + payload: { | |
| 132 | + owner: "org", | |
| 133 | + name: ConnectionName.make("existing"), | |
| 134 | + integration: IntegrationSlug.make(slug), | |
| 135 | + template: AuthTemplateSlug.make("apiKey"), | |
| 136 | + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, | |
| 137 | + }, | |
| 138 | + }); | |
| 139 | + | |
| 140 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 141 | + await step("The deep link surfaces the existing connection", async () => { | |
| 142 | + await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" }); | |
| 143 | + await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); | |
| 144 | + // The connect flow still opens (this link means "connect me"), and | |
| 145 | + // the already-saved account is visible behind it. | |
| 146 | + await page | |
| 147 | + .getByRole("heading", { name: /Add connection/ }) | |
| 148 | + .waitFor({ timeout: 20_000 }); | |
| 149 | + await page.keyboard.press("Escape"); | |
| 150 | + await page.getByText("existing").first().waitFor({ timeout: 20_000 }); | |
| 151 | + }); | |
| 152 | + }); | |
| 153 | + }), | |
| 154 | + Effect.gen(function* () { | |
| 155 | + yield* apiClient.connections | |
| 156 | + .remove({ | |
| 157 | + params: { | |
| 158 | + owner: "org", | |
| 159 | + integration: IntegrationSlug.make(slug), | |
| 160 | + name: ConnectionName.make("existing"), | |
| 161 | + }, | |
| 162 | + }) | |
| 163 | + .pipe(Effect.ignore); | |
| 164 | + yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | |
| 165 | + }).pipe(Effect.ignore), | |
| 166 | + ); | |
| 167 | + }), | |
| 168 | +); | |
| @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ · 16 unmodified lines | ||
| 17 | 17 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' |
| 18 | 18 | import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' |
| 19 | 19 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' |
| 20 | +import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug' | |
| 20 | 21 | import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$' |
| 21 | 22 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey' |
| 22 | 23 | |
| @@ -67,6 +68,12 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = · 44 unmodified lines | ||
| 67 | 68 | path: '/{-$orgSlug}/integrations/$namespace', |
| 68 | 69 | getParentRoute: () => rootRouteImport, |
| 69 | 70 | } as any) |
| 71 | +const DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute = | |
| 72 | + DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport.update({ | |
| 73 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 74 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 75 | + getParentRoute: () => rootRouteImport, | |
| 76 | + } as any) | |
| 70 | 77 | const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute = |
| 71 | 78 | DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({ |
| 72 | 79 | id: '/{-$orgSlug}/plugins/$pluginId/$', |
| @@ -88,6 +95,7 @@ export interface FileRoutesByFullPath { · 15 unmodified lines | ||
| 88 | 95 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren |
| 89 | 96 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 90 | 97 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 98 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | |
| 91 | 99 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 92 | 100 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 93 | 101 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -100,6 +108,7 @@ export interface FileRoutesByTo { · 6 unmodified lines | ||
| 100 | 108 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren |
| 101 | 109 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 102 | 110 | '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 111 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | |
| 103 | 112 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 104 | 113 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 105 | 114 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -113,6 +122,7 @@ export interface FileRoutesById { · 7 unmodified lines | ||
| 113 | 122 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren |
| 114 | 123 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 115 | 124 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 125 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | |
| 116 | 126 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 117 | 127 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 118 | 128 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| @@ -127,6 +137,7 @@ export interface FileRouteTypes { · 8 unmodified lines | ||
| 127 | 137 | | '/{-$orgSlug}/toolkits' |
| 128 | 138 | | '/{-$orgSlug}/tools' |
| 129 | 139 | | '/{-$orgSlug}/' |
| 140 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 130 | 141 | | '/{-$orgSlug}/integrations/$namespace' |
| 131 | 142 | | '/{-$orgSlug}/resume/$executionId' |
| 132 | 143 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -139,6 +150,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 139 | 150 | | '/{-$orgSlug}/toolkits' |
| 140 | 151 | | '/{-$orgSlug}/tools' |
| 141 | 152 | | '/{-$orgSlug}' |
| 153 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 142 | 154 | | '/{-$orgSlug}/integrations/$namespace' |
| 143 | 155 | | '/{-$orgSlug}/resume/$executionId' |
| 144 | 156 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -151,6 +163,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 151 | 163 | | '/{-$orgSlug}/toolkits' |
| 152 | 164 | | '/{-$orgSlug}/tools' |
| 153 | 165 | | '/{-$orgSlug}/' |
| 166 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 154 | 167 | | '/{-$orgSlug}/integrations/$namespace' |
| 155 | 168 | | '/{-$orgSlug}/resume/$executionId' |
| 156 | 169 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -164,6 +177,7 @@ export interface RootRouteChildren { · 7 unmodified lines | ||
| 164 | 177 | DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren |
| 165 | 178 | DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 166 | 179 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 180 | + DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | |
| 167 | 181 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 168 | 182 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 169 | 183 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -228,6 +242,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines | ||
| 228 | 242 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport |
| 229 | 243 | parentRoute: typeof rootRouteImport |
| 230 | 244 | } |
| 245 | + '/{-$orgSlug}/connect/$integrationSlug': { | |
| 246 | + id: '/{-$orgSlug}/connect/$integrationSlug' | |
| 247 | + path: '/{-$orgSlug}/connect/$integrationSlug' | |
| 248 | + fullPath: '/{-$orgSlug}/connect/$integrationSlug' | |
| 249 | + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport | |
| 250 | + parentRoute: typeof rootRouteImport | |
| 251 | + } | |
| 231 | 252 | '/{-$orgSlug}/plugins/$pluginId/$': { |
| 232 | 253 | id: '/{-$orgSlug}/plugins/$pluginId/$' |
| 233 | 254 | path: '/{-$orgSlug}/plugins/$pluginId/$' |
| @@ -270,6 +291,8 @@ const rootRouteChildren: RootRouteChildren = { · 36 unmodified lines | ||
| 270 | 291 | DotDotDotDotDotDotReactSrcRoutesToolsRoute, |
| 271 | 292 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: |
| 272 | 293 | DotDotDotDotDotDotReactSrcRoutesIndexRoute, |
| 294 | + DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: | |
| 295 | + DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute, | |
| 273 | 296 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 274 | 297 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 275 | 298 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: |
| @@ -57,7 +57,20 @@ describe("isValidOrgSlug", () => { · 56 unmodified lines | ||
| 57 | 57 | }); |
| 58 | 58 | |
| 59 | 59 | it("reserves the segments routing depends on", () => { |
| 60 | − for (const critical of ["api", "mcp", "integrations", "policies", "login", "cdn-cgi"]) { | |
| 60 | + for (const critical of [ | |
| 61 | + "api", | |
| 62 | + "mcp", | |
| 63 | + "integrations", | |
| 64 | + "policies", | |
| 65 | + "login", | |
| 66 | + "cdn-cgi", | |
| 67 | + // The connect deep link's root. Cloud's post-auth callback reads the | |
| 68 | + // first path segment as an org selector, so leaving `connect` claimable | |
| 69 | + // made `/connect/<slug>` look like a request for an org named "connect" | |
| 70 | + // — which suppressed the active-membership fallback and could drop the | |
| 71 | + // deep link into org onboarding after sign-in. | |
| 72 | + "connect", | |
| 73 | + ]) { | |
| 61 | 74 | expect(RESERVED_ORG_SLUGS.has(critical), critical).toBe(true); |
| 62 | 75 | } |
| 63 | 76 | }); |
| @@ -17,8 +17,8 @@ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,47}$/; · 16 unmodified lines | ||
| 17 | 17 | * predictably will): |
| 18 | 18 | * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost |
| 19 | 19 | * envelope, cloudflare `run_worker_first`) |
| 20 | − * - Console routes: integrations, policies, secrets, tools, resume, | |
| 21 | − * plugins (the shared contract), plus host extras: | |
| 20 | + * - Console routes: connect, integrations, policies, secrets, tools, | |
| 21 | + * resume, plugins (the shared contract), plus host extras: | |
| 22 | 22 | * api-keys, org, billing, create-org, setup-mcp (cloud), |
| 23 | 23 | * admin, join, docs (selfhost), login (Better Auth |
| 24 | 24 | * `mcp({ loginPage })` + cloud/selfhost login UX) |
| @@ -41,6 +41,7 @@ export const RESERVED_ORG_SLUGS: ReadonlySet<string> = new Set([ · 16 unmodified lines | ||
| 41 | 41 | "mcp", |
| 42 | 42 | ".well-known", |
| 43 | 43 | // console + host routes |
| 44 | + "connect", | |
| 44 | 45 | "integrations", |
| 45 | 46 | "policies", |
| 46 | 47 | "secrets", |
| @@ -34,6 +34,7 @@ export const ORG_SLUG_SEGMENT = "{-$orgSlug}"; · 33 unmodified lines | ||
| 34 | 34 | * are these paths prefixed with `/{-$orgSlug}`. */ |
| 35 | 35 | export const CONSOLE_ROUTE_PATHS = [ |
| 36 | 36 | "/", |
| 37 | + "/connect/$integrationSlug", | |
| 37 | 38 | "/integrations/$namespace", |
| 38 | 39 | "/integrations/add/$pluginKey", |
| 39 | 40 | "/policies", |
| @@ -62,6 +63,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 22 unmodified lines | ||
| 62 | 63 | const file = (name: string): string => `${options.dir}/${name}`; |
| 63 | 64 | const entries: ReadonlyArray<readonly [ConsoleRoutePath, VirtualRouteNode]> = [ |
| 64 | 65 | ["/", index(file("index.tsx"))], |
| 66 | + [ | |
| 67 | + "/connect/$integrationSlug", | |
| 68 | + route("/connect/$integrationSlug", file("connect.$integrationSlug.tsx")), | |
| 69 | + ], | |
| 65 | 70 | [ |
| 66 | 71 | "/integrations/$namespace", |
| 67 | 72 | route("/integrations/$namespace", file("integrations.$namespace.tsx")), |
| @@ -0,0 +1,109 @@ | ||
| 1 | +import { useEffect } from "react"; | |
| 2 | +import { Link, useNavigate } from "@tanstack/react-router"; | |
| 3 | +import { useAtomValue, useAtomRefresh } from "@effect/atom-react"; | |
| 4 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 5 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 6 | + | |
| 7 | +import { integrationAtom, integrationsOptimisticAtom } from "../api/atoms"; | |
| 8 | +import { ErrorState } from "../components/error-state"; | |
| 9 | +import { Skeleton } from "../components/skeleton"; | |
| 10 | +import { isAsyncResultLoading } from "../lib/async-result"; | |
| 11 | +import { useExecutorDocumentTitle } from "../lib/document-title"; | |
| 12 | + | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | +// `/connect/<integration slug>` — the stable, shareable deep link that drops a | |
| 15 | +// person straight into the connect flow for ONE integration ("connect your | |
| 16 | +// Linear") instead of the dashboard. | |
| 17 | +// | |
| 18 | +// This page owns no connect UI of its own. It resolves the slug against the | |
| 19 | +// tenant's catalog and forwards into the integration detail page's existing | |
| 20 | +// account handoff (`?addAccount=1`), which is the same surface the detail | |
| 21 | +// page's own "Add connection" button opens — multi-method tab strips, OAuth | |
| 22 | +// popups, existing-connection lists and "add another account" all come for | |
| 23 | +// free, and stay correct as that flow evolves. | |
| 24 | +// | |
| 25 | +// Deliberately keyed on the INTEGRATION SLUG, not a plugin id: the slug is the | |
| 26 | +// catalog identity a customer can write down, whereas plugin routes are on | |
| 27 | +// their way out. The redirect is `replace: true` so the deep link does not | |
| 28 | +// linger in history and Back leaves the connect flow rather than bouncing. | |
| 29 | +// --------------------------------------------------------------------------- | |
| 30 | + | |
| 31 | +export function ConnectIntegrationPage(props: { readonly integrationSlug: string }) { | |
| 32 | + const { integrationSlug } = props; | |
| 33 | + const slug = IntegrationSlug.make(integrationSlug); | |
| 34 | + const integration = useAtomValue(integrationAtom(slug)); | |
| 35 | + const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); | |
| 36 | + const navigate = useNavigate(); | |
| 37 | + | |
| 38 | + useExecutorDocumentTitle("Connect"); | |
| 39 | + | |
| 40 | + // The catalog read is a client-side find over the integrations list, so an | |
| 41 | + // unknown slug resolves to Success(null) rather than a failure — the missing | |
| 42 | + // case is a not-found state, and only a genuine fetch failure is an error. | |
| 43 | + const resolved = AsyncResult.isSuccess(integration) ? integration.value : null; | |
| 44 | + const loading = isAsyncResultLoading(integration); | |
| 45 | + const failed = AsyncResult.isFailure(integration); | |
| 46 | + const missing = !loading && !failed && resolved === null; | |
| 47 | + | |
| 48 | + useEffect(() => { | |
| 49 | + if (resolved === null) return; | |
| 50 | + void navigate({ | |
| 51 | + to: "/{-$orgSlug}/integrations/$namespace", | |
| 52 | + params: { namespace: String(resolved.slug) }, | |
| 53 | + // The NUMBER 1, so the router serializes the canonical `?addAccount=1` | |
| 54 | + // that agent-generated handoff links already use (a string would be | |
| 55 | + // quoted as `%221%22`). | |
| 56 | + search: { tab: "accounts", addAccount: 1 }, | |
| 57 | + replace: true, | |
| 58 | + }); | |
| 59 | + }, [resolved, navigate]); | |
| 60 | + | |
| 61 | + if (failed) { | |
| 62 | + return ( | |
| 63 | + <ConnectStateFrame> | |
| 64 | + <ErrorState message="Failed to load integrations" onRetry={refreshIntegrations} /> | |
| 65 | + </ConnectStateFrame> | |
| 66 | + ); | |
| 67 | + } | |
| 68 | + | |
| 69 | + if (missing) { | |
| 70 | + return ( | |
| 71 | + <ConnectStateFrame> | |
| 72 | + <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> | |
| 73 | + <p className="mb-1 text-sm font-medium text-foreground/70"> | |
| 74 | + Unknown integration: {integrationSlug} | |
| 75 | + </p> | |
| 76 | + <p className="mb-5 text-xs text-muted-foreground"> | |
| 77 | + This integration is not in your workspace. | |
| 78 | + </p> | |
| 79 | + <Link | |
| 80 | + to="/{-$orgSlug}" | |
| 81 | + className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" | |
| 82 | + > | |
| 83 | + Back to integrations | |
| 84 | + </Link> | |
| 85 | + </div> | |
| 86 | + </ConnectStateFrame> | |
| 87 | + ); | |
| 88 | + } | |
| 89 | + | |
| 90 | + // Loading, or resolved and mid-redirect: the same quiet placeholder, so the | |
| 91 | + // hand-off never flashes an empty frame between catalog load and navigation. | |
| 92 | + return ( | |
| 93 | + <ConnectStateFrame> | |
| 94 | + <div className="space-y-3"> | |
| 95 | + <Skeleton className="h-8 w-56" /> | |
| 96 | + <Skeleton className="h-4 w-80" /> | |
| 97 | + <Skeleton className="h-32 w-full" /> | |
| 98 | + </div> | |
| 99 | + </ConnectStateFrame> | |
| 100 | + ); | |
| 101 | +} | |
| 102 | + | |
| 103 | +function ConnectStateFrame(props: { readonly children: React.ReactNode }) { | |
| 104 | + return ( | |
| 105 | + <div className="relative min-h-0 flex-1 overflow-y-auto"> | |
| 106 | + <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">{props.children}</div> | |
| 107 | + </div> | |
| 108 | + ); | |
| 109 | +} | |
| @@ -60,6 +60,10 @@ type ToolRow = { · 59 unmodified lines | ||
| 60 | 60 | export function IntegrationDetailPage(props: { |
| 61 | 61 | namespace: string; |
| 62 | 62 | tab?: IntegrationDetailSearchTab; |
| 63 | + /** Route-validated `addAccount=1`: open the add-connection flow on arrival. | |
| 64 | + * Set by the `/connect/<slug>` deep link, which navigates client-side and so | |
| 65 | + * cannot rely on the raw location search below. */ | |
| 66 | + addAccount?: boolean; | |
| 63 | 67 | }) { |
| 64 | 68 | const { namespace } = props; |
| 65 | 69 | const slug = IntegrationSlug.make(namespace); |
| @@ -122,9 +126,11 @@ export function IntegrationDetailPage(props: { · 56 unmodified lines | ||
| 122 | 126 | const canRefresh = integrationData?.canRefresh ?? false; |
| 123 | 127 | const canRemove = integrationData?.canRemove ?? false; |
| 124 | 128 | const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => { |
| 125 | − if (locationSearch.length === 0) return null; | |
| 126 | 129 | const search = new URLSearchParams(locationSearch); |
| 127 | − if (search.get("addAccount") !== "1") return null; | |
| 130 | + // The route-validated flag and the raw `addAccount=1` are the same request; | |
| 131 | + // either one opens the flow. The extra prefill fields below are read from | |
| 132 | + // the raw search, which is populated on the full page loads that carry them. | |
| 133 | + if (!props.addAccount && search.get("addAccount") !== "1") return null; | |
| 128 | 134 | const owner = search.get("owner"); |
| 129 | 135 | const template = search.get("template"); |
| 130 | 136 | const label = search.get("label"); |
| @@ -149,13 +155,13 @@ export function IntegrationDetailPage(props: { · 18 unmodified lines | ||
| 149 | 155 | }; |
| 150 | 156 | })(); |
| 151 | 157 | return { |
| 152 | − key: locationSearch, | |
| 158 | + key: `${String(props.addAccount)}:${locationSearch}`, | |
| 153 | 159 | ...(owner === "org" || owner === "user" ? { owner } : {}), |
| 154 | 160 | ...(template != null && template.length > 0 ? { template } : {}), |
| 155 | 161 | ...(label != null && label.length > 0 ? { label } : {}), |
| 156 | 162 | ...(oauthClient !== undefined ? { oauthClient } : {}), |
| 157 | 163 | }; |
| 158 | − }, [locationSearch]); | |
| 164 | + }, [locationSearch, props.addAccount]); | |
| 159 | 165 | const accountHandoff = manualAccountHandoff ?? urlAccountHandoff; |
| 160 | 166 | |
| 161 | 167 | useEffect(() => { |
| @@ -0,0 +1,121 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + createMemoryHistory, | |
| 4 | + createRootRoute, | |
| 5 | + createRoute, | |
| 6 | + createRouter, | |
| 7 | +} from "@tanstack/react-router"; | |
| 8 | + | |
| 9 | +import { CONSOLE_ROUTE_PATHS } from "../console-routes"; | |
| 10 | +import { isAddAccountRequested } from "./integrations.$namespace"; | |
| 11 | + | |
| 12 | +// Routing semantics of the `/connect/<integration slug>` deep link — the URL a | |
| 13 | +// customer hands to an end user ("connect your Linear"). The page component is | |
| 14 | +// exercised end-to-end in e2e/scenarios/connect-deep-link.test.ts; this locks | |
| 15 | +// the URL contract itself: which shapes match, what the params resolve to, and | |
| 16 | +// that the org-slug prefix round-trips. | |
| 17 | + | |
| 18 | +type RouteMatch = { readonly routeId: string; readonly params: Record<string, string> }; | |
| 19 | +type SpikeRouter = { | |
| 20 | + matchRoutes(pathname: string): ReadonlyArray<RouteMatch>; | |
| 21 | + buildLocation(options: unknown): { readonly href: string }; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +const CONNECT_ROUTE_ID = "/{-$orgSlug}/connect/$integrationSlug"; | |
| 25 | + | |
| 26 | +const make = (): SpikeRouter => { | |
| 27 | + const rootRoute = createRootRoute(); | |
| 28 | + const orgScope = createRoute({ getParentRoute: () => rootRoute, path: "/{-$orgSlug}" }); | |
| 29 | + const indexRoute = createRoute({ getParentRoute: () => orgScope, path: "/" }); | |
| 30 | + const connectRoute = createRoute({ | |
| 31 | + getParentRoute: () => orgScope, | |
| 32 | + path: "/connect/$integrationSlug", | |
| 33 | + }); | |
| 34 | + const nsRoute = createRoute({ | |
| 35 | + getParentRoute: () => orgScope, | |
| 36 | + path: "/integrations/$namespace", | |
| 37 | + }); | |
| 38 | + const routeTree = rootRoute.addChildren([ | |
| 39 | + orgScope.addChildren([indexRoute, connectRoute, nsRoute]), | |
| 40 | + ]); | |
| 41 | + // A test-local tree is not the package's registered console tree, so widen | |
| 42 | + // away the global Register types — this asserts runtime semantics. | |
| 43 | + // oxlint-disable-next-line executor/no-double-cast -- boundary: a test-local route tree must escape the package-global router Register types | |
| 44 | + return createRouter({ | |
| 45 | + routeTree, | |
| 46 | + history: createMemoryHistory({ initialEntries: ["/"] }), | |
| 47 | + }) as unknown as SpikeRouter; | |
| 48 | +}; | |
| 49 | + | |
| 50 | +describe("connect deep link", () => { | |
| 51 | + it("is part of the shared console route contract", () => { | |
| 52 | + // Every host mounts the contract, so the deep link exists on cloud, | |
| 53 | + // self-host, cloudflare and local alike. | |
| 54 | + expect(CONSOLE_ROUTE_PATHS).toContain("/connect/$integrationSlug"); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it("matches the bare link owner.com would generate", () => { | |
| 58 | + const matches = make().matchRoutes("/connect/linear"); | |
| 59 | + const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID); | |
| 60 | + expect(match?.params).toEqual({ integrationSlug: "linear" }); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it("matches the org-prefixed form without eating the slug", () => { | |
| 64 | + const matches = make().matchRoutes("/acme/connect/linear"); | |
| 65 | + const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID); | |
| 66 | + expect(match?.params).toEqual({ orgSlug: "acme", integrationSlug: "linear" }); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("keeps slugs that look like other console words intact", () => { | |
| 70 | + // The integration slug is a free-form catalog id; "tools" or "integrations" | |
| 71 | + // as a slug must still land on connect, not on those pages. | |
| 72 | + for (const slug of ["tools", "integrations", "connect"]) { | |
| 73 | + const match = make() | |
| 74 | + .matchRoutes(`/connect/${slug}`) | |
| 75 | + .find((m) => m.routeId === CONNECT_ROUTE_ID); | |
| 76 | + expect(match?.params).toEqual({ integrationSlug: slug }); | |
| 77 | + } | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("builds the link with and without an org slug", () => { | |
| 81 | + const router = make(); | |
| 82 | + expect( | |
| 83 | + router.buildLocation({ to: CONNECT_ROUTE_ID, params: { integrationSlug: "linear" } }).href, | |
| 84 | + ).toBe("/connect/linear"); | |
| 85 | + expect( | |
| 86 | + router.buildLocation({ | |
| 87 | + to: CONNECT_ROUTE_ID, | |
| 88 | + params: { orgSlug: "acme", integrationSlug: "linear" }, | |
| 89 | + }).href, | |
| 90 | + ).toBe("/acme/connect/linear"); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("forwards into the integration's existing connect flow", () => { | |
| 94 | + // The page redirects here rather than rendering its own connect UI; the | |
| 95 | + // handoff is carried as validated search so it survives client navigation. | |
| 96 | + // The number 1 keeps the canonical `?addAccount=1` that agent-generated | |
| 97 | + // handoff links already use — a string would serialize as `%221%22`. | |
| 98 | + const href = make().buildLocation({ | |
| 99 | + to: "/{-$orgSlug}/integrations/$namespace", | |
| 100 | + params: { namespace: "linear" }, | |
| 101 | + search: { tab: "accounts", addAccount: 1 }, | |
| 102 | + }).href; | |
| 103 | + expect(href).toBe("/integrations/linear?tab=accounts&addAccount=1"); | |
| 104 | + }); | |
| 105 | +}); | |
| 106 | + | |
| 107 | +describe("addAccount flag", () => { | |
| 108 | + it("accepts every spelling the two link sources produce", () => { | |
| 109 | + // A full page load parses `?addAccount=1` to the number; a client-side | |
| 110 | + // navigation may carry the string. Both mean "open the connect flow". | |
| 111 | + expect(isAddAccountRequested(1)).toBe(true); | |
| 112 | + expect(isAddAccountRequested("1")).toBe(true); | |
| 113 | + expect(isAddAccountRequested(true)).toBe(true); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + it("ignores anything else instead of failing the page", () => { | |
| 117 | + for (const value of [undefined, null, 0, "0", "", "banana", {}]) { | |
| 118 | + expect(isAddAccountRequested(value)).toBe(false); | |
| 119 | + } | |
| 120 | + }); | |
| 121 | +}); | |
| @@ -0,0 +1,10 @@ | ||
| 1 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | + | |
| 3 | +import { ConnectIntegrationPage } from "../pages/connect-integration"; | |
| 4 | + | |
| 5 | +export const Route = createFileRoute("/{-$orgSlug}/connect/$integrationSlug")({ | |
| 6 | + component: () => { | |
| 7 | + const { integrationSlug } = Route.useParams(); | |
| 8 | + return <ConnectIntegrationPage integrationSlug={integrationSlug} />; | |
| 9 | + }, | |
| 10 | +}); | |
| @@ -4,21 +4,40 @@ import { createFileRoute } from "@tanstack/react-router"; · 3 unmodified lines | ||
| 4 | 4 | import { IntegrationDetailPage } from "../pages/integration-detail"; |
| 5 | 5 | import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs"; |
| 6 | 6 | |
| 7 | +// `addAccount=1` opens the add-connection flow on arrival. It is declared here | |
| 8 | +// (rather than only read off `window.location`) because unlisted keys do not | |
| 9 | +// survive validation: a CLIENT-SIDE navigation carrying the handoff — the | |
| 10 | +// `/connect/<slug>` deep link — would otherwise have it stripped before the | |
| 11 | +// page could see it. The richer handoff fields stay URL-only; they arrive on | |
| 12 | +// full page loads from agent-generated links. | |
| 13 | +// | |
| 14 | +// Typed as unknown and coerced, not as a literal, for two reasons. A URL flag | |
| 15 | +// must never hard-fail a page: a hand-edited `?addAccount=banana` should be | |
| 16 | +// ignored, not rendered as a validation error. And the router's search | |
| 17 | +// serializer quotes a string "1" (`addAccount=%221%22`) while leaving the | |
| 18 | +// number 1 bare, so accepting both is what keeps ONE canonical URL — | |
| 19 | +// `?addAccount=1` — shared by deep links and agent-generated handoff links. | |
| 7 | 20 | const SearchParams = Schema.toStandardSchemaV1( |
| 8 | 21 | Schema.Struct({ |
| 9 | 22 | tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])), |
| 23 | + addAccount: Schema.optional(Schema.Unknown), | |
| 10 | 24 | }), |
| 11 | 25 | ); |
| 12 | 26 | |
| 27 | +/** The truthy spellings of the `addAccount` URL flag. */ | |
| 28 | +export const isAddAccountRequested = (value: unknown): boolean => | |
| 29 | + value === 1 || value === "1" || value === true; | |
| 30 | + | |
| 13 | 31 | export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({ |
| 14 | 32 | validateSearch: SearchParams, |
| 15 | 33 | component: () => { |
| 16 | 34 | const { namespace } = Route.useParams(); |
| 17 | − const { tab } = Route.useSearch(); | |
| 35 | + const { tab, addAccount } = Route.useSearch(); | |
| 18 | 36 | return ( |
| 19 | 37 | <IntegrationDetailPage |
| 20 | 38 | namespace={namespace} |
| 21 | 39 | tab={tab as IntegrationDetailSearchTab | undefined} |
| 40 | + addAccount={isAddAccountRequested(addAccount)} | |
| 22 | 41 | /> |
| 23 | 42 | ); |
| 24 | 43 | }, |
| @@ -17,6 +17,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 16 unmodified lines | ||
| 17 | 17 | import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' |
| 18 | 18 | import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' |
| 19 | 19 | import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' |
| 20 | +import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug' | |
| 20 | 21 | import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$' |
| 21 | 22 | import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey' |
| 22 | 23 | |
| @@ -62,6 +63,12 @@ const DotIntegrationsDotnamespaceRoute = · 39 unmodified lines | ||
| 62 | 63 | path: '/{-$orgSlug}/integrations/$namespace', |
| 63 | 64 | getParentRoute: () => rootRouteImport, |
| 64 | 65 | } as any) |
| 66 | +const DotConnectDotintegrationSlugRoute = | |
| 67 | + DotConnectDotintegrationSlugRouteImport.update({ | |
| 68 | + id: '/{-$orgSlug}/connect/$integrationSlug', | |
| 69 | + path: '/{-$orgSlug}/connect/$integrationSlug', | |
| 70 | + getParentRoute: () => rootRouteImport, | |
| 71 | + } as any) | |
| 65 | 72 | const DotPluginsDotpluginIdDotsplatRoute = |
| 66 | 73 | DotPluginsDotpluginIdDotsplatRouteImport.update({ |
| 67 | 74 | id: '/{-$orgSlug}/plugins/$pluginId/$', |
| @@ -81,6 +88,7 @@ export interface FileRoutesByFullPath { · 13 unmodified lines | ||
| 81 | 88 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren |
| 82 | 89 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 83 | 90 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 91 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | |
| 84 | 92 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 85 | 93 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 86 | 94 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| @@ -93,6 +101,7 @@ export interface FileRoutesByTo { · 6 unmodified lines | ||
| 93 | 101 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren |
| 94 | 102 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 95 | 103 | '/{-$orgSlug}': typeof DotIndexRoute |
| 104 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | |
| 96 | 105 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 97 | 106 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 98 | 107 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| @@ -106,6 +115,7 @@ export interface FileRoutesById { · 7 unmodified lines | ||
| 106 | 115 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren |
| 107 | 116 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 108 | 117 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 118 | + '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | |
| 109 | 119 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 110 | 120 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 111 | 121 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| @@ -120,6 +130,7 @@ export interface FileRouteTypes { · 8 unmodified lines | ||
| 120 | 130 | | '/{-$orgSlug}/toolkits' |
| 121 | 131 | | '/{-$orgSlug}/tools' |
| 122 | 132 | | '/{-$orgSlug}/' |
| 133 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 123 | 134 | | '/{-$orgSlug}/integrations/$namespace' |
| 124 | 135 | | '/{-$orgSlug}/resume/$executionId' |
| 125 | 136 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -132,6 +143,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 132 | 143 | | '/{-$orgSlug}/toolkits' |
| 133 | 144 | | '/{-$orgSlug}/tools' |
| 134 | 145 | | '/{-$orgSlug}' |
| 146 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 135 | 147 | | '/{-$orgSlug}/integrations/$namespace' |
| 136 | 148 | | '/{-$orgSlug}/resume/$executionId' |
| 137 | 149 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -144,6 +156,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 144 | 156 | | '/{-$orgSlug}/toolkits' |
| 145 | 157 | | '/{-$orgSlug}/tools' |
| 146 | 158 | | '/{-$orgSlug}/' |
| 159 | + | '/{-$orgSlug}/connect/$integrationSlug' | |
| 147 | 160 | | '/{-$orgSlug}/integrations/$namespace' |
| 148 | 161 | | '/{-$orgSlug}/resume/$executionId' |
| 149 | 162 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| @@ -157,6 +170,7 @@ export interface RootRouteChildren { · 7 unmodified lines | ||
| 157 | 170 | DotToolkitsRoute: typeof DotToolkitsRouteWithChildren |
| 158 | 171 | DotToolsRoute: typeof DotToolsRoute |
| 159 | 172 | DotIndexRoute: typeof DotIndexRoute |
| 173 | + DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute | |
| 160 | 174 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute |
| 161 | 175 | DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute |
| 162 | 176 | DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute |
| @@ -221,6 +235,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines | ||
| 221 | 235 | preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport |
| 222 | 236 | parentRoute: typeof rootRouteImport |
| 223 | 237 | } |
| 238 | + '/{-$orgSlug}/connect/$integrationSlug': { | |
| 239 | + id: '/{-$orgSlug}/connect/$integrationSlug' | |
| 240 | + path: '/{-$orgSlug}/connect/$integrationSlug' | |
| 241 | + fullPath: '/{-$orgSlug}/connect/$integrationSlug' | |
| 242 | + preLoaderRoute: typeof DotConnectDotintegrationSlugRouteImport | |
| 243 | + parentRoute: typeof rootRouteImport | |
| 244 | + } | |
| 224 | 245 | '/{-$orgSlug}/plugins/$pluginId/$': { |
| 225 | 246 | id: '/{-$orgSlug}/plugins/$pluginId/$' |
| 226 | 247 | path: '/{-$orgSlug}/plugins/$pluginId/$' |
| @@ -256,6 +277,7 @@ const rootRouteChildren: RootRouteChildren = { · 29 unmodified lines | ||
| 256 | 277 | DotToolkitsRoute: DotToolkitsRouteWithChildren, |
| 257 | 278 | DotToolsRoute: DotToolsRoute, |
| 258 | 279 | DotIndexRoute: DotIndexRoute, |
| 280 | + DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute, | |
| 259 | 281 | DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, |
| 260 | 282 | DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute, |
| 261 | 283 | DotIntegrationsDotaddDotpluginKeyRoute: |