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 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 cd988782e4..569c5a1720 100644 --- a/client/dashboard/src/contexts/AuthProvider.tsx +++ b/client/dashboard/src/contexts/AuthProvider.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/sidebar"; import { Skeleton } from "@/components/ui/skeleton"; import BookDemo from "@/pages/demo/BookDemo"; +import SwitchOrg from "@/pages/demo/SwitchOrg"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { useIsAdminRef } from "@/contexts/Sdk"; @@ -43,7 +44,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 ( @@ -95,6 +96,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..cf536081e0 --- /dev/null +++ b/client/dashboard/src/pages/demo/SwitchOrg.tsx @@ -0,0 +1,125 @@ +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 [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."} +

+
+ +
+ + + +
+
+
+ ); +} 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/impl.go b/server/internal/auth/impl.go index bc13ecd0c8..9f38722290 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) } @@ -604,19 +604,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. + // 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 { - found := false + 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{{ 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) {