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).
Showing the changes to packages (10 of 17 files). Show all
Unified · Split
| @@ -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: |