mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Enterprise sso (#10008)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
parent
0e2f2f4173
commit
97bfa96a15
@ -1,27 +1,64 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { UserActions } from "#/components/features/sidebar/user-actions";
|
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", () => {
|
describe("UserActions", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClickAccountSettingsMock = vi.fn();
|
const onClickAccountSettingsMock = vi.fn();
|
||||||
const onLogoutMock = 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(() => {
|
afterEach(() => {
|
||||||
onClickAccountSettingsMock.mockClear();
|
onClickAccountSettingsMock.mockClear();
|
||||||
onLogoutMock.mockClear();
|
onLogoutMock.mockClear();
|
||||||
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render", () => {
|
it("should render", () => {
|
||||||
render(<UserActions onLogout={onLogoutMock} />);
|
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should toggle the user menu when the user avatar is clicked", async () => {
|
it("should toggle the user menu when the user avatar is clicked", async () => {
|
||||||
render(
|
renderWithQueryClient(
|
||||||
<UserActions
|
<UserActions
|
||||||
onLogout={onLogoutMock}
|
onLogout={onLogoutMock}
|
||||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
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 () => {
|
it("should call onLogout and close the menu when the logout option is clicked", async () => {
|
||||||
render(
|
renderWithQueryClient(
|
||||||
<UserActions
|
<UserActions
|
||||||
onLogout={onLogoutMock}
|
onLogout={onLogoutMock}
|
||||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||||
@ -62,20 +99,25 @@ describe("UserActions", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show context menu when user is undefined and avatar is clicked", async () => {
|
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
|
||||||
render(<UserActions onLogout={onLogoutMock} />);
|
// Set isAuthed to false for this test
|
||||||
|
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||||
|
|
||||||
|
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
|
||||||
|
|
||||||
const userAvatar = screen.getByTestId("user-avatar");
|
const userAvatar = screen.getByTestId("user-avatar");
|
||||||
await user.click(userAvatar);
|
await user.click(userAvatar);
|
||||||
|
|
||||||
// Context menu SHOULD appear even when user is undefined
|
// Context menu should NOT appear because user is not authenticated
|
||||||
expect(
|
expect(
|
||||||
screen.getByTestId("account-settings-context-menu"),
|
screen.queryByTestId("account-settings-context-menu"),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show context menu even when user has no avatar_url", async () => {
|
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");
|
const userAvatar = screen.getByTestId("user-avatar");
|
||||||
await user.click(userAvatar);
|
await user.click(userAvatar);
|
||||||
@ -86,52 +128,66 @@ describe("UserActions", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be able to access logout even when no user is provided", async () => {
|
it("should NOT be able to access logout when user is not authenticated", async () => {
|
||||||
render(<UserActions onLogout={onLogoutMock} />);
|
// Set isAuthed to false for this test
|
||||||
|
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||||
|
|
||||||
|
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
|
||||||
|
|
||||||
const userAvatar = screen.getByTestId("user-avatar");
|
const userAvatar = screen.getByTestId("user-avatar");
|
||||||
await user.click(userAvatar);
|
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(
|
expect(
|
||||||
screen.getByText("ACCOUNT_SETTINGS$LOGOUT"),
|
screen.queryByTestId("account-settings-context-menu"),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Verify logout works
|
// Logout option should NOT be accessible when user is not authenticated
|
||||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
|
||||||
await user.click(logoutOption);
|
|
||||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle user prop changing from undefined to defined", async () => {
|
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 { rerender } = renderWithQueryClient(
|
||||||
const userAvatar = screen.getByTestId("user-avatar");
|
<UserActions onLogout={onLogoutMock} />,
|
||||||
await user.click(userAvatar);
|
);
|
||||||
expect(
|
|
||||||
screen.getByTestId("account-settings-context-menu"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Close the menu
|
// Initially no user and not authenticated - menu should not appear
|
||||||
|
let userAvatar = screen.getByTestId("user-avatar");
|
||||||
await user.click(userAvatar);
|
await user.click(userAvatar);
|
||||||
expect(
|
expect(
|
||||||
screen.queryByTestId("account-settings-context-menu"),
|
screen.queryByTestId("account-settings-context-menu"),
|
||||||
).not.toBeInTheDocument();
|
).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(
|
rerender(
|
||||||
<UserActions
|
<QueryClientProvider client={queryClient}>
|
||||||
onLogout={onLogoutMock}
|
<UserActions
|
||||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
onLogout={onLogoutMock}
|
||||||
/>,
|
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Component should still render correctly
|
// Component should still render correctly
|
||||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("user-avatar")).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);
|
await user.click(userAvatar);
|
||||||
expect(
|
expect(
|
||||||
screen.getByTestId("account-settings-context-menu"),
|
screen.getByTestId("account-settings-context-menu"),
|
||||||
@ -139,7 +195,7 @@ describe("UserActions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle user prop changing from defined to undefined", async () => {
|
it("should handle user prop changing from defined to undefined", async () => {
|
||||||
const { rerender } = render(
|
const { rerender } = renderWithQueryClient(
|
||||||
<UserActions
|
<UserActions
|
||||||
onLogout={onLogoutMock}
|
onLogout={onLogoutMock}
|
||||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||||
@ -153,22 +209,27 @@ describe("UserActions", () => {
|
|||||||
screen.getByTestId("account-settings-context-menu"),
|
screen.getByTestId("account-settings-context-menu"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// Remove user prop - menu should still be visible
|
// Set authentication to false for the rerender
|
||||||
rerender(<UserActions onLogout={onLogoutMock} />);
|
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(
|
expect(
|
||||||
screen.getByTestId("account-settings-context-menu"),
|
screen.queryByTestId("account-settings-context-menu"),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Verify logout still works
|
// Logout option should not be accessible
|
||||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
|
||||||
await user.click(logoutOption);
|
|
||||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should work with loading state and user provided", async () => {
|
it("should work with loading state and user provided", async () => {
|
||||||
render(
|
renderWithQueryClient(
|
||||||
<UserActions
|
<UserActions
|
||||||
onLogout={onLogoutMock}
|
onLogout={onLogoutMock}
|
||||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||||
|
|||||||
@ -101,7 +101,8 @@ describe("Content", () => {
|
|||||||
|
|
||||||
renderSecretsSettings();
|
renderSecretsSettings();
|
||||||
|
|
||||||
expect(getSecretsSpy).not.toHaveBeenCalled();
|
// In SAAS mode, getSecrets is still called because the user is authenticated
|
||||||
|
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
|
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function AccountSettingsContextMenu({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className="absolute right-full md:left-full -top-1 z-10 w-fit"
|
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)}
|
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||||
</ContextMenuListItem>
|
</ContextMenuListItem>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { UserAvatar } from "./user-avatar";
|
import { UserAvatar } from "./user-avatar";
|
||||||
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
|
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
|
||||||
|
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||||
|
|
||||||
interface UserActionsProps {
|
interface UserActionsProps {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
@ -9,6 +10,7 @@ interface UserActionsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
||||||
|
const { data: isAuthed } = useIsAuthed();
|
||||||
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
|
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
|
||||||
React.useState(false);
|
React.useState(false);
|
||||||
|
|
||||||
@ -26,6 +28,9 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
|||||||
closeAccountMenu();
|
closeAccountMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Always show the menu for authenticated users, even without user data
|
||||||
|
const showMenu = accountContextMenuIsVisible && isAuthed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
|
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@ -34,7 +39,7 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{accountContextMenuIsVisible && (
|
{showMenu && (
|
||||||
<AccountSettingsContextMenu
|
<AccountSettingsContextMenu
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
onClose={closeAccountMenu}
|
onClose={closeAccountMenu}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ interface UserAvatarProps {
|
|||||||
|
|
||||||
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipButton
|
<TooltipButton
|
||||||
testId="user-avatar"
|
testId="user-avatar"
|
||||||
|
|||||||
@ -35,6 +35,11 @@ export function AuthModal({
|
|||||||
identityProvider: "bitbucket",
|
identityProvider: "bitbucket",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const enterpriseSsoUrl = useAuthUrl({
|
||||||
|
appMode: appMode || null,
|
||||||
|
identityProvider: "enterprise_sso",
|
||||||
|
});
|
||||||
|
|
||||||
const handleGitHubAuth = () => {
|
const handleGitHubAuth = () => {
|
||||||
if (githubAuthUrl) {
|
if (githubAuthUrl) {
|
||||||
// Always start the OIDC flow, let the backend handle TOS check
|
// 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
|
// Only show buttons if providers are configured and include the specific provider
|
||||||
const showGithub =
|
const showGithub =
|
||||||
providersConfigured &&
|
providersConfigured &&
|
||||||
@ -69,6 +81,10 @@ export function AuthModal({
|
|||||||
providersConfigured &&
|
providersConfigured &&
|
||||||
providersConfigured.length > 0 &&
|
providersConfigured.length > 0 &&
|
||||||
providersConfigured.includes("bitbucket");
|
providersConfigured.includes("bitbucket");
|
||||||
|
const showEnterpriseSso =
|
||||||
|
providersConfigured &&
|
||||||
|
providersConfigured.length > 0 &&
|
||||||
|
providersConfigured.includes("enterprise_sso");
|
||||||
|
|
||||||
// Check if no providers are configured
|
// Check if no providers are configured
|
||||||
const noProvidersConfigured =
|
const noProvidersConfigured =
|
||||||
@ -126,6 +142,17 @@ export function AuthModal({
|
|||||||
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
|
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
|
||||||
</BrandButton>
|
</BrandButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEnterpriseSso && (
|
||||||
|
<BrandButton
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleEnterpriseSsoAuth}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
|
||||||
|
</BrandButton>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -31,7 +31,7 @@ interface ConversationSubscriptionsContextType {
|
|||||||
subscribeToConversation: (options: {
|
subscribeToConversation: (options: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
sessionApiKey: string | null;
|
sessionApiKey: string | null;
|
||||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
onEvent?: (event: unknown, conversationId: string) => void;
|
onEvent?: (event: unknown, conversationId: string) => void;
|
||||||
}) => void;
|
}) => void;
|
||||||
@ -135,7 +135,7 @@ export function ConversationSubscriptionsProvider({
|
|||||||
(options: {
|
(options: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
sessionApiKey: string | null;
|
sessionApiKey: string | null;
|
||||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
onEvent?: (event: unknown, conversationId: string) => void;
|
onEvent?: (event: unknown, conversationId: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
@ -226,6 +226,7 @@ export function ConversationSubscriptionsProvider({
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on("connect_error", (error) => {
|
socket.on("connect_error", (error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.warn(
|
console.warn(
|
||||||
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
|
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
|
||||||
error,
|
error,
|
||||||
@ -233,6 +234,7 @@ export function ConversationSubscriptionsProvider({
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on("disconnect", (reason) => {
|
socket.on("disconnect", (reason) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.warn(
|
console.warn(
|
||||||
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
|
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
|
||||||
reason,
|
reason,
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { SecretsService } from "#/api/secrets-service";
|
import { SecretsService } from "#/api/secrets-service";
|
||||||
import { useUserProviders } from "../use-user-providers";
|
|
||||||
import { useConfig } from "./use-config";
|
import { useConfig } from "./use-config";
|
||||||
|
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||||
|
|
||||||
export const useGetSecrets = () => {
|
export const useGetSecrets = () => {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
const { providers } = useUserProviders();
|
const { data: isAuthed } = useIsAuthed();
|
||||||
|
|
||||||
const isOss = config?.APP_MODE === "oss";
|
const isOss = config?.APP_MODE === "oss";
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["secrets"],
|
queryKey: ["secrets"],
|
||||||
queryFn: SecretsService.getSecrets,
|
queryFn: SecretsService.getSecrets,
|
||||||
enabled: isOss || providers.length > 0,
|
enabled: isOss || isAuthed, // Enable regardless of providers
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,16 +3,16 @@ import React from "react";
|
|||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import { useConfig } from "./use-config";
|
import { useConfig } from "./use-config";
|
||||||
import OpenHands from "#/api/open-hands";
|
import OpenHands from "#/api/open-hands";
|
||||||
import { useUserProviders } from "../use-user-providers";
|
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||||
|
|
||||||
export const useGitUser = () => {
|
export const useGitUser = () => {
|
||||||
const { providers } = useUserProviders();
|
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
|
const { data: isAuthed } = useIsAuthed();
|
||||||
|
|
||||||
const user = useQuery({
|
const user = useQuery({
|
||||||
queryKey: ["user"],
|
queryKey: ["user"],
|
||||||
queryFn: OpenHands.getGitUser,
|
queryFn: OpenHands.getGitUser,
|
||||||
enabled: !!config?.APP_MODE && providers.length > 0,
|
enabled: !!config?.APP_MODE && isAuthed, // Enable regardless of providers
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||||
|
|||||||
@ -31,6 +31,11 @@ export const useAutoLogin = () => {
|
|||||||
identityProvider: "bitbucket",
|
identityProvider: "bitbucket",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const enterpriseSsoUrl = useAuthUrl({
|
||||||
|
appMode: config?.APP_MODE || null,
|
||||||
|
identityProvider: "enterprise_sso",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only auto-login in SAAS mode
|
// Only auto-login in SAAS mode
|
||||||
if (config?.APP_MODE !== "saas") {
|
if (config?.APP_MODE !== "saas") {
|
||||||
@ -60,6 +65,8 @@ export const useAutoLogin = () => {
|
|||||||
authUrl = gitlabAuthUrl;
|
authUrl = gitlabAuthUrl;
|
||||||
} else if (loginMethod === LoginMethod.BITBUCKET) {
|
} else if (loginMethod === LoginMethod.BITBUCKET) {
|
||||||
authUrl = bitbucketAuthUrl;
|
authUrl = bitbucketAuthUrl;
|
||||||
|
} else if (loginMethod === LoginMethod.ENTERPRISE_SSO) {
|
||||||
|
authUrl = enterpriseSsoUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have an auth URL, redirect to it
|
// If we have an auth URL, redirect to it
|
||||||
@ -80,5 +87,6 @@ export const useAutoLogin = () => {
|
|||||||
githubAuthUrl,
|
githubAuthUrl,
|
||||||
gitlabAuthUrl,
|
gitlabAuthUrl,
|
||||||
bitbucketAuthUrl,
|
bitbucketAuthUrl,
|
||||||
|
enterpriseSsoUrl,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -559,6 +559,7 @@ export enum I18nKey {
|
|||||||
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
|
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
|
||||||
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
|
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
|
||||||
BITBUCKET$CONNECT_TO_BITBUCKET = "BITBUCKET$CONNECT_TO_BITBUCKET",
|
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",
|
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
|
||||||
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
|
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
|
||||||
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
|
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
|
||||||
|
|||||||
@ -8943,6 +8943,22 @@
|
|||||||
"tr": "Bitbucket'a bağlan",
|
"tr": "Bitbucket'a bağlan",
|
||||||
"uk": "Увійти за допомогою Bitbucket"
|
"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": {
|
"AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER": {
|
||||||
"en": "Log in to OpenHands",
|
"en": "Log in to OpenHands",
|
||||||
"ja": "IDプロバイダーでサインイン",
|
"ja": "IDプロバイダーでサインイン",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export const ProviderOptions = {
|
|||||||
github: "github",
|
github: "github",
|
||||||
gitlab: "gitlab",
|
gitlab: "gitlab",
|
||||||
bitbucket: "bitbucket",
|
bitbucket: "bitbucket",
|
||||||
|
enterprise_sso: "enterprise_sso",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Provider = keyof typeof ProviderOptions;
|
export type Provider = keyof typeof ProviderOptions;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export enum LoginMethod {
|
|||||||
GITHUB = "github",
|
GITHUB = "github",
|
||||||
GITLAB = "gitlab",
|
GITLAB = "gitlab",
|
||||||
BITBUCKET = "bitbucket",
|
BITBUCKET = "bitbucket",
|
||||||
|
ENTERPRISE_SSO = "enterprise_sso",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class ProviderType(Enum):
|
|||||||
GITHUB = 'github'
|
GITHUB = 'github'
|
||||||
GITLAB = 'gitlab'
|
GITLAB = 'gitlab'
|
||||||
BITBUCKET = 'bitbucket'
|
BITBUCKET = 'bitbucket'
|
||||||
|
ENTERPRISE_SSO = 'enterprise_sso'
|
||||||
|
|
||||||
|
|
||||||
class TaskType(str, Enum):
|
class TaskType(str, Enum):
|
||||||
|
|||||||
@ -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.security import app as security_api_router
|
||||||
from openhands.server.routes.settings import app as settings_router
|
from openhands.server.routes.settings import app as settings_router
|
||||||
from openhands.server.routes.trajectory import app as trajectory_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')
|
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(manage_conversation_api_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(secrets_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)
|
app.include_router(trajectory_router)
|
||||||
add_health_endpoints(app)
|
add_health_endpoints(app)
|
||||||
|
|||||||
23
poetry.lock
generated
23
poetry.lock
generated
@ -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]]
|
[[package]]
|
||||||
name = "aiofiles"
|
name = "aiofiles"
|
||||||
@ -3770,6 +3770,22 @@ http2 = ["h2 (>=3,<5)"]
|
|||||||
socks = ["socksio (==1.*)"]
|
socks = ["socksio (==1.*)"]
|
||||||
zstd = ["zstandard (>=0.18.0)"]
|
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]]
|
[[package]]
|
||||||
name = "httpx-sse"
|
name = "httpx-sse"
|
||||||
version = "0.4.0"
|
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-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-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_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_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-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-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-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
|
||||||
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
|
{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]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = "^3.12,<3.14"
|
python-versions = "^3.12,<3.14"
|
||||||
content-hash = "d957f92f0d194e78b1cbf4b5a31c28df83e34e508d2c9810de96204db8e8f158"
|
content-hash = "8568c6ec2e11d4fcb23e206a24896b4d2d50e694c04011b668148f484e95b406"
|
||||||
|
|||||||
@ -99,6 +99,7 @@ e2b = { version = ">=1.0.5,<1.8.0", optional = true }
|
|||||||
modal = { version = ">=0.66.26,<1.2.0", optional = true }
|
modal = { version = ">=0.66.26,<1.2.0", optional = true }
|
||||||
runloop-api-client = { version = "0.50.0", optional = true }
|
runloop-api-client = { version = "0.50.0", optional = true }
|
||||||
daytona = { version = "0.24.2", optional = true }
|
daytona = { version = "0.24.2", optional = true }
|
||||||
|
httpx-aiohttp = "^0.1.8"
|
||||||
|
|
||||||
[tool.poetry.extras]
|
[tool.poetry.extras]
|
||||||
third_party_runtimes = [ "e2b", "modal", "runloop-api-client", "daytona" ]
|
third_party_runtimes = [ "e2b", "modal", "runloop-api-client", "daytona" ]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user