Skip to content

Commit ea800cf

Browse files
committed
Untangle ForeignOrgSlug; fix stuck-spinner on switch failure
The component collapsed loading / no-match / matched-org into one null|"none"|org value, re-derived the id off it, then ran a side-effecting switch through a mutable ref guard. The failure branch only flipped the ref (no re-render, match still present), so a failed switch sat on "Switching organization…" forever — never the 404 the comment claimed. Split it: render is now a pure AsyncResult.match over the three states, and the one side effect (switch + reload) lives in a small SwitchIntoOrg child that tracks failure in state, so a failed switch renders not-found. Adds e2e coverage for the switch-by-URL path (open another of your orgs by slug -> session switches into it), which had none.
1 parent f94425f commit ea800cf

3 files changed

Lines changed: 132 additions & 45 deletions

File tree

apps/cloud/src/routeTree.gen.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,11 +350,15 @@ export const routeTree = rootRouteImport
350350
._addFileTypes<FileRouteTypes>()
351351

352352
import type { getRouter } from './router.tsx'
353+
353354
import type { startInstance } from './start.ts'
355+
354356
declare module '@tanstack/react-start' {
355357
interface Register {
356358
ssr: true
359+
357360
router: Awaited<ReturnType<typeof getRouter>>
361+
358362
config: Awaited<ReturnType<typeof startInstance.getOptions>>
359363
}
360364
}
Lines changed: 44 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef } from "react";
1+
import { useEffect, useRef, useState, type ReactNode } from "react";
22
import { useAtomValue, useAtomSet } from "@effect/atom-react";
33
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44
import * as Exit from "effect/Exit";
@@ -8,55 +8,54 @@ import { OrgSlugNotFound } from "@executor-js/react/multiplayer/org-slug-gate";
88
import { organizationsAtom, switchOrganization } from "../auth";
99

