From 5c2b7fa41dca98c219d34fb538c69c6f64a4dde0 Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Wed, 20 May 2026 14:44:07 +0100 Subject: [PATCH 01/13] fix: login journey --- client/dashboard/src/App.tsx | 4 + .../dashboard/src/components/top-header.tsx | 13 +- .../dashboard/src/contexts/AuthProvider.tsx | 6 +- client/dashboard/src/pages/demo/SwitchOrg.tsx | 128 ++++++++++++++++++ 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 client/dashboard/src/pages/demo/SwitchOrg.tsx diff --git a/client/dashboard/src/App.tsx b/client/dashboard/src/App.tsx index bc6aacb23d..b6851f49ef 100644 --- a/client/dashboard/src/App.tsx +++ b/client/dashboard/src/App.tsx @@ -30,6 +30,7 @@ import { RBACDevToolbar } from "./components/dev-toolbar"; import { usePageTitle } from "./hooks/use-page-title"; import CliCallback from "./pages/cli/CliCallback"; import SlackRegister from "./pages/slackapp/SlackRegister"; +import SwitchOrg from "./pages/demo/SwitchOrg"; import { AppRoute, useRoutes, useOrgRoutes } from "./routes"; export default function App() { @@ -245,6 +246,9 @@ const RouteProvider = () => { }> } /> + }> + } /> + }> {routesWithSubroutes(outsideStructureRoutes)} diff --git a/client/dashboard/src/components/top-header.tsx b/client/dashboard/src/components/top-header.tsx index 97d3e0375e..ced0b7b635 100644 --- a/client/dashboard/src/components/top-header.tsx +++ b/client/dashboard/src/components/top-header.tsx @@ -2,6 +2,7 @@ import { useIsAdmin, useOrganization, useProject, + useSession, useUser, } from "@/contexts/Auth"; import { useSdkClient, useSlugs } from "@/contexts/Sdk"; @@ -18,6 +19,7 @@ import { } from "@speakeasy-api/moonshine"; import { BugIcon, + BuildingIcon, CheckIcon, ChevronsUpDown, ArrowRightLeftIcon, @@ -29,7 +31,7 @@ import { SettingsIcon, } from "lucide-react"; import { useCallback, useState } from "react"; -import { Link } from "react-router"; +import { Link, useNavigate } from "react-router"; import { useRBAC } from "@/hooks/useRBAC"; import { GramLogo } from "./gram-logo"; import { InputDialog } from "./input-dialog"; @@ -52,11 +54,14 @@ export function TopHeader() { const organization = useOrganization(); const project = useProject(); const user = useUser(); + const session = useSession(); + const navigate = useNavigate(); const { projectSlug } = useSlugs(); const [open, setOpen] = useState(false); const isAdmin = useIsAdmin(); const { hasAnyScope } = useRBAC(); const canAccessOrgRoutes = hasAnyScope(["org:read", "org:admin"]); + const isMultiOrg = session.organizations.length > 1; const [createDialogOpen, setCreateDialogOpen] = useState(false); const [pylonOpen, setPylonOpen] = useState(false); const togglePylon = useCallback(() => { @@ -283,6 +288,12 @@ export function TopHeader() { Organization Override )} + {isMultiOrg && ( + navigate("/switch-org")}> + + Switch Organization + + )} diff --git a/client/dashboard/src/contexts/AuthProvider.tsx b/client/dashboard/src/contexts/AuthProvider.tsx index af4aa6f0a8..c6ec622c4d 100644 --- a/client/dashboard/src/contexts/AuthProvider.tsx +++ b/client/dashboard/src/contexts/AuthProvider.tsx @@ -16,6 +16,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { Type } from "@/components/ui/type"; import BookDemo from "@/pages/demo/BookDemo"; +import SwitchOrg from "@/pages/demo/SwitchOrg"; import { Icon } from "@speakeasy-api/moonshine"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; @@ -51,7 +52,7 @@ const PREFERRED_PROJECT_KEY = "preferredProject"; const UNAUTHENTICATED_PATHS = ["/login", "/register", "/book-demo"]; -const SLUG_EXEMPT_PATHS = ["/slack/register"]; +const SLUG_EXEMPT_PATHS = ["/slack/register", "/switch-org"]; export const AuthProvider = ({ children }: { children: React.ReactNode }) => { return ( @@ -103,6 +104,9 @@ const AuthHandler = ({ children }: { children: React.ReactNode }) => { // Show book demo page if organization is not whitelisted // Check this before the no-org fallback so non-whitelisted orgs are blocked before reaching the normal app flow if (session.activeOrganizationId && !session.whitelisted) { + if (session.organizations.length > 1) { + return ; + } return ; } diff --git a/client/dashboard/src/pages/demo/SwitchOrg.tsx b/client/dashboard/src/pages/demo/SwitchOrg.tsx new file mode 100644 index 0000000000..d3436ba0cd --- /dev/null +++ b/client/dashboard/src/pages/demo/SwitchOrg.tsx @@ -0,0 +1,128 @@ +import { useSessionData } from "@/contexts/Auth"; +import { useSdkClient } from "@/contexts/Sdk"; +import { AuthLayout } from "@/pages/login/components/login-section"; +import { JourneyDemo } from "@/pages/login/components/journey-demo"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@speakeasy-api/moonshine"; +import { LogOutIcon, AlertCircleIcon, BuildingIcon } from "lucide-react"; +import { useState } from "react"; + +interface SwitchOrgProps { + gate?: boolean; +} + +export default function SwitchOrg({ gate = false }: SwitchOrgProps) { + const client = useSdkClient(); + const { session } = useSessionData(); + + const allOrgs = session?.organizations ?? []; + const otherOrgs = allOrgs.filter( + (org) => org.id !== session?.activeOrganizationId, + ); + + const [selectedOrgId, setSelectedOrgId] = useState(""); + const [isSwitching, setIsSwitching] = useState(false); + + const handleSwitch = async () => { + if (!selectedOrgId || selectedOrgId === session?.activeOrganizationId) + return; + setIsSwitching(true); + try { + await client.auth.switchScopes({ organizationId: selectedOrgId }); + window.location.replace("/"); + } finally { + setIsSwitching(false); + } + }; + + const handleLogout = async () => { + await client.auth.logout(); + window.location.href = "/login"; + }; + + const currentOrgName = session?.organization?.name ?? "This organization"; + + return ( +
+ + + + + Log out + + ) : undefined + } + > +
+
+ {gate ? ( + + ) : ( + + )} +
+

+ {gate ? `No access for ${currentOrgName}` : "Switch organization"} +

+

+ {gate + ? "This organization doesn't have access to the MCP platform. Switch to another organization to continue." + : "Select which organization you'd like to work in."} +

+
+ +
+ + + +
+
+
+ ); +} From 5ee0673b2937c0f08d6de32f5cd0f4ecc598eebd Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Wed, 20 May 2026 14:59:32 +0100 Subject: [PATCH 02/13] chore: lint --- client/dashboard/src/pages/demo/SwitchOrg.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/dashboard/src/pages/demo/SwitchOrg.tsx b/client/dashboard/src/pages/demo/SwitchOrg.tsx index d3436ba0cd..cf536081e0 100644 --- a/client/dashboard/src/pages/demo/SwitchOrg.tsx +++ b/client/dashboard/src/pages/demo/SwitchOrg.tsx @@ -22,9 +22,6 @@ export default function SwitchOrg({ gate = false }: SwitchOrgProps) { const { session } = useSessionData(); const allOrgs = session?.organizations ?? []; - const otherOrgs = allOrgs.filter( - (org) => org.id !== session?.activeOrganizationId, - ); const [selectedOrgId, setSelectedOrgId] = useState(""); const [isSwitching, setIsSwitching] = useState(false); From 4ea90f4f5d20fb296f3ad439ecf07ef12153e5cf Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Wed, 20 May 2026 17:13:24 +0100 Subject: [PATCH 03/13] dummy to trigger CI --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 6c95387213..c8483b16e5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@

-
# Introduction From b6044eb8a70482ae8a5feb831769be1e19e6de78 Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Wed, 20 May 2026 17:39:24 +0100 Subject: [PATCH 04/13] chore: trigger CI --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c8483b16e5..6c95387213 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@

+
# Introduction From 643b21ba49c89a6c185778c1f790664092abf116 Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Wed, 20 May 2026 18:22:37 +0100 Subject: [PATCH 05/13] chore: trigger CI --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6c95387213..03bf9c988e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@

Gram - The MCP Cloud Platform

+


Learn more » From 1202e422b0daeaafea848db09f04af5d94ee2d2a Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Wed, 20 May 2026 19:32:00 +0100 Subject: [PATCH 06/13] fix: memberships --- server/internal/auth/impl.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/internal/auth/impl.go b/server/internal/auth/impl.go index a1ca824041..c06aff4995 100644 --- a/server/internal/auth/impl.go +++ b/server/internal/auth/impl.go @@ -264,7 +264,7 @@ func (s *Service) Callback(ctx context.Context, payload *gen.CallbackPayload) (r return redirectWithError(authErrInit, err) } - if idpUser.OrganizationID != "" { + if idpUser.Sub != "" { if err := s.identity.SyncMembershipsFromWorkOS(ctx, userID, idpUser.Sub); err != nil { return redirectWithError(authErrInit, err) } From 31fc711090eb34136f7bede0ef7a11a281a86450 Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Thu, 21 May 2026 09:19:11 +0100 Subject: [PATCH 07/13] fix: admin gate --- server/internal/auth/impl.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/internal/auth/impl.go b/server/internal/auth/impl.go index c06aff4995..23b825968d 100644 --- a/server/internal/auth/impl.go +++ b/server/internal/auth/impl.go @@ -608,7 +608,9 @@ func (s *Service) Info(ctx context.Context, payload *gen.InfoPayload) (res *gen. // For admins we only return the active organization to avoid overloaded returns. // The active org may not be in the admin's membership list (admin override), // so fall back to a DB lookup. - if userInfo.Admin { + // Exception: if the active org is not whitelisted, return all real memberships + // so the user can switch to a whitelisted org from the gate page. + if userInfo.Admin && authCtx.Whitelisted { found := false for _, org := range userInfo.Organizations { if org.ID == authCtx.ActiveOrganizationID { From 641d3b39b734f0a4acfb6df8993270e37765a81d Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Thu, 21 May 2026 09:33:03 +0100 Subject: [PATCH 08/13] fix(auth): return all org memberships for admins in own org Admins overriding into a foreign org still get only that org returned (to avoid overloaded responses), but admins operating within their own memberships now see all their orgs so the org-switcher works correctly. Co-Authored-By: Claude Sonnet 4.6 --- server/internal/auth/impl.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/server/internal/auth/impl.go b/server/internal/auth/impl.go index 23b825968d..a9577e2ecc 100644 --- a/server/internal/auth/impl.go +++ b/server/internal/auth/impl.go @@ -605,21 +605,18 @@ func (s *Service) Info(ctx context.Context, payload *gen.InfoPayload) (res *gen. return nil, oops.E(oops.CodeUnexpected, err, "error getting user info").Log(ctx, s.logger) } - // For admins we only return the active organization to avoid overloaded returns. - // The active org may not be in the admin's membership list (admin override), - // so fall back to a DB lookup. - // Exception: if the active org is not whitelisted, return all real memberships - // so the user can switch to a whitelisted org from the gate page. - if userInfo.Admin && authCtx.Whitelisted { - found := false + // For admins overriding into a foreign org (one not in their own membership list), + // return only that org to avoid overloaded returns. When admins are in one of their + // own orgs, return all real memberships so the org-switcher works normally. + if userInfo.Admin { + inOwnOrg := false for _, org := range userInfo.Organizations { if org.ID == authCtx.ActiveOrganizationID { - userInfo.Organizations = []sessions.Organization{org} - found = true + inOwnOrg = true break } } - if !found { + if !inOwnOrg { orgMeta, err := s.orgRepo.GetOrganizationMetadata(ctx, authCtx.ActiveOrganizationID) if err == nil { userInfo.Organizations = []sessions.Organization{{ From 292c9f54f42ffb217be7d9b8afd6c126dc2c02f5 Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Fri, 22 May 2026 10:36:00 +0100 Subject: [PATCH 09/13] chore: reverts accidental changes --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 03bf9c988e..6c95387213 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@

Gram - The MCP Cloud Platform

-


Learn more » From 1ca51ab327e22153096a1a52dc0aedb77efc567b Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Fri, 22 May 2026 10:39:19 +0100 Subject: [PATCH 10/13] chore: reverts accidental changes --- server/internal/mcpendpoints/setup_test.go | 32 +++---- .../mcpservers/deletemcpserver_test.go | 32 +++---- .../mcpservers/listmcpservers_test.go | 64 ++++++------- server/internal/mcpservers/ownership_test.go | 96 +++++++++---------- .../mcpservers/updatemcpserver_test.go | 96 +++++++++---------- 5 files changed, 160 insertions(+), 160 deletions(-) diff --git a/server/internal/mcpendpoints/setup_test.go b/server/internal/mcpendpoints/setup_test.go index 91fa63de68..db094306ef 100644 --- a/server/internal/mcpendpoints/setup_test.go +++ b/server/internal/mcpendpoints/setup_test.go @@ -140,14 +140,14 @@ func seedMcpServer(t *testing.T, ctx context.Context, conn *pgxpool.Pool, projec mcpServerID, err := uuid.NewV7() require.NoError(t, err) frontend, err := mcpserversrepo.New(conn).CreateMCPServer(ctx, mcpserversrepo.CreateMCPServerParams{ - ID: mcpServerID, - ProjectID: projectID, - Name: conv.ToPGText("test mcp server"), - Slug: conv.ToPGText("test-mcp-server-" + mcpServerID.String()[len(mcpServerID.String())-4:]), - EnvironmentID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - RemoteMcpServerID: uuid.NullUUID{UUID: server.ID, Valid: true}, - ToolsetID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - Visibility: "disabled", + ID: mcpServerID, + ProjectID: projectID, + Name: conv.ToPGText("test mcp server"), + Slug: conv.ToPGText("test-mcp-server-" + mcpServerID.String()[len(mcpServerID.String())-4:]), + EnvironmentID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, + RemoteMcpServerID: uuid.NullUUID{UUID: server.ID, Valid: true}, + ToolsetID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, + Visibility: "disabled", }) require.NoError(t, err) @@ -177,14 +177,14 @@ func seedOtherProjectMcpFrontend(t *testing.T, ctx context.Context, conn *pgxpoo mcpServerID, err := uuid.NewV7() require.NoError(t, err) frontend, err := mcpserversrepo.New(conn).CreateMCPServer(ctx, mcpserversrepo.CreateMCPServerParams{ - ID: mcpServerID, - ProjectID: otherProject.ID, - Name: conv.ToPGText("test mcp server"), - Slug: conv.ToPGText("test-mcp-server-" + mcpServerID.String()[len(mcpServerID.String())-4:]), - EnvironmentID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - RemoteMcpServerID: uuid.NullUUID{UUID: server.ID, Valid: true}, - ToolsetID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - Visibility: "disabled", + ID: mcpServerID, + ProjectID: otherProject.ID, + Name: conv.ToPGText("test mcp server"), + Slug: conv.ToPGText("test-mcp-server-" + mcpServerID.String()[len(mcpServerID.String())-4:]), + EnvironmentID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, + RemoteMcpServerID: uuid.NullUUID{UUID: server.ID, Valid: true}, + ToolsetID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, + Visibility: "disabled", }) require.NoError(t, err) diff --git a/server/internal/mcpservers/deletemcpserver_test.go b/server/internal/mcpservers/deletemcpserver_test.go index f5c8781027..2cc836be2d 100644 --- a/server/internal/mcpservers/deletemcpserver_test.go +++ b/server/internal/mcpservers/deletemcpserver_test.go @@ -26,14 +26,14 @@ func TestDeleteMcpServer(t *testing.T) { serverID := seedRemoteMcpServer(t, ctx, ti.conn, *authCtx.ProjectID).String() created, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &serverID, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &serverID, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) @@ -88,14 +88,14 @@ func TestDeleteMcpServer_CascadesSoftDeleteToSlugs(t *testing.T) { // Create a frontend and two slugs that both point at it. serverID := seedRemoteMcpServer(t, ctx, ti.conn, *authCtx.ProjectID).String() frontend, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &serverID, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &serverID, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) diff --git a/server/internal/mcpservers/listmcpservers_test.go b/server/internal/mcpservers/listmcpservers_test.go index 00b1894cf8..d12e675623 100644 --- a/server/internal/mcpservers/listmcpservers_test.go +++ b/server/internal/mcpservers/listmcpservers_test.go @@ -40,14 +40,14 @@ func TestListMcpServers_Multiple(t *testing.T) { for _, sid := range []string{serverA, serverB} { _, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &sid, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &sid, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) } @@ -75,26 +75,26 @@ func TestListMcpServers_FilterByRemoteMcpServerID(t *testing.T) { otherRemote := seedRemoteMcpServer(t, ctx, ti.conn, *authCtx.ProjectID).String() wanted, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &wantedRemote, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &wantedRemote, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) _, err = ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &otherRemote, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &otherRemote, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) @@ -120,14 +120,14 @@ func TestListMcpServers_FilterByRemoteMcpServerID_NoMatches(t *testing.T) { existingRemote := seedRemoteMcpServer(t, ctx, ti.conn, *authCtx.ProjectID).String() _, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &existingRemote, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &existingRemote, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) diff --git a/server/internal/mcpservers/ownership_test.go b/server/internal/mcpservers/ownership_test.go index fa9e98f3b6..f8e7db8336 100644 --- a/server/internal/mcpservers/ownership_test.go +++ b/server/internal/mcpservers/ownership_test.go @@ -83,14 +83,14 @@ func TestCreateMcpServer_RejectsCrossTenantToolset(t *testing.T) { otherToolsetID := seedOtherProjectToolset(t, ctx, ti.conn, authCtx.ActiveOrganizationID).String() _, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: nil, - ToolsetID: &otherToolsetID, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: nil, + ToolsetID: &otherToolsetID, + Visibility: types.McpServerVisibility("disabled"), }) requireOopsCode(t, err, oops.CodeInvalid) } @@ -106,14 +106,14 @@ func TestUpdateMcpServer_RejectsCrossTenantToolset(t *testing.T) { // Start with a valid frontend pointing at a same-project remote MCP server. ownServerID := seedRemoteMcpServer(t, ctx, ti.conn, *authCtx.ProjectID).String() created, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &ownServerID, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &ownServerID, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) @@ -121,14 +121,14 @@ func TestUpdateMcpServer_RejectsCrossTenantToolset(t *testing.T) { otherToolsetID := seedOtherProjectToolset(t, ctx, ti.conn, authCtx.ActiveOrganizationID).String() _, err = ti.service.UpdateMcpServer(ctx, &gen.UpdateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - ID: created.ID, - EnvironmentID: nil, - RemoteMcpServerID: nil, - ToolsetID: &otherToolsetID, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + ID: created.ID, + EnvironmentID: nil, + RemoteMcpServerID: nil, + ToolsetID: &otherToolsetID, + Visibility: types.McpServerVisibility("disabled"), }) requireOopsCode(t, err, oops.CodeInvalid) } @@ -145,14 +145,14 @@ func TestCreateMcpServer_RejectsCrossTenantRemoteMcpServer(t *testing.T) { otherServerID := seedOtherProjectRemoteMcpServer(t, ctx, ti.conn, authCtx.ActiveOrganizationID).String() _, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &otherServerID, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &otherServerID, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) requireOopsCode(t, err, oops.CodeInvalid) } @@ -168,14 +168,14 @@ func TestUpdateMcpServer_RejectsCrossTenantRemoteMcpServer(t *testing.T) { // Start with a valid frontend in the caller's own project. ownServerID := seedRemoteMcpServer(t, ctx, ti.conn, *authCtx.ProjectID).String() created, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &ownServerID, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &ownServerID, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) @@ -183,14 +183,14 @@ func TestUpdateMcpServer_RejectsCrossTenantRemoteMcpServer(t *testing.T) { otherServerID := seedOtherProjectRemoteMcpServer(t, ctx, ti.conn, authCtx.ActiveOrganizationID).String() _, err = ti.service.UpdateMcpServer(ctx, &gen.UpdateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - ID: created.ID, - EnvironmentID: nil, - RemoteMcpServerID: &otherServerID, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + ID: created.ID, + EnvironmentID: nil, + RemoteMcpServerID: &otherServerID, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) requireOopsCode(t, err, oops.CodeInvalid) } diff --git a/server/internal/mcpservers/updatemcpserver_test.go b/server/internal/mcpservers/updatemcpserver_test.go index 172d19601a..8265dd09cd 100644 --- a/server/internal/mcpservers/updatemcpserver_test.go +++ b/server/internal/mcpservers/updatemcpserver_test.go @@ -26,14 +26,14 @@ func TestUpdateMcpServer_FullReplace(t *testing.T) { serverB := seedRemoteMcpServer(t, ctx, ti.conn, *authCtx.ProjectID).String() created, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &serverA, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &serverA, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) @@ -43,14 +43,14 @@ func TestUpdateMcpServer_FullReplace(t *testing.T) { // Full-record replace: swap backend to serverB, flip visibility, drop // any optional fields. updated, err := ti.service.UpdateMcpServer(ctx, &gen.UpdateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - ID: created.ID, - EnvironmentID: nil, - RemoteMcpServerID: &serverB, - ToolsetID: nil, - Visibility: types.McpServerVisibility("public"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + ID: created.ID, + EnvironmentID: nil, + RemoteMcpServerID: &serverB, + ToolsetID: nil, + Visibility: types.McpServerVisibility("public"), }) require.NoError(t, err) require.Equal(t, created.ID, updated.ID) @@ -79,27 +79,27 @@ func TestUpdateMcpServer_InvalidBackend(t *testing.T) { serverID := seedRemoteMcpServer(t, ctx, ti.conn, *authCtx.ProjectID).String() created, err := ti.service.CreateMcpServer(ctx, &gen.CreateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - Name: "test mcp server", - EnvironmentID: nil, - RemoteMcpServerID: &serverID, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Name: "test mcp server", + EnvironmentID: nil, + RemoteMcpServerID: &serverID, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) require.NoError(t, err) // Update with neither backend — should fail validation. _, err = ti.service.UpdateMcpServer(ctx, &gen.UpdateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - ID: created.ID, - EnvironmentID: nil, - RemoteMcpServerID: nil, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + ID: created.ID, + EnvironmentID: nil, + RemoteMcpServerID: nil, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) requireOopsCode(t, err, oops.CodeInvalid) } @@ -115,14 +115,14 @@ func TestUpdateMcpServer_NotFound(t *testing.T) { serverID := seedRemoteMcpServer(t, ctx, ti.conn, *authCtx.ProjectID).String() _, err := ti.service.UpdateMcpServer(ctx, &gen.UpdateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - ID: uuid.NewString(), - EnvironmentID: nil, - RemoteMcpServerID: &serverID, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + ID: uuid.NewString(), + EnvironmentID: nil, + RemoteMcpServerID: &serverID, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) requireOopsCode(t, err, oops.CodeNotFound) } @@ -405,14 +405,14 @@ func TestUpdateMcpServer_RBACForbidden(t *testing.T) { ctx = withExactAuthzGrants(t, ctx, ti.conn) _, err := ti.service.UpdateMcpServer(ctx, &gen.UpdateMcpServerPayload{ - SessionToken: nil, - ApikeyToken: nil, - ProjectSlugInput: nil, - ID: uuid.NewString(), - EnvironmentID: nil, - RemoteMcpServerID: nil, - ToolsetID: nil, - Visibility: types.McpServerVisibility("disabled"), + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + ID: uuid.NewString(), + EnvironmentID: nil, + RemoteMcpServerID: nil, + ToolsetID: nil, + Visibility: types.McpServerVisibility("disabled"), }) requireOopsCode(t, err, oops.CodeForbidden) } From 34ce44d286bab9a1fdd576e810ff6442e16a9f87 Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Fri, 22 May 2026 10:44:40 +0100 Subject: [PATCH 11/13] docs(changeset): Fixes login journey for allowed orgs --- .changeset/dirty-lamps-fetch.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/dirty-lamps-fetch.md diff --git a/.changeset/dirty-lamps-fetch.md b/.changeset/dirty-lamps-fetch.md new file mode 100644 index 0000000000..90b241b274 --- /dev/null +++ b/.changeset/dirty-lamps-fetch.md @@ -0,0 +1,6 @@ +--- +"dashboard": patch +"server": patch +--- + +Fixes login journey for allowed orgs From 790ecf728b186b18dc00ca060f3f590e99a0b236 Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Fri, 22 May 2026 10:50:32 +0100 Subject: [PATCH 12/13] fix(auth): update tests for always-on WorkOS membership sync SyncMembershipsFromWorkOS now runs on every login (not just org-scoped logins). Update affected tests: - e2e_test: WorkOS mock now returns the same org as in the DB instead of a ghost org, reflecting that sync runs and both sources agree. - info_test: admin in own org now sees all memberships (org-switcher fix); use unique user ID to avoid Redis cache collisions between parallel tests. Co-Authored-By: Claude Sonnet 4.6 --- server/internal/auth/e2e_test.go | 10 +++++----- server/internal/auth/info_test.go | 12 +++++++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/server/internal/auth/e2e_test.go b/server/internal/auth/e2e_test.go index 64bc1756a0..0af86bce4e 100644 --- a/server/internal/auth/e2e_test.go +++ b/server/internal/auth/e2e_test.go @@ -361,20 +361,20 @@ func TestE2E_Callback_NewUserNoWorkOSOrgs_AssistantsDisposition(t *testing.T) { } // TestE2E_Callback_ExistingUserWithDBOrgs verifies the happy path: user -// already has orgs in the DB, so the WorkOS fallback is skipped entirely. +// already has an org in the DB and WorkOS confirms the same membership. +// Sync runs on every login; when WorkOS and DB agree the active org is unchanged. func TestE2E_Callback_ExistingUserWithDBOrgs(t *testing.T) { t.Parallel() const workosUserID = "user_01WORKOS_EXISTING" - // Fetcher has memberships, but they should NOT be consulted because the DB - // already has org data. + // WorkOS returns the same org that already exists in the DB. fetcher := &mockWorkOSFetcher{ members: map[string][]workos.Member{ - workosUserID: {{ID: "om_99", UserID: workosUserID, OrganizationID: "org_SHOULD_NOT_APPEAR", Organization: "Ghost", RoleSlug: "admin"}}, + workosUserID: {{ID: "om_99", UserID: workosUserID, OrganizationID: "org_01DB_EXISTING", Organization: "DB Corp", RoleSlug: "admin"}}, }, orgs: map[string]*workos.Organization{ - "org_SHOULD_NOT_APPEAR": {ID: "org_SHOULD_NOT_APPEAR", Name: "Ghost"}, + "org_01DB_EXISTING": {ID: "org_01DB_EXISTING", Name: "DB Corp"}, }, } diff --git a/server/internal/auth/info_test.go b/server/internal/auth/info_test.go index 526096f54e..6e987075ce 100644 --- a/server/internal/auth/info_test.go +++ b/server/internal/auth/info_test.go @@ -87,6 +87,9 @@ func TestService_Info(t *testing.T) { t.Parallel() userInfo := adminMockUserInfo() + // Use a unique ID to avoid Redis cache collisions with other parallel admin tests. + userInfo.UserID = "admin-multiorg-test-user" + userInfo.Email = "admin-multiorg@speakeasyapi.dev" // Add additional organizations to test filtering userInfo.Organizations = append(userInfo.Organizations, MockOrganizationEntry{ ID: "other-org-456", @@ -137,9 +140,12 @@ func TestService_Info(t *testing.T) { require.NotNil(t, result) require.True(t, result.IsAdmin) - // Admin should only see the active organization - require.Len(t, result.Organizations, 1) - require.Equal(t, userInfo.Organizations[0].ID, result.Organizations[0].ID) + // Admin in their own org sees all real memberships so the org-switcher works. + // Only when overriding into a foreign org is the list collapsed to 1. + require.Len(t, result.Organizations, 2) + orgIDs := []string{result.Organizations[0].ID, result.Organizations[1].ID} + require.Contains(t, orgIDs, userInfo.Organizations[0].ID) + require.Contains(t, orgIDs, userInfo.Organizations[1].ID) }) t.Run("info returns non-member org for admin override", func(t *testing.T) { From 601e73c487a81740eba31fe8af7be16ed14d204e Mon Sep 17 00:00:00 2001 From: dennnis-ez Date: Fri, 22 May 2026 10:55:14 +0100 Subject: [PATCH 13/13] chore: lint --- client/dashboard/src/contexts/AuthProvider.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/dashboard/src/contexts/AuthProvider.tsx b/client/dashboard/src/contexts/AuthProvider.tsx index fd9275fa57..569c5a1720 100644 --- a/client/dashboard/src/contexts/AuthProvider.tsx +++ b/client/dashboard/src/contexts/AuthProvider.tsx @@ -10,7 +10,6 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import BookDemo from "@/pages/demo/BookDemo"; import SwitchOrg from "@/pages/demo/SwitchOrg"; -import { Icon } from "@speakeasy-api/moonshine"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { useIsAdminRef } from "@/contexts/Sdk";