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/web (3 of 14 files). Show all
Unified · Split
| @@ -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> |