History

Web clients scope by the URL org; drop cookie-based org switching (#1000)

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).

Rhys Sullivan committed 5 months ago

Commit 1f9bfe01 parent 58e23e814 files · +233 −214Browse files

Showing the changes to apps/cloud/src/web (3 of 14 files). Show all

Unified · Split

3 files
  1. apps/cloud/src/web/auth.tsx +0 −1
  2. apps/cloud/src/web/components/foreign-org-slug.tsx +0 −61
  3. apps/cloud/src/web/components/org-menu-slot.tsx +12 −15
apps/cloud/src/web/auth.tsx+0−1
@@ -33,7 +33,6 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( · 32 unmodified lines
33 }),33 }),
34);34);
35 35
36export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
37export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");36export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
38 37
39export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {38export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {
apps/cloud/src/web/components/foreign-org-slug.tsx+0−61
@@ -1,61 +0,0 @@
1import { useEffect, useRef, useState, type ReactNode } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";
5import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import { OrgSlugNotFound } from "@executor-js/react/multiplayer/org-slug-gate";
7
8import { 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
23const 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.
33function 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
50export 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}
apps/cloud/src/web/components/org-menu-slot.tsx+12−15
@@ -1,8 +1,6 @@
1import { useState } from "react";1import { useState } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";
5import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import { trackEvent } from "@executor-js/react/api/analytics";4import { trackEvent } from "@executor-js/react/api/analytics";
7import { Button } from "@executor-js/react/components/button";5import { Button } from "@executor-js/react/components/button";
8import {6import {
@@ -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";
25import { useAuth } from "../auth";23import { useAuth } from "../auth";
26import { organizationsAtom, switchOrganization } from "../auth";24import { organizationsAtom } from "../auth";
27import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";25import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";
28 26
29// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
@@ -52,18 +50,15 @@ function CheckIcon() { · 22 unmodified lines
52 50
53function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {51function 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 <DropdownMenuItem75 <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>

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t