1010
// ---------------------------------------------------------------------------
11-
// Foreign-slug resolution for the cloud org-slug gate: the URL carries a slug
12-
// that isn't the active org's. If the caller is a member of that org (their
13-
// bookmark, a teammate's link into a shared org), switch the session to it
14-
// and reload so the whole app re-scopes. Anything else is a WRONG ADDRESS and
15-
// renders not-found — a slug must never silently resolve to a different
16-
// workspace than the URL names, and single-segment typos (/this-page-does-
17-
// not-exist) match the slugged index route, so this page IS the app's 404 for
18-
// them. Membership comes from the already-cached organizations atom; the
19-
// fallback while it loads is a blank screen, not a skeleton.
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`.
2021
// ---------------------------------------------------------------------------
2122

22-
export function ForeignOrgSlug(props: { readonly slug: string }) {
23-
const organizations = useAtomValue(organizationsAtom);
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 }) {
2434
const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" });
25-
const switching = useRef(false);
26-
27-
const target = AsyncResult.match(organizations, {
28-
onInitial: () => null,
29-
onFailure: () => "none" as const,
30-
onSuccess: ({ value }) =>
31-
value.organizations.find((org: { slug: string }) => org.slug === props.slug) ??
32-
("none" as const),
33-
});
34-
35-
const targetOrgId = target !== null && target !== "none" ? target.id : null;
35+
const [failed, setFailed] = useState(false);
36+
const started = useRef(false);
3637

3738
useEffect(() => {
38-
if (!targetOrgId || switching.current) return;
39-
switching.current = true;
39+
if (started.current) return;
40+
started.current = true;
4041
void doSwitchOrganization({
41-
payload: { organizationId: targetOrgId },
42+
payload: { organizationId: props.organizationId },
4243
reactivityKeys: authWriteKeys,
43-
}).then((exit) => {
44-
// Keep the URL (it already names the target org); reload re-scopes the
45-
// app to the switched session. On failure fall through to not-found by
46-
// releasing the guard — the next render still has no active match.
47-
if (Exit.isSuccess(exit)) {
48-
window.location.reload();
49-
} else {
50-
switching.current = false;
51-
}
52-
});
53-
}, [targetOrgId, doSwitchOrganization]);
54-
55-
if (target === "none") return <OrgSlugNotFound />;
56-
57-
return (
58-
<div className="flex min-h-full flex-1 items-center justify-center text-sm text-muted-foreground">
59-
{targetOrgId ? "Switching organization…" : ""}
60-
</div>
61-
);
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+
});
6261
}

e2e/cloud/org-slug-foreign.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Cloud-only (browser): opening another of YOUR orgs by its slug URL switches
2+
// the session into it. The org-slug gate's foreign-slug branch (ForeignOrgSlug)
3+
// resolves a URL slug that isn't the active org against the caller's
4+
// memberships — a match switches + reloads, so a bookmark or a teammate's link
5+
// into a shared org lands you there even when a different org is active.
6+
//
7+
// org-switcher.test.ts covers switching via the account menu; this covers the
8+
// URL path. (Unknown/unauthorized slugs → 404 is covered by
9+
// scenarios/org-slug-routing.test.ts.)
10+
import { expect } from "@effect/vitest";
11+
import { Effect } from "effect";
12+
13+
import { scenario } from "../src/scenario";
14+
import { Browser, Target } from "../src/services";
15+
16+
const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
17+
18+
scenario(
19+
"Org URLs · opening another of your orgs by slug switches the session into it",
20+
{},
21+
Effect.gen(function* () {
22+
const target = yield* Target;
23+
const browser = yield* Browser;
24+
25+
// 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+
const identity = yield* target.newIdentity();
28+
const cookie = identity.headers?.cookie ?? "";
29+
30+
const createB = yield* Effect.promise(() =>
31+
fetch(new URL("/api/auth/create-organization", target.baseUrl), {
32+
method: "POST",
33+
headers: {
34+
"content-type": "application/json",
35+
cookie,
36+
...CLOUD_ORIGIN_HEADERS(target.baseUrl),
37+
},
38+
body: JSON.stringify({ name: "Foreign Slug Org B" }),
39+
}),
40+
);
41+
expect(createB.ok, "org B was created").toBe(true);
42+
const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string };
43+
const setCookie = createB.headers.get("set-cookie") ?? "";
44+
const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1];
45+
expect(sessionB, "creating org B refreshed the session into it").toBeTruthy();
46+
47+
// Both orgs' slugs from the session that is now active in B.
48+
const orgs = (yield* Effect.promise(() =>
49+
fetch(new URL("/api/auth/organizations", target.baseUrl), {
50+
headers: { cookie: `wos-session=${sessionB}` },
51+
}).then((r) => r.json()),
52+
)) as {
53+
organizations: ReadonlyArray<{ name: string; slug: string }>;
54+
activeOrganizationId: string;
55+
};
56+
const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug;
57+
expect(slugA, "org A 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);
60+
61+
// Drive the browser as the session that is ACTIVE IN B.
62+
const inB = {
63+
...identity,
64+
headers: { cookie: `wos-session=${sessionB}` },
65+
cookies: [{ name: "wos-session", value: sessionB! }],
66+
};
67+
68+
yield* browser.session(inB, async ({ page, step }) => {
69+
await step("Land in org B, then open org A's slug URL directly", async () => {
70+
await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
71+
await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
72+
// Navigate to org A — the gate sees a foreign-but-member slug.
73+
await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
74+
});
75+
76+
await step("The session switches into org A and lands on the slugged URL", async () => {
77+
// Reaching org A's policies at its URL is the proof: a switch that
78+
// failed would render the gate's 404 here, not the Policies page.
79+
await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 });
80+
await page.getByText("Policies").first().waitFor({ timeout: 30_000 });
81+
});
82+
});
83+
}),
84+
);

0 commit comments

Comments
 (0)