The console URL's org slug is now the request scope on the client too: both API clients (core + account) read the slug from window.location at request time and send it as the x-executor-organization header, so every tab's requests are scoped to the org its own URL names — independent of the single shared session cookie. With requests URL-scoped, the cookie-based switch machinery is dead weight: - delete the switchOrganization endpoint/handler/atom; the org switcher and create-org just navigate to /<slug> (the session already authenticates the user to all their orgs, so there is nothing to switch server-side). - delete ForeignOrgSlug; a slug the caller can't access now resolves to a null org via /account/me and the shell 404s, no switch-into-org dance. - simplify OrgSlugGate to canonicalize a bare URL onto the active slug and otherwise render through (a slug in the URL is already the scope). Flips the multi-tab scenario from reproducing the cookie-steal corruption to asserting per-tab independence, and reframes the foreign-slug scenario around URL scoping (no session switch; the cookie is never rewritten).
Unified · Split
| @@ -34,10 +34,6 @@ const AuthOrganizationsResponse = Schema.Struct({ · 33 unmodified lines | |||
| 34 | activeOrganizationId: Schema.NullOr(Schema.String), | 34 | activeOrganizationId: Schema.NullOr(Schema.String), |
| 35 | }); | 35 | }); |
| 36 | 36 | ||
| 37 | const SwitchOrganizationBody = Schema.Struct({ | ||
| 38 | organizationId: Schema.String, | ||
| 39 | }); | ||
| 40 | |||
| 41 | const CreateOrganizationBody = Schema.Struct({ | 37 | const CreateOrganizationBody = Schema.Struct({ |
| 42 | name: Schema.String, | 38 | name: Schema.String, |
| 43 | }); | 39 | }); |
| @@ -143,7 +139,6 @@ export const AUTH_PATHS = { · 99 unmodified lines | |||
| 143 | login: "/api/auth/login", | 139 | login: "/api/auth/login", |
| 144 | logout: "/api/auth/logout", | 140 | logout: "/api/auth/logout", |
| 145 | callback: "/api/auth/callback", | 141 | callback: "/api/auth/callback", |
| 146 | switchOrganization: "/api/auth/switch-organization", | ||
| 147 | } as const; | 142 | } as const; |
| 148 | 143 | ||
| 149 | const AuthErrors = [UserStoreError, WorkOSError] as const; | 144 | const AuthErrors = [UserStoreError, WorkOSError] as const; |
| @@ -178,12 +173,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 28 unmodified lines | |||
| 178 | error: WorkOSError, | 173 | error: WorkOSError, |
| 179 | }), | 174 | }), |
| 180 | ) | 175 | ) |
| 181 | .add( | ||
| 182 | HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", { | ||
| 183 | payload: SwitchOrganizationBody, | ||
| 184 | error: WorkOSError, | ||
| 185 | }), | ||
| 186 | ) | ||
| 187 | .add( | 176 | .add( |
| 188 | HttpApiEndpoint.post("createOrganization", "/auth/create-organization", { | 177 | HttpApiEndpoint.post("createOrganization", "/auth/create-organization", { |
| 189 | payload: CreateOrganizationBody, | 178 | payload: CreateOrganizationBody, |
| @@ -301,20 +301,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 300 unmodified lines | |||
| 301 | }; | 301 | }; |
| 302 | }), | 302 | }), |
| 303 | ) | 303 | ) |
| 304 | .handle("switchOrganization", ({ payload }) => | ||
| 305 | Effect.gen(function* () { | ||
| 306 | const workos = yield* WorkOSClient; | ||
| 307 | const session = yield* SessionContext; | ||
| 308 | |||
| 309 | const refreshed = yield* workos.refreshSession( | ||
| 310 | session.sealedSession, | ||
| 311 | payload.organizationId, | ||
| 312 | ); | ||
| 313 | if (refreshed) { | ||
| 314 | (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); | ||
| 315 | } | ||
| 316 | }), | ||
| 317 | ) | ||
| 318 | .handle("createOrganization", ({ payload }) => | 304 | .handle("createOrganization", ({ payload }) => |
| 319 | Effect.gen(function* () { | 305 | Effect.gen(function* () { |
| 320 | const workos = yield* WorkOSClient; | 306 | const workos = yield* WorkOSClient; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | createRootRoute, | 7 | createRootRoute, |
| 8 | useLocation, | 8 | useLocation, |
| 9 | useNavigate, | 9 | useNavigate, |
| 10 | useParams, | ||
| 10 | } from "@tanstack/react-router"; | 11 | } from "@tanstack/react-router"; |
| 11 | import { AutumnProvider } from "autumn-js/react"; | 12 | import { AutumnProvider } from "autumn-js/react"; |
| 12 | import posthog from "posthog-js"; | 13 | import posthog from "posthog-js"; |
| @@ -23,7 +24,6 @@ import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; · 10 unmodified lines | |||
| 23 | import { AuthProvider, useAuth } from "../web/auth"; | 24 | import { AuthProvider, useAuth } from "../web/auth"; |
| 24 | import { loginPath } from "../auth/return-to"; | 25 | import { loginPath } from "../auth/return-to"; |
| 25 | import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths"; | 26 | import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths"; |
| 26 | import { ForeignOrgSlug } from "../web/components/foreign-org-slug"; | ||
| 27 | import { SupportOptions } from "../web/components/support-options"; | 27 | import { SupportOptions } from "../web/components/support-options"; |
| 28 | import { Shell } from "../web/shell"; | 28 | import { Shell } from "../web/shell"; |
| 29 | import appCss from "@executor-js/react/globals.css?url"; | 29 | import appCss from "@executor-js/react/globals.css?url"; |
| @@ -209,13 +209,20 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 179 unmodified lines | |||
| 209 | const navigate = useNavigate(); | 209 | const navigate = useNavigate(); |
| 210 | const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname); | 210 | const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname); |
| 211 | const isPublicRoute = PUBLIC_PATHS.has(location.pathname); | 211 | const isPublicRoute = PUBLIC_PATHS.has(location.pathname); |
| 212 | // The org the URL names (the `{-$orgSlug}` segment), if any. `/account/me` | ||
| 213 | // is scoped to it, so `auth.organization` IS this org when the caller is a | ||
| 214 | // member — and `null` when the URL names an org they can't access. | ||
| 215 | const urlOrgSlug = (useParams({ strict: false }) as { orgSlug?: string }).orgSlug; | ||
| 212 | 216 | ||
| 213 | // The SSR gate already bounced fresh org-less document requests to | 217 | // The SSR gate already bounced fresh org-less document requests to |
| 214 | // /create-org; this catches the MID-SESSION transitions (org deleted, | 218 | // /create-org; this catches the MID-SESSION transitions (org deleted, |
| 215 | // membership revoked → /account/me now reports no org). | 219 | // membership revoked → /account/me now reports no org). Only for BARE paths: |
| 220 | // an org-less result on a slugged URL is a wrong address (404 below), not a | ||
| 221 | // reason to send the user to onboarding. | ||
| 216 | const needsOrgRedirect = | 222 | const needsOrgRedirect = |
| 217 | auth.status === "authenticated" && | 223 | auth.status === "authenticated" && |
| 218 | auth.organization == null && | 224 | auth.organization == null && |
| 225 | !urlOrgSlug && | ||
| 219 | !isOnboardingRoute && | 226 | !isOnboardingRoute && |
| 220 | !isPublicRoute; | 227 | !isPublicRoute; |
| 221 | 228 | ||
| @@ -254,7 +261,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 32 unmodified lines | |||
| 254 | } | 261 | } |
| 255 | 262 | ||
| 256 | if (auth.organization == null) { | 263 | if (auth.organization == null) { |
| 257 | return <BlankScreen />; | 264 | // A URL naming an org this session can't access (`/account/me` returned no |
| 265 | // org for its slug) is a wrong address → the route 404, framed by nothing | ||
| 266 | // (the user isn't "in" any org here). A bare path with no org is a new | ||
| 267 | // user — the redirect effect above is taking them to onboarding. | ||
| 268 | return urlOrgSlug ? <NotFoundPage /> : <BlankScreen />; | ||
| 258 | } | 269 | } |
| 259 | 270 | ||
| 260 | // Seed the server connection from the SSR origin so origin-derived UI (the | 271 | // Seed the server connection from the SSR origin so origin-derived UI (the |
| @@ -275,18 +286,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 14 unmodified lines | |||
| 275 | organizationId={auth.organization.id} | 286 | organizationId={auth.organization.id} |
| 276 | organizationSlug={activeSlug} | 287 | organizationSlug={activeSlug} |
| 277 | > | 288 | > |
| 278 | <OrgSlugGate | 289 | {/* The org header scopes every request to the URL's org, so |
| 279 | activeSlug={activeSlug} | 290 | reaching here means the caller is a member of `activeSlug` |
| 280 | // Framed by the real shell: a foreign slug resolves (or | 291 | (a foreign slug already 404'd above). The gate only keeps |
| 281 | // 404s) inside the app chrome, exactly like the route-level | 292 | the URL canonical — bare → /<slug>. */} |
| 282 | // not-found — never a bare full-page state. | 293 | <OrgSlugGate activeSlug={activeSlug}> |
| 283 | foreignSlug={(slug) => ( | ||
| 284 | <> | ||
| 285 | <Shell content={<ForeignOrgSlug slug={slug} />} /> | ||
| 286 | <Toaster /> | ||
| 287 | </> | ||
| 288 | )} | ||
| 289 | > | ||
| 290 | <Shell /> | 294 | <Shell /> |
| 291 | <Toaster /> | 295 | <Toaster /> |
| 292 | </OrgSlugGate> | 296 | </OrgSlugGate> |
| @@ -33,7 +33,6 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( · 32 unmodified lines | |||
| 33 | }), | 33 | }), |
| 34 | ); | 34 | ); |
| 35 | 35 | ||
| 36 | export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization"); | ||
| 37 | export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); | 36 | export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); |
| 38 | 37 | ||
| 39 | export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", { | 38 | export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", { |
| @@ -1,61 +0,0 @@ | |||
| 1 | import { useEffect, useRef, useState, type ReactNode } from "react"; | ||
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | ||
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 6 | import { OrgSlugNotFound } from "@executor-js/react/multiplayer/org-slug-gate"; | ||
| 7 | |||
| 8 | import { organizationsAtom, switchOrganization } from "../auth"; | ||
| 9 | |||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | // Foreign-slug resolution for the cloud org-slug gate: the URL names a slug | ||
| 12 | // that isn't the active org's. Resolve it against the caller's memberships: | ||
| 13 | // - a member of that org (their bookmark, a teammate's shared link) → switch | ||
| 14 | // the session into it and reload, so every atom re-scopes | ||
| 15 | // - anything else → a WRONG ADDRESS → not-found. A slug must never silently | ||
| 16 | // resolve to a different workspace than the URL names, and single-segment | ||
| 17 | // typos (/this-page-does-not-exist) match the slugged index route, so this | ||
| 18 | // IS the app's 404 for them. | ||
| 19 | // Membership comes from the already-cached organizations atom; the matched | ||
| 20 | // case alone performs a side effect, isolated in `SwitchIntoOrg`. | ||
| 21 | // --------------------------------------------------------------------------- | ||
| 22 | |||
| 23 | const Centered = (props: { children?: ReactNode }) => ( | ||
| 24 | <div className="flex min-h-full flex-1 items-center justify-center text-sm text-muted-foreground"> | ||
| 25 | {props.children} | ||
| 26 | </div> | ||
| 27 | ); | ||
| 28 | |||
| 29 | // Switch the session into `organizationId`, then full-reload so the whole app | ||
| 30 | // re-scopes. One-shot: the ref guards React's double-invoke and the render | ||
| 31 | // between resolution and the reload landing. A failed switch means the slug | ||
| 32 | // named an org we can't actually enter — fall back to not-found. | ||
| 33 | function SwitchIntoOrg(props: { readonly organizationId: string }) { | ||
| 34 | const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" }); | ||
| 35 | const [failed, setFailed] = useState(false); | ||
| 36 | const started = useRef(false); | ||
| 37 | |||
| 38 | useEffect(() => { | ||
| 39 | if (started.current) return; | ||
| 40 | started.current = true; | ||
| 41 | void doSwitchOrganization({ | ||
| 42 | payload: { organizationId: props.organizationId }, | ||
| 43 | reactivityKeys: authWriteKeys, | ||
| 44 | }).then((exit) => (Exit.isSuccess(exit) ? window.location.reload() : setFailed(true))); | ||
| 45 | }, [props.organizationId, doSwitchOrganization]); | ||
| 46 | |||
| 47 | return failed ? <OrgSlugNotFound /> : <Centered>Switching organization…</Centered>; | ||
| 48 | } | ||
| 49 | |||
| 50 | export function ForeignOrgSlug(props: { readonly slug: string }) { | ||
| 51 | const organizations = useAtomValue(organizationsAtom); | ||
| 52 | |||
| 53 | return AsyncResult.match(organizations, { | ||
| 54 | onInitial: () => <Centered />, | ||
| 55 | onFailure: () => <OrgSlugNotFound />, | ||
| 56 | onSuccess: ({ value }) => { | ||
| 57 | const match = value.organizations.find((org: { slug: string }) => org.slug === props.slug); | ||
| 58 | return match ? <SwitchIntoOrg organizationId={match.id} /> : <OrgSlugNotFound />; | ||
| 59 | }, | ||
| 60 | }); | ||
| 61 | } | ||
| @@ -1,8 +1,6 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 6 | import { trackEvent } from "@executor-js/react/api/analytics"; | 4 | import { trackEvent } from "@executor-js/react/api/analytics"; |
| 7 | import { Button } from "@executor-js/react/components/button"; | 5 | import { Button } from "@executor-js/react/components/button"; |
| 8 | import { | 6 | import { |
| @@ -23,7 +21,7 @@ import { · 14 unmodified lines | |||
| 23 | DropdownMenuSubTrigger, | 21 | DropdownMenuSubTrigger, |
| 24 | } from "@executor-js/react/components/dropdown-menu"; | 22 | } from "@executor-js/react/components/dropdown-menu"; |
| 25 | import { useAuth } from "../auth"; | 23 | import { useAuth } from "../auth"; |
| 26 | import { organizationsAtom, switchOrganization } from "../auth"; | 24 | import { organizationsAtom } from "../auth"; |
| 27 | import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form"; | 25 | import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form"; |
| 28 | 26 | ||
| 29 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| @@ -52,18 +50,15 @@ function CheckIcon() { · 22 unmodified lines | |||
| 52 | 50 | ||
| 53 | function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) { | 51 | function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) { |
| 54 | const organizations = useAtomValue(organizationsAtom); | 52 | const organizations = useAtomValue(organizationsAtom); |
| 55 | const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" }); | ||
| 56 | 53 | ||
| 57 | const handleSwitch = async (organization: { id: string; slug: string }) => { | 54 | // Switching orgs is now a pure URL navigation: the session authenticates the |
| 55 | // user to ALL their orgs, and the slug in the path scopes every request (the | ||
| 56 | // `x-executor-organization` header). No cookie to rewrite, no server switch | ||
| 57 | // call — just land on the other org's URL root and the whole app re-scopes. | ||
| 58 | const handleSwitch = (organization: { id: string; slug: string }) => { | ||
| 58 | if (organization.id === props.activeOrganizationId) return; | 59 | if (organization.id === props.activeOrganizationId) return; |
| 59 | const exit = await doSwitchOrganization({ | 60 | trackEvent("org_switched", { success: true }); |
| 60 | payload: { organizationId: organization.id }, | 61 | window.location.href = `/${organization.slug}`; |
| 61 | reactivityKeys: authWriteKeys, | ||
| 62 | }); | ||
| 63 | trackEvent("org_switched", { success: Exit.isSuccess(exit) }); | ||
| 64 | // Land on the new org's URL root — a plain reload would keep the OLD | ||
| 65 | // org's slug in the path and the slug gate would switch right back. | ||
| 66 | if (Exit.isSuccess(exit)) window.location.href = `/${organization.slug}`; | ||
| 67 | }; | 62 | }; |
| 68 | 63 | ||
| 69 | return AsyncResult.match(organizations, { | 64 | return AsyncResult.match(organizations, { |
| @@ -80,7 +75,9 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null · 10 unmodified lines | |||
| 80 | <DropdownMenuItem | 75 | <DropdownMenuItem |
| 81 | key={organization.id} | 76 | key={organization.id} |
| 82 | disabled={isActive} | 77 | disabled={isActive} |
| 83 | onClick={() => handleSwitch(organization)} | 78 | onClick={() => { |
| 79 | handleSwitch(organization); | ||
| 80 | }} | ||
| 84 | className="text-xs" | 81 | className="text-xs" |
| 85 | > | 82 | > |
| 86 | <span className="min-w-0 flex-1 truncate">{organization.name}</span> | 83 | <span className="min-w-0 flex-1 truncate">{organization.name}</span> |
| @@ -62,8 +62,8 @@ function AuthenticatedApp() { · 61 unmodified lines | |||
| 62 | const auth = useAuth(); | 62 | const auth = useAuth(); |
| 63 | const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null; | 63 | const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null; |
| 64 | 64 | ||
| 65 | // Single-org instance: any URL slug other than the instance org's (or none) | 65 | // Single-org instance: a bare URL canonicalizes onto the instance org's |
| 66 | // canonicalizes — no foreignSlug handler, there's nothing to switch to. | 66 | // slug. There's only ever one org, so no other slug is reachable. |
| 67 | const gated = ( | 67 | const gated = ( |
| 68 | <> | 68 | <> |
| 69 | <Shell onSignOut={signOut} navItems={defaultShellNavItems} apiKeysTo={null} /> | 69 | <Shell onSignOut={signOut} navItems={defaultShellNavItems} apiKeysTo={null} /> |
| @@ -70,8 +70,8 @@ function AuthenticatedApp() { · 69 unmodified lines | |||
| 70 | const auth = useAuth(); | 70 | const auth = useAuth(); |
| 71 | const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null; | 71 | const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null; |
| 72 | 72 | ||
| 73 | // Single-org instance: any URL slug other than the instance org's (or none) | 73 | // Single-org instance: a bare URL canonicalizes onto the instance org's |
| 74 | // canonicalizes — no foreignSlug handler, there's nothing to switch to. | 74 | // slug. There's only ever one org, so no other slug is reachable. |
| 75 | const gated = ( | 75 | const gated = ( |
| 76 | <> | 76 | <> |
| 77 | <Shell onSignOut={signOut} navItems={selfHostNavItems} /> | 77 | <Shell onSignOut={signOut} navItems={selfHostNavItems} /> |
| @@ -0,0 +1,111 @@ | |||
| 1 | // Cloud-only (browser): two tabs, two orgs, at the same time — independent. | ||
| 2 | // | ||
| 3 | // WorkOS still pins ONE org into the sealed `wos-session` cookie, and the whole | ||
| 4 | // browser shares one cookie jar. Under the OLD cookie-based "active org" model | ||
| 5 | // that made "active organization" a browser-global: two tabs could not be in | ||
| 6 | // two orgs at once, and a switch (or the slug gate's switch-to-honor-the-URL) | ||
| 7 | // silently re-scoped the other tab out from under it. | ||
| 8 | // | ||
| 9 | // The stateless URL model removes that hazard. The slug in the path is the | ||
| 10 | // request scope: every API call carries it (the `x-executor-organization` | ||
| 11 | // header), the server re-checks live membership and resolves data for THAT | ||
| 12 | // org, and the session merely authenticates the user to all their orgs at | ||
| 13 | // once. Nothing writes the cookie on a switch. So this scenario — once the | ||
| 14 | // reproduction of the corruption — now asserts the opposite: each tab's | ||
| 15 | // requests stay scoped to its own URL org, no matter what the other tab does. | ||
| 16 | // | ||
| 17 | // Everything runs through the browser (onboarding + the menu create-org), so | ||
| 18 | // the single-use WorkOS refresh-token chain stays browser-owned and valid. | ||
| 19 | import { expect } from "@effect/vitest"; | ||
| 20 | import { Effect } from "effect"; | ||
| 21 | |||
| 22 | import { scenario } from "../src/scenario"; | ||
| 23 | import { Browser, Target } from "../src/services"; | ||
| 24 | |||
| 25 | scenario( | ||
| 26 | "Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)", | ||
| 27 | {}, | ||
| 28 | Effect.gen(function* () { | ||
| 29 | const target = yield* Target; | ||
| 30 | const browser = yield* Browser; | ||
| 31 | const identity = yield* target.newIdentity({ org: false }); | ||
| 32 | |||
| 33 | yield* browser.session(identity, async ({ page: tab1, step }) => { | ||
| 34 | const slugOf = (page: typeof tab1) => new URL(page.url()).pathname.replace(/^\/|\/.*$/g, ""); | ||
| 35 | |||
| 36 | // The org slug a page's REAL app requests carry — read straight off the | ||
| 37 | // outgoing `x-executor-organization` header, the actual request scope. | ||
| 38 | // This is what makes the two tabs independent; the shared session cookie | ||
| 39 | // is irrelevant to it. | ||
| 40 | const requestOrgSlugOf = async (page: typeof tab1): Promise<string> => { | ||
| 41 | const matching = page.waitForRequest( | ||
| 42 | (request) => | ||
| 43 | request.url().includes("/api/") && | ||
| 44 | request.headers()["x-executor-organization"] !== undefined, | ||
| 45 | { timeout: 15_000 }, | ||
| 46 | ); | ||
| 47 | // Nudge the app to refetch so a fresh scoped request goes out. | ||
| 48 | void page.reload({ waitUntil: "commit" }); | ||
| 49 | return (await matching).headers()["x-executor-organization"]!; | ||
| 50 | }; | ||
| 51 | |||
| 52 | let slugA = ""; | ||
| 53 | let slugB = ""; | ||
| 54 | |||
| 55 | await step("Onboard org A in tab 1", async () => { | ||
| 56 | await tab1.goto("/", { waitUntil: "networkidle" }); | ||
| 57 | await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A"); | ||
| 58 | await tab1.getByRole("button", { name: "Create organization" }).click(); | ||
| 59 | await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); | ||
| 60 | await tab1.getByRole("button", { name: "Continue to app" }).click(); | ||
| 61 | await tab1.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 }); | ||
| 62 | await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 }); | ||
| 63 | slugA = slugOf(tab1); | ||
| 64 | }); | ||
| 65 | |||
| 66 | await step("Create org B from tab 1's account menu — tab 1 is now in B", async () => { | ||
| 67 | await tab1.getByRole("button", { name: /Test User/ }).click(); | ||
| 68 | await tab1.getByRole("menuitem", { name: "Multitab A" }).click(); | ||
| 69 | await tab1 | ||
| 70 | .locator('[data-slot="dropdown-menu-sub-content"]') | ||
| 71 | .getByText("Create organization", { exact: true }) | ||
| 72 | .click(); | ||
| 73 | await tab1.getByText("Add another organization").waitFor(); | ||
| 74 | await tab1.getByPlaceholder("Northwind Labs").fill("Multitab B"); | ||
| 75 | await tab1.getByRole("button", { name: "Create organization" }).click(); | ||
| 76 | await tab1.waitForURL((url) => url.pathname !== `/${slugA}`, { timeout: 30_000 }); | ||
| 77 | await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 }); | ||
| 78 | slugB = slugOf(tab1); | ||
| 79 | }); | ||
| 80 | expect(slugB, "the two orgs have distinct slugs").not.toBe(slugA); | ||
| 81 | |||
| 82 | // A second tab in the SAME context — shares tab 1's cookie jar. | ||
| 83 | const tab2 = await tab1.context().newPage(); | ||
| 84 | |||
| 85 | await step("Tab 2 opens org A's URL and stays in A — no switch, no reload loop", async () => { | ||
| 86 | await tab2.goto(`/${slugA}/policies`, { waitUntil: "networkidle" }); | ||
| 87 | await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 }); | ||
| 88 | expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe( | ||
| 89 | `/${slugA}/policies`, | ||
| 90 | ); | ||
| 91 | expect(await requestOrgSlugOf(tab2), "tab 2's API requests are scoped to org A").toBe( | ||
| 92 | slugA, | ||
| 93 | ); | ||
| 94 | }); | ||
| 95 | |||
| 96 | await step("Tab 1 is untouched: still org B, and its requests still scope to B", async () => { | ||
| 97 | expect(new URL(tab1.url()).pathname, "tab 1's URL still says org B").toBe(`/${slugB}`); | ||
| 98 | expect( | ||
| 99 | await tab1.getByRole("button", { name: /Multitab B/ }).isVisible(), | ||
| 100 | "tab 1's sidebar still shows org B", | ||
| 101 | ).toBe(true); | ||
| 102 | // The crux: tab 2 opening org A did NOT re-scope tab 1. Tab 1's own | ||
| 103 | // requests still carry org B's slug — the URL is the scope, not a | ||
| 104 | // shared cookie a sibling tab can steal. | ||
| 105 | expect(await requestOrgSlugOf(tab1), "tab 1's API requests stay scoped to org B").toBe( | ||
| 106 | slugB, | ||
| 107 | ); | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | }), | ||
| 111 | ); | ||
| @@ -1,12 +1,13 @@ | |||
| 1 | // Cloud-only (browser): opening another of YOUR orgs by its slug URL switches | 1 | // Cloud-only (browser): opening another of YOUR orgs by its slug URL just |
| 2 | // the session into it. The org-slug gate's foreign-slug branch (ForeignOrgSlug) | 2 | // works — no switch, no cookie rewrite. The slug in the path is the request |
| 3 | // resolves a URL slug that isn't the active org against the caller's | 3 | // scope (the `x-executor-organization` header), and the session authenticates |
| 4 | // memberships — a match switches + reloads, so a bookmark or a teammate's link | 4 | // the user to ALL their orgs at once, so a bookmark or a teammate's link into a |
| 5 | // into a shared org lands you there even when a different org is active. | 5 | // shared org lands you there regardless of which org the cookie happens to pin. |
| 6 | // | 6 | // |
| 7 | // org-switcher.test.ts covers switching via the account menu; this covers the | 7 | // org-switcher.test.ts covers the account-menu navigation; this covers the URL |
| 8 | // URL path. (Unknown/unauthorized slugs → 404 is covered by | 8 | // path. (Unknown/unauthorized slugs → 404 is covered by |
| 9 | // scenarios/org-slug-routing.test.ts.) | 9 | // scenarios/org-slug-routing.test.ts; two tabs at once by |
| 10 | // org-multitab-cookie.test.ts.) | ||
| 10 | import { expect } from "@effect/vitest"; | 11 | import { expect } from "@effect/vitest"; |
| 11 | import { Effect } from "effect"; | 12 | import { Effect } from "effect"; |
| 12 | 13 | ||
| @@ -16,14 +17,14 @@ import { Browser, Target } from "../src/services"; · 3 unmodified lines | |||
| 16 | const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); | 17 | const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); |
| 17 | 18 | ||
| 18 | scenario( | 19 | scenario( |
| 19 | "Org URLs · opening another of your orgs by slug switches the session into it", | 20 | "Org URLs · opening another of your orgs by slug works without switching the session", |
| 20 | {}, | 21 | {}, |
| 21 | Effect.gen(function* () { | 22 | Effect.gen(function* () { |
| 22 | const target = yield* Target; | 23 | const target = yield* Target; |
| 23 | const browser = yield* Browser; | 24 | const browser = yield* Browser; |
| 24 | 25 | ||
| 25 | // Identity starts in org A. Create org B through the real endpoint, which | 26 | // Identity starts in org A. Create org B through the real endpoint, which |
| 26 | // switches the active session to B and returns its refreshed cookie. | 27 | // returns its refreshed cookie (the session is now pinned to B). |
| 27 | const identity = yield* target.newIdentity(); | 28 | const identity = yield* target.newIdentity(); |
| 28 | const cookie = identity.headers?.cookie ?? ""; | 29 | const cookie = identity.headers?.cookie ?? ""; |
| 29 | 30 | ||
| @@ -42,23 +43,22 @@ scenario( · 12 unmodified lines | |||
| 42 | const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string }; | 43 | const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string }; |
| 43 | const setCookie = createB.headers.get("set-cookie") ?? ""; | 44 | const setCookie = createB.headers.get("set-cookie") ?? ""; |
| 44 | const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1]; | 45 | const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1]; |
| 45 | expect(sessionB, "creating org B refreshed the session into it").toBeTruthy(); | 46 | expect(sessionB, "creating org B pinned the session into it").toBeTruthy(); |
| 46 | 47 | ||
| 47 | // Both orgs' slugs from the session that is now active in B. | 48 | // Both orgs' slugs (the org list is the same whichever org the cookie pins). |
| 48 | const orgs = (yield* Effect.promise(() => | 49 | const orgs = (yield* Effect.promise(() => |
| 49 | fetch(new URL("/api/auth/organizations", target.baseUrl), { | 50 | fetch(new URL("/api/auth/organizations", target.baseUrl), { |
| 50 | headers: { cookie: `wos-session=${sessionB}` }, | 51 | headers: { cookie: `wos-session=${sessionB}` }, |
| 51 | }).then((r) => r.json()), | 52 | }).then((r) => r.json()), |
| 52 | )) as { | 53 | )) as { |
| 53 | organizations: ReadonlyArray<{ name: string; slug: string }>; | 54 | organizations: ReadonlyArray<{ name: string; slug: string }>; |
| 54 | activeOrganizationId: string; | ||
| 55 | }; | 55 | }; |
| 56 | const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug; | 56 | const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug; |
| 57 | expect(slugA, "org A has a slug").toBeTruthy(); | 57 | expect(slugA, "org A has a slug").toBeTruthy(); |
| 58 | expect(orgB.slug, "org B has a slug").toBeTruthy(); | 58 | expect(orgB.slug, "org B has a slug").toBeTruthy(); |
| 59 | expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug); | 59 | expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug); |
| 60 | 60 | ||
| 61 | // Drive the browser as the session that is ACTIVE IN B. | 61 | // Drive the browser as the session pinned to B. |
| 62 | const inB = { | 62 | const inB = { |
| 63 | ...identity, | 63 | ...identity, |
| 64 | headers: { cookie: `wos-session=${sessionB}` }, | 64 | headers: { cookie: `wos-session=${sessionB}` }, |
| @@ -69,15 +69,21 @@ scenario( · 4 unmodified lines | |||
| 69 | await step("Land in org B, then open org A's slug URL directly", async () => { | 69 | await step("Land in org B, then open org A's slug URL directly", async () => { |
| 70 | await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); | 70 | await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); |
| 71 | await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); | 71 | await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); |
| 72 | // Navigate to org A — the gate sees a foreign-but-member slug. | 72 | // Open org A by its slug while the cookie is still pinned to B. |
| 73 | await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" }); | 73 | await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" }); |
| 74 | }); | 74 | }); |
| 75 | 75 | ||
| 76 | await step("The session switches into org A and lands on the slugged URL", async () => { | 76 | await step("Org A's page renders at its slugged URL — the URL is the scope", async () => { |
| 77 | // Reaching org A's policies at its URL is the proof: a switch that | 77 | // Reaching org A's policies at its URL is the proof the request scoped |
| 78 | // failed would render the gate's 404 here, not the Policies page. | 78 | // to A off the URL alone: a model that required the cookie to name A |
| 79 | // would 404 here (the cookie still says B). | ||
| 79 | await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 }); | 80 | await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 }); |
| 80 | await page.getByText("Policies").first().waitFor({ timeout: 30_000 }); | 81 | await page.getByText("Policies").first().waitFor({ timeout: 30_000 }); |
| 82 | // And no switch happened: the session cookie still names org B. | ||
| 83 | const sessionCookie = (await page.context().cookies()) | ||
| 84 | .find((c) => c.name === "wos-session") | ||
| 85 | ?.value.split(";")[0]; | ||
| 86 | expect(sessionCookie, "the session cookie was never rewritten — no switch").toBe(sessionB); | ||
| 81 | }); | 87 | }); |
| 82 | }); | 88 | }); |
| 83 | }), | 89 | }), |
| @@ -4,7 +4,12 @@ import { AccountHttpApi } from "@executor-js/api/client"; · 3 unmodified lines | |||
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | ||
| 6 | import { reportApiClientInfrastructureCause } from "./client"; | 6 | import { reportApiClientInfrastructureCause } from "./client"; |
| 7 | import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection"; | 7 | import { |
| 8 | EXECUTOR_ORG_HEADER, | ||
| 9 | getActiveOrgSlug, | ||
| 10 | getExecutorApiBaseUrl, | ||
| 11 | getExecutorServerAuthorizationHeader, | ||
| 12 | } from "./server-connection"; | ||
| 8 | 13 | ||
| 9 | // --------------------------------------------------------------------------- | 14 | // --------------------------------------------------------------------------- |
| 10 | // Shared account client — the provider-neutral `/account/*` surface. | 15 | // Shared account client — the provider-neutral `/account/*` surface. |
| @@ -25,6 +30,11 @@ const AccountApiClient = AtomHttpApi.Service<"AccountApiClient">()("AccountApiCl · 14 unmodified lines | |||
| 25 | if (authorization) { | 30 | if (authorization) { |
| 26 | next = HttpClientRequest.setHeader(next, "authorization", authorization); | 31 | next = HttpClientRequest.setHeader(next, "authorization", authorization); |
| 27 | } | 32 | } |
| 33 | // Scope to the org the console URL is on (see server-connection). | ||
| 34 | const orgSlug = getActiveOrgSlug(); | ||
| 35 | if (orgSlug) { | ||
| 36 | next = HttpClientRequest.setHeader(next, EXECUTOR_ORG_HEADER, orgSlug); | ||
| 37 | } | ||
| 28 | return next; | 38 | return next; |
| 29 | }), | 39 | }), |
| 30 | transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), | 40 | transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), |
| @@ -11,7 +11,12 @@ import * as Option from "effect/Option"; · 10 unmodified lines | |||
| 11 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 12 | 12 | ||
| 13 | import { reportHandledFrontendError } from "./error-reporting"; | 13 | import { reportHandledFrontendError } from "./error-reporting"; |
| 14 | import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection"; | 14 | import { |
| 15 | EXECUTOR_ORG_HEADER, | ||
| 16 | getActiveOrgSlug, | ||
| 17 | getExecutorApiBaseUrl, | ||
| 18 | getExecutorServerAuthorizationHeader, | ||
| 19 | } from "./server-connection"; | ||
| 15 | 20 | ||
| 16 | const isApiClientInfrastructureCause = (cause: Cause.Cause<unknown>): boolean => | 21 | const isApiClientInfrastructureCause = (cause: Cause.Cause<unknown>): boolean => |
| 17 | Option.match(Cause.findErrorOption(cause), { | 22 | Option.match(Cause.findErrorOption(cause), { |
| @@ -95,6 +100,11 @@ const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorAp · 77 unmodified lines | |||
| 95 | if (authorization) { | 100 | if (authorization) { |
| 96 | next = HttpClientRequest.setHeader(next, "authorization", authorization); | 101 | next = HttpClientRequest.setHeader(next, "authorization", authorization); |
| 97 | } | 102 | } |
| 103 | // Scope the request to the org the console URL is on (see server-connection). | ||
| 104 | const orgSlug = getActiveOrgSlug(); | ||
| 105 | if (orgSlug) { | ||
| 106 | next = HttpClientRequest.setHeader(next, EXECUTOR_ORG_HEADER, orgSlug); | ||
| 107 | } | ||
| 98 | return next; | 108 | return next; |
| 99 | }), | 109 | }), |
| 100 | transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), | 110 | transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), |
| @@ -1,4 +1,5 @@ | |||
| 1 | import * as React from "react"; | 1 | import * as React from "react"; |
| 2 | import { isValidOrgSlug } from "@executor-js/api"; | ||
| 2 | import { | 3 | import { |
| 3 | DEFAULT_EXECUTOR_SERVER_ORIGIN, | 4 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 4 | DEFAULT_EXECUTOR_SERVER_USERNAME, | 5 | DEFAULT_EXECUTOR_SERVER_USERNAME, |
| @@ -84,6 +85,32 @@ const resolveInitialExecutorServerConnection = (): ExecutorServerConnection => { · 79 unmodified lines | |||
| 84 | 85 | ||
| 85 | let activeConnection = resolveInitialExecutorServerConnection(); | 86 | let activeConnection = resolveInitialExecutorServerConnection(); |
| 86 | 87 | ||
| 88 | // --------------------------------------------------------------------------- | ||
| 89 | // Active org selector — the org the console URL is scoped to. | ||
| 90 | // --------------------------------------------------------------------------- | ||
| 91 | // | ||
| 92 | // Org-scoped hosts (cloud) send this on every API request so the server scopes | ||
| 93 | // to the URL's org, not the session's stored one — which is what lets two | ||
| 94 | // browser tabs sit in two different orgs at once (each tab's window.location is | ||
| 95 | // its own). | ||
| 96 | // | ||
| 97 | // Read straight from `window.location` at REQUEST time, not from a | ||
| 98 | // React-synced mirror: the API clients' `transformClient` runs only in the | ||
| 99 | // browser, so this never touches SSR/hydration, and it's always exactly the | ||
| 100 | // current URL with no effect-timing to get wrong. The org is the first path | ||
| 101 | // segment when it's a valid slug; reserved console roots (`/policies`, | ||
| 102 | // `/login`, …) are NOT valid slugs, so a bare path sends no header and the | ||
| 103 | // server falls back to the session org. Hosts without slugs (self-host, | ||
| 104 | // cloudflare) never produce one either. | ||
| 105 | export const EXECUTOR_ORG_HEADER = "x-executor-organization"; | ||
| 106 | |||
| 107 | export const getActiveOrgSlug = (): string | null => { | ||
| 108 | const pathname = globalThis.window?.location?.pathname; | ||
| 109 | if (!pathname) return null; | ||
| 110 | const first = pathname.split("/")[1]; | ||
| 111 | return first && isValidOrgSlug(first) ? first : null; | ||
| 112 | }; | ||
| 113 | |||
| 87 | export const getExecutorServerConnection = (): ExecutorServerConnection => activeConnection; | 114 | export const getExecutorServerConnection = (): ExecutorServerConnection => activeConnection; |
| 88 | 115 | ||
| 89 | export const setExecutorServerConnection = (input: ExecutorServerConnectionInput): void => { | 116 | export const setExecutorServerConnection = (input: ExecutorServerConnectionInput): void => { |
| @@ -1,94 +1,39 @@ | |||
| 1 | import { useEffect, useRef, type ReactNode } from "react"; | 1 | import { useEffect, type ReactNode } from "react"; |
| 2 | import { useNavigate, useParams } from "@tanstack/react-router"; | 2 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | 3 | ||
| 4 | // --------------------------------------------------------------------------- | 4 | // --------------------------------------------------------------------------- |
| 5 | // Org-slug URL canonicalization for org-scoped hosts (cloud, self-host, | 5 | // Org-slug URL canonicalization for org-scoped hosts (cloud, self-host, |
| 6 | // cloudflare). Console routes live under an optional `{-$orgSlug}` segment, so | 6 | // cloudflare). Console routes live under an optional `{-$orgSlug}` segment, so |
| 7 | // the same tree serves `/policies` and `/acme/policies`; this gate, mounted | 7 | // the same tree serves `/policies` and `/acme/policies`; this gate, mounted |
| 8 | // inside the authenticated shell, pins the URL to the ACTIVE organization's | 8 | // inside the authenticated shell, pins a BARE URL to the active org's slug: |
| 9 | // slug: | ||
| 10 | // | 9 | // |
| 11 | // - bare URL → replace with `/<active-slug>/…` (canonicalize) | 10 | // - bare URL → replace with `/<active-slug>/…` (canonicalize) |
| 12 | // - active slug in URL → render | 11 | // - slug already in URL → render |
| 13 | // - other slug in URL → `foreignSlug(slug)` when provided (cloud renders a | ||
| 14 | // switch-organization resolver); single-org hosts | ||
| 15 | // omit it and the slug reads as a wrong address — | ||
| 16 | // a NOT-FOUND page, never a silent redirect into a | ||
| 17 | // workspace the URL didn't name | ||
| 18 | // | 12 | // |
| 19 | // "Foreign" is judged against where the SESSION has been, not just the URL: | 13 | // The URL slug is the request SCOPE, not just a label: every API call carries |
| 20 | // when the active slug changes while mounted (an org create/switch elsewhere | 14 | // it (the `x-executor-organization` header), and the server re-checks live |
| 21 | // in the app re-resolves `/account/me`), the URL is the stale side and | 15 | // membership and resolves data for that org — same as the MCP URL-pinned org. |
| 22 | // canonicalizes forward. Treating that as a foreign URL would switch the | 16 | // So a foreign slug never reaches this gate as "active": the server returns no |
| 23 | // session straight back — racing the in-flight navigation and rotating the | 17 | // organization for an org the caller can't see, and the shell 404s upstream. |
| 24 | // session cookie twice (WorkOS sealed-session refreshes are single-use, so | 18 | // That makes two browser tabs on different orgs fully independent — no shared |
| 25 | // the loser of that race invalidates the winner). | 19 | // "active org" to steal. |
| 26 | // | ||
| 27 | // The URL slug is a SELECTOR, not a trust boundary — every API call is scoped | ||
| 28 | // by the session server-side, same as the MCP URL-pinned org. | ||
| 29 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
| 30 | 21 | ||
| 31 | export interface OrgSlugGateProps { | 22 | export interface OrgSlugGateProps { |
| 32 | /** The active organization's slug (from `useAuth().organization`). */ | 23 | /** The active organization's slug (from `useAuth().organization`). */ |
| 33 | readonly activeSlug: string; | 24 | readonly activeSlug: string; |
| 34 | /** | ||
| 35 | * Rendered INSTEAD of children when the URL carries a different org's slug | ||
| 36 | * at load time. Multi-org hosts resolve it (switch the session when the | ||
| 37 | * caller is a member, not-found otherwise); single-org hosts leave it unset | ||
| 38 | * and get {@link OrgSlugNotFound}. | ||
| 39 | */ | ||
| 40 | readonly foreignSlug?: (slug: string) => ReactNode; | ||
| 41 | /** The host's not-found page for foreign slugs; default {@link OrgSlugNotFound}. */ | ||
| 42 | readonly notFound?: ReactNode; | ||
| 43 | readonly children: ReactNode; | 25 | readonly children: ReactNode; |
| 44 | } | 26 | } |
| 45 | 27 | ||
| 46 | /** | ||
| 47 | * The default not-found page for a URL naming an org this session can't see. | ||
| 48 | * Sized to fill its container (the shell's content area, or the viewport when | ||
| 49 | * rendered bare). The home link is BARE on purpose — it canonicalizes onto | ||
| 50 | * the active org. | ||
| 51 | */ | ||
| 52 | export function OrgSlugNotFound() { | ||
| 53 | return ( | ||
| 54 | <main className="flex min-h-full flex-1 items-center justify-center bg-background px-6 py-10"> | ||
| 55 | <section className="w-full max-w-md text-center"> | ||
| 56 | <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">404</p> | ||
| 57 | <h1 className="mt-2 text-xl font-semibold text-foreground">Page not found</h1> | ||
| 58 | <p className="mt-2 text-sm leading-6 text-muted-foreground"> | ||
| 59 | There's nothing at this address. | ||
| 60 | </p> | ||
| 61 | <a | ||
| 62 | href="/" | ||
| 63 | className="mt-6 inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 transition-colors" | ||
| 64 | > | ||
| 65 | Go home | ||
| 66 | </a> | ||
| 67 | </section> | ||
| 68 | </main> | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | |||
| 72 | export function OrgSlugGate(props: OrgSlugGateProps) { | 28 | export function OrgSlugGate(props: OrgSlugGateProps) { |
| 73 | const { activeSlug, foreignSlug } = props; | 29 | const { activeSlug } = props; |
| 74 | const params = useParams({ strict: false }) as { orgSlug?: string }; | 30 | const params = useParams({ strict: false }) as { orgSlug?: string }; |
| 75 | const urlSlug = params.orgSlug ?? null; | 31 | const urlSlug = params.orgSlug ?? null; |
| 76 | const navigate = useNavigate(); | 32 | const navigate = useNavigate(); |
| 77 | 33 | ||
| 78 | // The active slug the URL last agreed with. While it differs from | 34 | // Only a BARE URL canonicalizes. A slug in the URL is the scope the whole |
| 79 | // `activeSlug`, the session has moved out from under the URL (create/switch | 35 | // request chain already ran with, so it always matches `activeSlug` here. |
| 80 | // elsewhere in the app) — that is never a foreign URL. | 36 | const needsCanonicalize = urlSlug === null; |
| 81 | const agreedSlug = useRef(activeSlug); | ||
| 82 | useEffect(() => { | ||
| 83 | if (urlSlug === activeSlug) agreedSlug.current = activeSlug; | ||
| 84 | }, [urlSlug, activeSlug]); | ||
| 85 | const sessionMoved = agreedSlug.current !== activeSlug; | ||
| 86 | |||
| 87 | const isForeign = urlSlug !== null && urlSlug !== activeSlug && !sessionMoved; | ||
| 88 | // Only URLs that don't NAME a different org canonicalize: bare paths, and | ||
| 89 | // stale slugs after the session itself moved. A foreign slug never does — | ||
| 90 | // it either resolves (the host's foreignSlug) or reads as a wrong address. | ||
| 91 | const needsCanonicalize = urlSlug === null || sessionMoved; | ||
| 92 | 37 | ||
| 93 | useEffect(() => { | 38 | useEffect(() => { |
| 94 | if (!needsCanonicalize) return; | 39 | if (!needsCanonicalize) return; |
| @@ -104,10 +49,6 @@ export function OrgSlugGate(props: OrgSlugGateProps) { · 9 unmodified lines | |||
| 104 | }); | 49 | }); |
| 105 | }, [needsCanonicalize, activeSlug, navigate]); | 50 | }, [needsCanonicalize, activeSlug, navigate]); |
| 106 | 51 | ||
| 107 | if (isForeign) { | ||
| 108 | return <>{foreignSlug ? foreignSlug(urlSlug) : (props.notFound ?? <OrgSlugNotFound />)}</>; | ||
| 109 | } | ||
| 110 | |||
| 111 | // Render through while canonicalizing — the target is the same route, so | 52 | // Render through while canonicalizing — the target is the same route, so |
| 112 | // withholding children would only flash the page. | 53 | // withholding children would only flash the page. |
| 113 | return <>{props.children}</>; | 54 | return <>{props.children}</>; |