diff --git a/apps/mesh/src/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index c0f04e548b..efa9f19495 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -632,9 +632,47 @@ async function authenticateRequest( let organization: OrganizationContext | undefined; let role: string | undefined; - if (session.session.activeOrganizationId) { - // Get full organization data (includes members with roles) + // Prefer the explicit x-org-id request header over the session's activeOrganizationId. + // The MCP client sends this header on every request with the org from the URL, enabling + // multiple browser tabs to operate in different orgs simultaneously without one tab's + // setActive() call clobbering another tab's session state. + const headerOrgId = req.headers.get("x-org-id"); + + if (headerOrgId) { + // Resolve org directly from DB and verify the session user is a member. + // This bypasses the shared session state entirely for per-request org resolution. + const membership = await timings.measure( + "auth_query_org_membership", + () => + db + .selectFrom("member") + .innerJoin( + "organization", + "organization.id", + "member.organizationId", + ) + .select([ + "member.role", + "organization.id as orgId", + "organization.slug as orgSlug", + "organization.name as orgName", + ]) + .where("member.userId", "=", session.user.id) + .where("member.organizationId", "=", headerOrgId) + .executeTakeFirst(), + ); + if (membership) { + organization = { + id: membership.orgId, + slug: membership.orgSlug, + name: membership.orgName, + }; + role = membership.role; + } + } else if (session.session.activeOrganizationId) { + // Fall back to the session's active org when no explicit header is present. + // (Existing behavior for requests that don't send x-org-id.) const orgData = await timings.measure( "auth_get_full_organization", () => diff --git a/apps/mesh/src/web/components/organizations-home.tsx b/apps/mesh/src/web/components/organizations-home.tsx index 5831bda25e..b4bca2eb5a 100644 --- a/apps/mesh/src/web/components/organizations-home.tsx +++ b/apps/mesh/src/web/components/organizations-home.tsx @@ -1,4 +1,5 @@ import { authClient } from "@/web/lib/auth-client"; +import { setCurrentOrgId } from "@/web/lib/org-store"; import { useNavigate } from "@tanstack/react-router"; import { EntityCard } from "@deco/ui/components/entity-card.tsx"; import { EntityGrid } from "@deco/ui/components/entity-grid.tsx"; @@ -40,11 +41,16 @@ function InvitationCard({ invitation }: { invitation: Invitation }) { toast.error(result.error.message); setIsAccepting(false); } else { - // Set the new org as active to update session + // Set the new org as active to update session and get org data. const setActiveResult = await authClient.organization.setActive({ organizationId: invitation.organizationId, }); + // Keep the per-tab org store in sync with the explicit switch. + if (setActiveResult?.data?.id) { + setCurrentOrgId(setActiveResult.data.id); + } + if (setActiveResult?.data?.slug) { toast.success("Invitation accepted!"); navigate({ to: "/$org", params: { org: setActiveResult.data.slug } }); diff --git a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx index 1948214265..04aaadef95 100644 --- a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx +++ b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx @@ -1,4 +1,5 @@ import { authClient } from "@/web/lib/auth-client"; +import { setCurrentOrgId } from "@/web/lib/org-store"; import { MeshUserMenu } from "@/web/components/user-menu"; import { Button } from "@deco/ui/components/button.tsx"; import { @@ -45,6 +46,12 @@ function InvitationItem({ invitation }: { invitation: Invitation }) { const setActiveResult = await authClient.organization.setActive({ organizationId: invitation.organizationId, }); + + // Keep the per-tab org store in sync with the explicit switch. + if (setActiveResult?.data?.id) { + setCurrentOrgId(setActiveResult.data.id); + } + toast.success("Invitation accepted!"); const slug = setActiveResult?.data?.slug; window.location.href = slug ? `/${slug}` : "/"; diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index 82008e13a0..08a8070916 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -11,6 +11,7 @@ import { useLocalStorage } from "@/web/hooks/use-local-storage"; import RequiredAuthLayout from "@/web/layouts/required-auth-layout"; import { authClient } from "@/web/lib/auth-client"; import { LOCALSTORAGE_KEYS } from "@/web/lib/localstorage-keys"; +import { setCurrentOrgId } from "@/web/lib/org-store"; import { ResizableHandle, ResizablePanel, @@ -187,10 +188,17 @@ function ShellLayoutContent() { return null; } - const { data } = await authClient.organization.setActive({ - organizationSlug: org, + // Pure read — does not mutate the shared session's activeOrganizationId. + // This is the key fix for multi-tab org isolation: previously setActive() + // would overwrite the session for every other open tab. + const { data } = await authClient.organization.getFullOrganization({ + query: { organizationSlug: org }, }); + // Populate the per-tab org store so the auth client injects organizationId + // on all subsequent Better Auth org-management calls from this tab. + setCurrentOrgId(data?.id ?? null); + return { org: data, // Project slug comes from URL param, actual project data is fetched in project-layout diff --git a/apps/mesh/src/web/lib/auth-client.ts b/apps/mesh/src/web/lib/auth-client.ts index 55595963e2..eeadc2793e 100644 --- a/apps/mesh/src/web/lib/auth-client.ts +++ b/apps/mesh/src/web/lib/auth-client.ts @@ -5,6 +5,7 @@ import { magicLinkClient, } from "better-auth/client/plugins"; import { ssoClient } from "@better-auth/sso/client"; +import { getCurrentOrgId } from "./org-store"; export const authClient = createAuthClient({ plugins: [ @@ -17,4 +18,38 @@ export const authClient = createAuthClient({ ssoClient(), magicLinkClient(), ], + fetchOptions: { + onRequest: (ctx) => { + const orgId = getCurrentOrgId(); + if (!orgId) return; + + const urlStr = typeof ctx.url === "string" ? ctx.url : ctx.url.toString(); + + // Only intercept organization-management routes. + // Skip /set-active — those calls carry their own explicit org ID in the + // body (the org the user is switching TO) and must not be overridden. + if (!urlStr.includes("/organization/") || urlStr.includes("/set-active")) + return; + + if (ctx.method?.toUpperCase() === "GET") { + // Inject as query param — Better Auth reads organizationId from query + // for listMembers, listRoles, getFullOrganization, etc. + const url = new URL(urlStr, window.location.origin); + url.searchParams.set("organizationId", orgId); + ctx.url = url.toString(); + } else if ( + ctx.body && + typeof ctx.body === "object" && + !Array.isArray(ctx.body) + ) { + // Inject into body — Better Auth reads organizationId from body for + // inviteMember, removeMember, createRole, updateRole, deleteRole, etc. + // Only inject if not already explicitly provided by the caller. + const body = ctx.body as Record; + if (!body.organizationId) { + ctx.body = { ...body, organizationId: orgId }; + } + } + }, + }, }); diff --git a/apps/mesh/src/web/lib/org-store.ts b/apps/mesh/src/web/lib/org-store.ts new file mode 100644 index 0000000000..ba9ea4bef2 --- /dev/null +++ b/apps/mesh/src/web/lib/org-store.ts @@ -0,0 +1,17 @@ +/** + * Per-tab org ID store. + * + * Browser tabs each have their own JS execution context, so module-level state + * is naturally isolated per tab. We use this to track the org the current tab + * is operating in, so the auth client can inject it on every outbound request + * rather than relying on the shared server-side session's activeOrganizationId. + */ +let currentOrgId: string | null = null; + +export function setCurrentOrgId(id: string | null): void { + currentOrgId = id; +} + +export function getCurrentOrgId(): string | null { + return currentOrgId; +}