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).
Showing the changes to apps/cloud/src (6 of 14 files). Show all
Unified · Split
| @@ -34,10 +34,6 @@ const AuthOrganizationsResponse = Schema.Struct({ · 33 unmodified lines | ||
| 34 | 34 | activeOrganizationId: Schema.NullOr(Schema.String), |
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | −const SwitchOrganizationBody = Schema.Struct({ | |
| 38 | − organizationId: Schema.String, | |
| 39 | −}); | |
| 40 | − | |
| 41 | 37 | const CreateOrganizationBody = Schema.Struct({ |
| 42 | 38 | name: Schema.String, |
| 43 | 39 | }); |
| @@ -143,7 +139,6 @@ export const AUTH_PATHS = { · 99 unmodified lines | ||
| 143 | 139 | login: "/api/auth/login", |
| 144 | 140 | logout: "/api/auth/logout", |
| 145 | 141 | callback: "/api/auth/callback", |
| 146 | − switchOrganization: "/api/auth/switch-organization", | |
| 147 | 142 | } as const; |
| 148 | 143 | |
| 149 | 144 | const AuthErrors = [UserStoreError, WorkOSError] as const; |
| @@ -178,12 +173,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 28 unmodified lines | ||
| 178 | 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 | 176 | .add( |
| 188 | 177 | HttpApiEndpoint.post("createOrganization", "/auth/create-organization", { |
| 189 | 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 | 304 | .handle("createOrganization", ({ payload }) => |
| 319 | 305 | Effect.gen(function* () { |
| 320 | 306 | const workos = yield* WorkOSClient; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | createRootRoute, |
| 8 | 8 | useLocation, |
| 9 | 9 | useNavigate, |
| 10 | + useParams, | |
| 10 | 11 | } from "@tanstack/react-router"; |
| 11 | 12 | import { AutumnProvider } from "autumn-js/react"; |
| 12 | 13 | import posthog from "posthog-js"; |
| @@ -23,7 +24,6 @@ import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; · 10 unmodified lines | ||
| 23 | 24 | import { AuthProvider, useAuth } from "../web/auth"; |
| 24 | 25 | import { loginPath } from "../auth/return-to"; |
| 25 | 26 | import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths"; |
| 26 | −import { ForeignOrgSlug } from "../web/components/foreign-org-slug"; | |
| 27 | 27 | import { SupportOptions } from "../web/components/support-options"; |
| 28 | 28 | import { Shell } from "../web/shell"; |
| 29 | 29 | import appCss from "@executor-js/react/globals.css?url"; |
| @@ -209,13 +209,20 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 179 unmodified lines | ||
| 209 | 209 | const navigate = useNavigate(); |
| 210 | 210 | const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname); |
| 211 | 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 | 217 | // The SSR gate already bounced fresh org-less document requests to |
| 214 | 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 | 222 | const needsOrgRedirect = |
| 217 | 223 | auth.status === "authenticated" && |
| 218 | 224 | auth.organization == null && |
| 225 | + !urlOrgSlug && | |
| 219 | 226 | !isOnboardingRoute && |
| 220 | 227 | !isPublicRoute; |
| 221 | 228 | |
| @@ -254,7 +261,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 32 unmodified lines | ||
| 254 | 261 | } |
| 255 | 262 | |
| 256 | 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 | 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 | 286 | organizationId={auth.organization.id} |
| 276 | 287 | organizationSlug={activeSlug} |
| 277 | 288 | > |
| 278 | − <OrgSlugGate | |
| 279 | − activeSlug={activeSlug} | |
| 280 | − // Framed by the real shell: a foreign slug resolves (or | |
| 281 | − // 404s) inside the app chrome, exactly like the route-level | |
| 282 | − // not-found — never a bare full-page state. | |
| 283 | − foreignSlug={(slug) => ( | |
| 284 | − <> | |
| 285 | − <Shell content={<ForeignOrgSlug slug={slug} />} /> | |
| 286 | − <Toaster /> | |
| 287 | − </> | |
| 288 | − )} | |
| 289 | − > | |
| 289 | + {/* The org header scopes every request to the URL's org, so | |
| 290 | + reaching here means the caller is a member of `activeSlug` | |
| 291 | + (a foreign slug already 404'd above). The gate only keeps | |
| 292 | + the URL canonical — bare → /<slug>. */} | |
| 293 | + <OrgSlugGate activeSlug={activeSlug}> | |
| 290 | 294 | <Shell /> |
| 291 | 295 | <Toaster /> |
| 292 | 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 | 36 | export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); |
| 38 | 37 | |
| 39 | 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 | 1 | import { useState } from "react"; |
| 2 | −import { useAtomValue, useAtomSet } from "@effect/atom-react"; | |
| 2 | +import { useAtomValue } from "@effect/atom-react"; | |
| 3 | 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 | 4 | import { trackEvent } from "@executor-js/react/api/analytics"; |
| 7 | 5 | import { Button } from "@executor-js/react/components/button"; |
| 8 | 6 | import { |
| @@ -23,7 +21,7 @@ import { · 14 unmodified lines | ||
| 23 | 21 | DropdownMenuSubTrigger, |
| 24 | 22 | } from "@executor-js/react/components/dropdown-menu"; |
| 25 | 23 | import { useAuth } from "../auth"; |
| 26 | −import { organizationsAtom, switchOrganization } from "../auth"; | |
| 24 | +import { organizationsAtom } from "../auth"; | |
| 27 | 25 | import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form"; |
| 28 | 26 | |
| 29 | 27 | // --------------------------------------------------------------------------- |
| @@ -52,18 +50,15 @@ function CheckIcon() { · 22 unmodified lines | ||
| 52 | 50 | |
| 53 | 51 | function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) { |
| 54 | 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 | 59 | if (organization.id === props.activeOrganizationId) return; |
| 59 | − const exit = await doSwitchOrganization({ | |
| 60 | − payload: { organizationId: organization.id }, | |
| 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}`; | |
| 60 | + trackEvent("org_switched", { success: true }); | |
| 61 | + window.location.href = `/${organization.slug}`; | |
| 67 | 62 | }; |
| 68 | 63 | |
| 69 | 64 | return AsyncResult.match(organizations, { |
| @@ -80,7 +75,9 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null · 10 unmodified lines | ||
| 80 | 75 | <DropdownMenuItem |
| 81 | 76 | key={organization.id} |
| 82 | 77 | disabled={isActive} |
| 83 | − onClick={() => handleSwitch(organization)} | |
| 78 | + onClick={() => { | |
| 79 | + handleSwitch(organization); | |
| 80 | + }} | |
| 84 | 81 | className="text-xs" |
| 85 | 82 | > |
| 86 | 83 | <span className="min-w-0 flex-1 truncate">{organization.name}</span> |