Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dirty-lamps-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"dashboard": patch
"server": patch
---

Fixes login journey for allowed orgs
4 changes: 4 additions & 0 deletions client/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -245,6 +246,9 @@ const RouteProvider = () => {
<Route path="/slack/register" element={<LoginCheck />}>
<Route index element={<SlackRegister />} />
</Route>
<Route path="/switch-org" element={<LoginCheck />}>
<Route index element={<SwitchOrg />} />
</Route>
<Route path="/" element={<LoginCheck />}>
<Route path=":orgSlug/projects/:projectSlug">
{routesWithSubroutes(outsideStructureRoutes)}
Expand Down
13 changes: 12 additions & 1 deletion client/dashboard/src/components/top-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
useIsAdmin,
useOrganization,
useProject,
useSession,
useUser,
} from "@/contexts/Auth";
import { useSdkClient, useSlugs } from "@/contexts/Sdk";
Expand All @@ -18,6 +19,7 @@ import {
} from "@speakeasy-api/moonshine";
import {
BugIcon,
BuildingIcon,
CheckIcon,
ChevronsUpDown,
ArrowRightLeftIcon,
Expand All @@ -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";
Expand All @@ -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(() => {
Expand Down Expand Up @@ -283,6 +288,12 @@ export function TopHeader() {
Organization Override
</DropdownMenuItem>
)}
{isMultiOrg && (
<DropdownMenuItem onClick={() => navigate("/switch-org")}>
<BuildingIcon className="mr-2 h-4 w-4" />
Switch Organization
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
Expand Down
6 changes: 5 additions & 1 deletion client/dashboard/src/contexts/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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) {
Comment thread
dennnis-ez marked this conversation as resolved.
return <SwitchOrg gate />;
}
return <BookDemo />;
}

Expand Down
125 changes: 125 additions & 0 deletions client/dashboard/src/pages/demo/SwitchOrg.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("");
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 (
<main className="flex min-h-screen flex-col md:flex-row">
<JourneyDemo />

<AuthLayout
topRight={
gate ? (
<button
onClick={handleLogout}
className="flex items-center gap-1.5 text-xs text-[#8B8684] transition-colors hover:text-slate-600"
>
<LogOutIcon className="h-3.5 w-3.5" />
Log out
</button>
) : undefined
}
>
<div className="flex flex-col items-center gap-3 text-center">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full ${gate ? "bg-amber-50" : "bg-blue-50"}`}
>
{gate ? (
<AlertCircleIcon className="h-6 w-6 text-amber-500" />
) : (
<BuildingIcon className="h-6 w-6 text-blue-500" />
)}
</div>
<h1 className="text-2xl font-bold tracking-tight text-gray-900">
{gate ? `No access for ${currentOrgName}` : "Switch organization"}
</h1>
<p className="text-sm leading-relaxed text-[#8B8684]">
{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."}
</p>
</div>

<div className="flex w-full flex-col gap-3">
<Select value={selectedOrgId} onValueChange={setSelectedOrgId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an organization" />
</SelectTrigger>
<SelectContent>
{allOrgs.map((org) => {
const isCurrent = org.id === session?.activeOrganizationId;
return (
<SelectItem key={org.id} value={org.id} disabled={isCurrent}>
<span className="flex items-center gap-2">
{org.name || org.slug}
{isCurrent && (
<span className="rounded-sm bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">
current
</span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>

<Button
variant="brand"
className="w-full"
onClick={handleSwitch}
disabled={
!selectedOrgId ||
selectedOrgId === session?.activeOrganizationId ||
isSwitching
}
>
{isSwitching ? "Switching…" : "Switch organization"}
</Button>
</div>
</AuthLayout>
</main>
);
}
10 changes: 5 additions & 5 deletions server/internal/auth/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
}

Expand Down
15 changes: 7 additions & 8 deletions server/internal/auth/impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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{{
Expand Down
12 changes: 9 additions & 3 deletions server/internal/auth/info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
Loading