Enterprise sso (#10008)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
chuckbutkus 2025-08-04 17:50:59 -04:00 committed by GitHub
parent 0e2f2f4173
commit 97bfa96a15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 203 additions and 62 deletions

View File

@ -1,27 +1,64 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, test, vi, afterEach } from "vitest";
import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactElement } from "react";
// Create a mock for useIsAuthed that we can control per test
const useIsAuthedMock = vi
.fn()
.mockReturnValue({ data: true, isLoading: false });
// Mock the useIsAuthed hook
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => useIsAuthedMock(),
}));
describe("UserActions", () => {
const user = userEvent.setup();
const onClickAccountSettingsMock = vi.fn();
const onLogoutMock = vi.fn();
// Create a wrapper with QueryClientProvider
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
};
beforeEach(() => {
// Reset the mock to default value before each test
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
});
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
vi.clearAllMocks();
});
it("should render", () => {
render(<UserActions onLogout={onLogoutMock} />);
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should toggle the user menu when the user avatar is clicked", async () => {
render(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@ -43,7 +80,7 @@ describe("UserActions", () => {
});
it("should call onLogout and close the menu when the logout option is clicked", async () => {
render(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@ -62,20 +99,25 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
});
it("should show context menu when user is undefined and avatar is clicked", async () => {
render(<UserActions onLogout={onLogoutMock} />);
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu SHOULD appear even when user is undefined
// Context menu should NOT appear because user is not authenticated
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should show context menu even when user has no avatar_url", async () => {
render(<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />);
renderWithQueryClient(
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@ -86,52 +128,66 @@ describe("UserActions", () => {
).toBeInTheDocument();
});
it("should be able to access logout even when no user is provided", async () => {
render(<UserActions onLogout={onLogoutMock} />);
it("should NOT be able to access logout when user is not authenticated", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Logout option should be accessible even when no user is provided
// Context menu should NOT appear because user is not authenticated
expect(
screen.getByText("ACCOUNT_SETTINGS$LOGOUT"),
).toBeInTheDocument();
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Verify logout works
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
// Logout option should NOT be accessible when user is not authenticated
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should handle user prop changing from undefined to defined", async () => {
const { rerender } = render(<UserActions onLogout={onLogoutMock} />);
// Start with no authentication
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Initially no user - but we can still click to show the menu
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
const { rerender } = renderWithQueryClient(
<UserActions onLogout={onLogoutMock} />,
);
// Close the menu
// Initially no user and not authenticated - menu should not appear
let userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Add user prop
// Set authentication to true for the rerender
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
// Add user prop and create a new QueryClient to ensure fresh state
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
rerender(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
<QueryClientProvider client={queryClient}>
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>
</QueryClientProvider>,
);
// Component should still render correctly
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
// Menu should still work with user defined
// Menu should now work with user defined and authenticated
userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
@ -139,7 +195,7 @@ describe("UserActions", () => {
});
it("should handle user prop changing from defined to undefined", async () => {
const { rerender } = render(
const { rerender } = renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@ -153,22 +209,27 @@ describe("UserActions", () => {
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
// Remove user prop - menu should still be visible
rerender(<UserActions onLogout={onLogoutMock} />);
// Set authentication to false for the rerender
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Context menu should remain visible even when user becomes undefined
// Remove user prop - menu should disappear because user is no longer authenticated
rerender(
<QueryClientProvider client={new QueryClient()}>
<UserActions onLogout={onLogoutMock} />
</QueryClientProvider>,
);
// Context menu should NOT be visible when user becomes unauthenticated
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Verify logout still works
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
// Logout option should not be accessible
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should work with loading state and user provided", async () => {
render(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}

View File

@ -101,7 +101,8 @@ describe("Content", () => {
renderSecretsSettings();
expect(getSecretsSpy).not.toHaveBeenCalled();
// In SAAS mode, getSecrets is still called because the user is authenticated
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
);

View File

@ -22,7 +22,7 @@ export function AccountSettingsContextMenu({
ref={ref}
className="absolute right-full md:left-full -top-1 z-10 w-fit"
>
<ContextMenuListItem onClick={onLogout}>
<ContextMenuListItem onClick={onLogout} data-testid="logout-button">
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>
</ContextMenu>

View File

@ -1,6 +1,7 @@
import React from "react";
import { UserAvatar } from "./user-avatar";
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
interface UserActionsProps {
onLogout: () => void;
@ -9,6 +10,7 @@ interface UserActionsProps {
}
export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
const { data: isAuthed } = useIsAuthed();
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
React.useState(false);
@ -26,6 +28,9 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
closeAccountMenu();
};
// Always show the menu for authenticated users, even without user data
const showMenu = accountContextMenuIsVisible && isAuthed;
return (
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
<UserAvatar
@ -34,7 +39,7 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
isLoading={isLoading}
/>
{accountContextMenuIsVisible && (
{showMenu && (
<AccountSettingsContextMenu
onLogout={handleLogout}
onClose={closeAccountMenu}

View File

@ -14,6 +14,7 @@ interface UserAvatarProps {
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
const { t } = useTranslation();
return (
<TooltipButton
testId="user-avatar"

View File

@ -35,6 +35,11 @@ export function AuthModal({
identityProvider: "bitbucket",
});
const enterpriseSsoUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "enterprise_sso",
});
const handleGitHubAuth = () => {
if (githubAuthUrl) {
// Always start the OIDC flow, let the backend handle TOS check
@ -56,6 +61,13 @@ export function AuthModal({
}
};
const handleEnterpriseSsoAuth = () => {
if (enterpriseSsoUrl) {
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = enterpriseSsoUrl;
}
};
// Only show buttons if providers are configured and include the specific provider
const showGithub =
providersConfigured &&
@ -69,6 +81,10 @@ export function AuthModal({
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("bitbucket");
const showEnterpriseSso =
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("enterprise_sso");
// Check if no providers are configured
const noProvidersConfigured =
@ -126,6 +142,17 @@ export function AuthModal({
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</BrandButton>
)}
{showEnterpriseSso && (
<BrandButton
type="button"
variant="primary"
onClick={handleEnterpriseSsoAuth}
className="w-full"
>
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
</BrandButton>
)}
</>
)}
</div>

View File

@ -31,7 +31,7 @@ interface ConversationSubscriptionsContextType {
subscribeToConversation: (options: {
conversationId: string;
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket")[];
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
baseUrl: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => void;
@ -135,7 +135,7 @@ export function ConversationSubscriptionsProvider({
(options: {
conversationId: string;
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket")[];
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
baseUrl: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => {
@ -226,6 +226,7 @@ export function ConversationSubscriptionsProvider({
});
socket.on("connect_error", (error) => {
// eslint-disable-next-line no-console
console.warn(
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
error,
@ -233,6 +234,7 @@ export function ConversationSubscriptionsProvider({
});
socket.on("disconnect", (reason) => {
// eslint-disable-next-line no-console
console.warn(
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
reason,

View File

@ -1,17 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { useUserProviders } from "../use-user-providers";
import { useConfig } from "./use-config";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
export const useGetSecrets = () => {
const { data: config } = useConfig();
const { providers } = useUserProviders();
const { data: isAuthed } = useIsAuthed();
const isOss = config?.APP_MODE === "oss";
return useQuery({
queryKey: ["secrets"],
queryFn: SecretsService.getSecrets,
enabled: isOss || providers.length > 0,
enabled: isOss || isAuthed, // Enable regardless of providers
});
};

View File

@ -3,16 +3,16 @@ import React from "react";
import posthog from "posthog-js";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
export const useGitUser = () => {
const { providers } = useUserProviders();
const { data: config } = useConfig();
const { data: isAuthed } = useIsAuthed();
const user = useQuery({
queryKey: ["user"],
queryFn: OpenHands.getGitUser,
enabled: !!config?.APP_MODE && providers.length > 0,
enabled: !!config?.APP_MODE && isAuthed, // Enable regardless of providers
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes

View File

@ -31,6 +31,11 @@ export const useAutoLogin = () => {
identityProvider: "bitbucket",
});
const enterpriseSsoUrl = useAuthUrl({
appMode: config?.APP_MODE || null,
identityProvider: "enterprise_sso",
});
useEffect(() => {
// Only auto-login in SAAS mode
if (config?.APP_MODE !== "saas") {
@ -60,6 +65,8 @@ export const useAutoLogin = () => {
authUrl = gitlabAuthUrl;
} else if (loginMethod === LoginMethod.BITBUCKET) {
authUrl = bitbucketAuthUrl;
} else if (loginMethod === LoginMethod.ENTERPRISE_SSO) {
authUrl = enterpriseSsoUrl;
}
// If we have an auth URL, redirect to it
@ -80,5 +87,6 @@ export const useAutoLogin = () => {
githubAuthUrl,
gitlabAuthUrl,
bitbucketAuthUrl,
enterpriseSsoUrl,
]);
};

View File

@ -559,6 +559,7 @@ export enum I18nKey {
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
BITBUCKET$CONNECT_TO_BITBUCKET = "BITBUCKET$CONNECT_TO_BITBUCKET",
ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO = "ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO",
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",

View File

@ -8943,6 +8943,22 @@
"tr": "Bitbucket'a bağlan",
"uk": "Увійти за допомогою Bitbucket"
},
"ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO": {
"en": "Login with Enterprise SSO",
"ja": "エンタープライズSSOでログイン",
"zh-CN": "使用企业SSO登录",
"zh-TW": "使用企業SSO登入",
"ko-KR": "엔터프라이즈 SSO로 로그인",
"de": "Mit Enterprise SSO anmelden",
"no": "Logg inn med Enterprise SSO",
"it": "Accedi con Enterprise SSO",
"pt": "Entrar com Enterprise SSO",
"es": "Iniciar sesión con Enterprise SSO",
"ar": "تسجيل الدخول باستخدام Enterprise SSO",
"fr": "Se connecter avec Enterprise SSO",
"tr": "Enterprise SSO ile giriş yap",
"uk": "Увійти за допомогою Enterprise SSO"
},
"AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER": {
"en": "Log in to OpenHands",
"ja": "IDプロバイダーでサインイン",

View File

@ -2,6 +2,7 @@ export const ProviderOptions = {
github: "github",
gitlab: "gitlab",
bitbucket: "bitbucket",
enterprise_sso: "enterprise_sso",
} as const;
export type Provider = keyof typeof ProviderOptions;

View File

@ -8,6 +8,7 @@ export enum LoginMethod {
GITHUB = "github",
GITLAB = "gitlab",
BITBUCKET = "bitbucket",
ENTERPRISE_SSO = "enterprise_sso",
}
/**

View File

@ -18,6 +18,7 @@ class ProviderType(Enum):
GITHUB = 'github'
GITLAB = 'gitlab'
BITBUCKET = 'bitbucket'
ENTERPRISE_SSO = 'enterprise_sso'
class TaskType(str, Enum):

View File

@ -28,7 +28,8 @@ from openhands.server.routes.secrets import app as secrets_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.shared import conversation_manager
from openhands.server.shared import conversation_manager, server_config
from openhands.server.types import AppMode
mcp_app = mcp_server.http_app(path='/mcp')
@ -68,6 +69,7 @@ app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(secrets_router)
app.include_router(git_api_router)
if server_config.app_mode == AppMode.OSS:
app.include_router(git_api_router)
app.include_router(trajectory_router)
add_health_endpoints(app)

23
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "aiofiles"
@ -3770,6 +3770,22 @@ http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "httpx-aiohttp"
version = "0.1.8"
description = "Aiohttp transport for HTTPX"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpx_aiohttp-0.1.8-py3-none-any.whl", hash = "sha256:b7bd958d1331f3759a38a0ba22ad29832cb63ca69498c17735228055bf78fa7e"},
{file = "httpx_aiohttp-0.1.8.tar.gz", hash = "sha256:756c5e74cdb568c3248ba63fe82bfe8bbe64b928728720f7eaac64b3cf46f308"},
]
[package.dependencies]
aiohttp = ">=3.10.0,<4"
httpx = ">=0.27.0"
[[package]]
name = "httpx-sse"
version = "0.4.0"
@ -5136,11 +5152,8 @@ files = [
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
@ -11753,4 +11766,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "d957f92f0d194e78b1cbf4b5a31c28df83e34e508d2c9810de96204db8e8f158"
content-hash = "8568c6ec2e11d4fcb23e206a24896b4d2d50e694c04011b668148f484e95b406"

View File

@ -99,6 +99,7 @@ e2b = { version = ">=1.0.5,<1.8.0", optional = true }
modal = { version = ">=0.66.26,<1.2.0", optional = true }
runloop-api-client = { version = "0.50.0", optional = true }
daytona = { version = "0.24.2", optional = true }
httpx-aiohttp = "^0.1.8"
[tool.poetry.extras]
third_party_runtimes = [ "e2b", "modal", "runloop-api-client", "daytona" ]