mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 15:03:43 +08:00
Fix Cloud credential expiration recovery (#1383)
* Fix cloud credential expiration recovery Co-authored-by: openhands <openhands@all-hands.dev> * Improve cloud re-login dialog styling Co-authored-by: openhands <openhands@all-hands.dev> * Use icon-only cloud re-login button Co-authored-by: openhands <openhands@all-hands.dev> * Use retry icon for cloud re-login Co-authored-by: openhands <openhands@all-hands.dev> * Fix root recovery test for icon login Co-authored-by: openhands <openhands@all-hands.dev> * chore: Remove PR-only artifacts --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com> Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
openhands
allhands-bot
Rohit Malhotra
parent
d09346f8ef
commit
3c40e8fdc4
@@ -19,6 +19,12 @@ import { ManageBackendsModal } from "#/components/features/backends/manage-backe
|
||||
import { BackendVersion } from "#/components/features/backends/backend-version";
|
||||
import { BackendRow } from "#/components/features/backends/backend-row";
|
||||
import { type Backend } from "#/api/backend-registry/types";
|
||||
import { CLOUD_BACKEND_LOGGED_OUT_ERROR } from "#/hooks/query/use-backends-health";
|
||||
|
||||
const deviceFlowMocks = vi.hoisted(() => ({
|
||||
startDeviceFlow: vi.fn(),
|
||||
pollForToken: vi.fn(),
|
||||
}));
|
||||
|
||||
const getServerInfoMock = vi.fn().mockResolvedValue({ version: "1.28.0" });
|
||||
const getSettingsMock = vi.fn().mockResolvedValue({});
|
||||
@@ -39,6 +45,19 @@ vi.mock("#/api/cloud/organization-service.api", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/api/device-flow-client", () => ({
|
||||
startDeviceFlow: deviceFlowMocks.startDeviceFlow,
|
||||
pollForToken: deviceFlowMocks.pollForToken,
|
||||
DeviceFlowError: class DeviceFlowError extends Error {
|
||||
code: string;
|
||||
|
||||
constructor(message: string, code: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
@@ -71,12 +90,25 @@ beforeEach(() => {
|
||||
getServerInfoMock.mockResolvedValue({ version: "1.28.0" });
|
||||
getSettingsMock.mockReset();
|
||||
getSettingsMock.mockResolvedValue({});
|
||||
deviceFlowMocks.startDeviceFlow.mockReset();
|
||||
deviceFlowMocks.startDeviceFlow.mockResolvedValue({
|
||||
device_code: "device-code",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: "https://app.all-hands.dev/device",
|
||||
verification_uri_complete:
|
||||
"https://app.all-hands.dev/device?user_code=ABCD-EFGH",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
});
|
||||
deviceFlowMocks.pollForToken.mockReset();
|
||||
deviceFlowMocks.pollForToken.mockImplementation(() => new Promise(() => {}));
|
||||
__resetActiveStoreForTests();
|
||||
__resetHealthStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
__resetActiveStoreForTests();
|
||||
__resetHealthStoreForTests();
|
||||
});
|
||||
@@ -140,9 +172,7 @@ describe("ManageBackendsModal", () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<ManageBackendsModal onClose={onClose} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByTestId("close-manage-backends-modal"),
|
||||
);
|
||||
await user.click(await screen.findByTestId("close-manage-backends-modal"));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -271,9 +301,7 @@ describe("ManageBackendsModal", () => {
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem("openhands-backends") ?? "[]",
|
||||
);
|
||||
const updated = stored.find(
|
||||
(b: { id: string }) => b.id === backendId,
|
||||
);
|
||||
const updated = stored.find((b: { id: string }) => b.id === backendId);
|
||||
expect(updated).toMatchObject({
|
||||
name: "OHE Prod Renamed",
|
||||
kind: "cloud",
|
||||
@@ -368,4 +396,96 @@ describe("BackendRow", () => {
|
||||
.closest("button");
|
||||
expect(selectButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows a logged-out cloud status with a log back in button", () => {
|
||||
const onLogin = vi.fn();
|
||||
|
||||
renderInQueryClient(
|
||||
<ul>
|
||||
<BackendRow
|
||||
backend={cloudBackend}
|
||||
health={{
|
||||
isConnected: false,
|
||||
consecutiveFailures: 1,
|
||||
lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR,
|
||||
disabled: false,
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onLogin={onLogin}
|
||||
/>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId(`manage-backends-status-${cloudBackend.name}`),
|
||||
).toHaveTextContent("BACKEND$LOGGED_OUT");
|
||||
expect(
|
||||
screen.queryByTestId(
|
||||
`manage-backends-status-detail-${cloudBackend.name}`,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
const loginButton = screen.getByTestId(
|
||||
`manage-backends-login-${cloudBackend.id}-login-button`,
|
||||
);
|
||||
expect(loginButton).toHaveAccessibleName("BACKEND$LOG_BACK_IN");
|
||||
expect(loginButton).not.toHaveTextContent("BACKEND$LOG_BACK_IN");
|
||||
expect(loginButton.querySelector("svg")).toBeInTheDocument();
|
||||
expect(loginButton).toHaveClass("hover:bg-interactive-hover");
|
||||
expect(loginButton).not.toHaveClass("border");
|
||||
expect(loginButton).not.toHaveClass("bg-primary");
|
||||
});
|
||||
|
||||
it("shows device authorization in a modal instead of expanding the row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const popup = {
|
||||
closed: false,
|
||||
close: vi.fn(),
|
||||
location: { href: "" },
|
||||
} as unknown as Window;
|
||||
vi.spyOn(window, "open").mockReturnValue(popup);
|
||||
|
||||
renderInQueryClient(
|
||||
<ul>
|
||||
<BackendRow
|
||||
backend={cloudBackend}
|
||||
health={{
|
||||
isConnected: false,
|
||||
consecutiveFailures: 1,
|
||||
lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR,
|
||||
disabled: false,
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onLogin={vi.fn()}
|
||||
/>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
|
||||
await user.click(
|
||||
screen.getByTestId(
|
||||
`manage-backends-login-${cloudBackend.id}-login-button`,
|
||||
),
|
||||
);
|
||||
|
||||
const modal = await screen.findByTestId(
|
||||
`manage-backends-login-${cloudBackend.id}-auth-modal`,
|
||||
);
|
||||
expect(
|
||||
await within(modal).findByTestId(
|
||||
`manage-backends-login-${cloudBackend.id}-auth-awaiting`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row).queryByTestId(
|
||||
`manage-backends-login-${cloudBackend.id}-auth-awaiting`,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(deviceFlowMocks.startDeviceFlow).toHaveBeenCalledWith(
|
||||
cloudBackend.host,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
resetBackendHealth,
|
||||
} from "#/api/backend-registry/health-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { useBackendsHealth } from "#/hooks/query/use-backends-health";
|
||||
import {
|
||||
CLOUD_BACKEND_LOGGED_OUT_ERROR,
|
||||
useBackendsHealth,
|
||||
} from "#/hooks/query/use-backends-health";
|
||||
|
||||
const getSettingsMock = vi.fn();
|
||||
const getServerInfoMock = vi.fn();
|
||||
@@ -160,7 +163,7 @@ describe("useBackendsHealth", () => {
|
||||
});
|
||||
|
||||
it("reports disconnected when the cloud probe throws", async () => {
|
||||
getCurrentCloudApiKeyMock.mockRejectedValue(new Error("401"));
|
||||
getCurrentCloudApiKeyMock.mockRejectedValue(new Error("Network Error"));
|
||||
|
||||
const { result } = renderHook(() => useBackendsHealth([cloudBackend]), {
|
||||
wrapper,
|
||||
@@ -171,6 +174,26 @@ describe("useBackendsHealth", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports logged out when the cloud probe returns 401", async () => {
|
||||
getCurrentCloudApiKeyMock.mockRejectedValue(
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBackendsHealth([cloudBackend]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current[cloudBackend.id]).toMatchObject({
|
||||
isConnected: false,
|
||||
lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports null while the first probe is still in flight", async () => {
|
||||
let resolveProbe!: () => void;
|
||||
getSettingsMock.mockImplementation(
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAgentServerQueryClient } from "#/query-client-config";
|
||||
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -42,4 +46,47 @@ describe("createAgentServerQueryClient", () => {
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith("query error with toast");
|
||||
});
|
||||
|
||||
it("does not show raw 401 toasts while the active cloud backend is logged out", async () => {
|
||||
const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
const backend = {
|
||||
id: "cloud-expired",
|
||||
name: "OpenHands Cloud",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "expired-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
"openhands-backends",
|
||||
JSON.stringify([backend]),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"openhands-active-backend",
|
||||
JSON.stringify({ backendId: backend.id, orgId: null }),
|
||||
);
|
||||
window.sessionStorage.setItem(
|
||||
"openhands-active-backend",
|
||||
JSON.stringify({ backendId: backend.id, orgId: null }),
|
||||
);
|
||||
__resetActiveStoreForTests();
|
||||
const client = createAgentServerQueryClient();
|
||||
|
||||
await expect(
|
||||
client.fetchQuery({
|
||||
queryKey: ["cloud", "logged-out"],
|
||||
queryFn: async () => {
|
||||
throw new AxiosError(
|
||||
"Request failed with status code 401",
|
||||
"ERR_BAD_REQUEST",
|
||||
undefined,
|
||||
undefined,
|
||||
{ status: 401 } as never,
|
||||
);
|
||||
},
|
||||
retry: false,
|
||||
}),
|
||||
).rejects.toThrow("Request failed with status code 401");
|
||||
|
||||
expect(toastSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+52
-9
@@ -9,14 +9,16 @@ import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
|
||||
const TRANSLATIONS: Record<string, string> = {
|
||||
"BACKEND$MANAGE_TITLE": "Manage backends",
|
||||
"BACKEND$MANAGE_EMPTY": "No backends yet.",
|
||||
"BACKEND$ADD": "+ Add Backend",
|
||||
"BACKEND$KIND_LOCAL": "Local",
|
||||
"BACKEND$KIND_CLOUD": "Cloud",
|
||||
"BACKEND$EDIT": "Edit",
|
||||
"BACKEND$REMOVE": "Remove",
|
||||
"HOME$DONE": "Done",
|
||||
BACKEND$MANAGE_TITLE: "Manage backends",
|
||||
BACKEND$MANAGE_EMPTY: "No backends yet.",
|
||||
BACKEND$ADD: "+ Add Backend",
|
||||
BACKEND$LOG_BACK_IN: "Log back in",
|
||||
BACKEND$LOGGED_OUT: "Logged out",
|
||||
BACKEND$KIND_LOCAL: "Local",
|
||||
BACKEND$KIND_CLOUD: "Cloud",
|
||||
BACKEND$EDIT: "Edit",
|
||||
BACKEND$REMOVE: "Remove",
|
||||
HOME$DONE: "Done",
|
||||
};
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
@@ -65,7 +67,6 @@ describe("App root agent-server availability guard", () => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
|
||||
it("shows the manage-backends modal when the connected server reports an old version", async () => {
|
||||
server.use(
|
||||
http.get("/server_info", () =>
|
||||
@@ -136,6 +137,48 @@ describe("App root agent-server availability guard", () => {
|
||||
expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the manage-backends recovery modal when the active cloud backend is logged out", async () => {
|
||||
const cloudBackend = {
|
||||
id: "cloud-expired",
|
||||
name: "OpenHands Cloud",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "expired-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
"openhands-backends",
|
||||
JSON.stringify([cloudBackend]),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"openhands-active-backend",
|
||||
JSON.stringify({ backendId: cloudBackend.id, orgId: null }),
|
||||
);
|
||||
window.sessionStorage.setItem(
|
||||
"openhands-active-backend",
|
||||
JSON.stringify({ backendId: cloudBackend.id, orgId: null }),
|
||||
);
|
||||
__resetActiveStoreForTests();
|
||||
server.use(
|
||||
http.get("https://app.all-hands.dev/api/keys/current", () =>
|
||||
HttpResponse.json({ detail: "NoCredentialsError" }, { status: 401 }),
|
||||
),
|
||||
);
|
||||
|
||||
renderApp(["/"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("agent-server-onboarding-screen"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Logged out")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Log back in" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the routed page when the agent server is reachable", async () => {
|
||||
renderApp(["/"]);
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
import { type Backend } from "#/api/backend-registry/types";
|
||||
import {
|
||||
isCloudBackendLoggedOutHealthError,
|
||||
isInvalidBackendApiKeyHealthError,
|
||||
type BackendHealth,
|
||||
} from "#/hooks/query/use-backends-health";
|
||||
@@ -10,6 +11,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BackendStatusDot } from "./backend-status-dot";
|
||||
import { BackendVersion } from "./backend-version";
|
||||
import { DeviceFlowAuth } from "./device-flow-auth";
|
||||
|
||||
const ROW_ACTION_BUTTON_CLASS =
|
||||
"inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-interactive-hover hover:text-white";
|
||||
@@ -20,6 +22,7 @@ interface BackendRowProps {
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
onLogin?: (apiKey: string) => void;
|
||||
}
|
||||
|
||||
export function BackendRow({
|
||||
@@ -28,17 +31,27 @@ export function BackendRow({
|
||||
onSelect,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onLogin,
|
||||
}: BackendRowProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const isInvalidApiKey = isInvalidBackendApiKeyHealthError(health?.lastError);
|
||||
const isCloudLoggedOut =
|
||||
backend.kind === "cloud" &&
|
||||
isCloudBackendLoggedOutHealthError(health?.lastError);
|
||||
const statusDetail =
|
||||
!isInvalidApiKey && health?.isConnected === false && health.lastError
|
||||
!isInvalidApiKey &&
|
||||
!isCloudLoggedOut &&
|
||||
health?.isConnected === false &&
|
||||
health.lastError
|
||||
? health.lastError
|
||||
: null;
|
||||
let statusLabel: string;
|
||||
let statusClassName = "text-[var(--oh-muted)]";
|
||||
|
||||
if (isInvalidApiKey) {
|
||||
if (isCloudLoggedOut) {
|
||||
statusLabel = t(I18nKey.BACKEND$LOGGED_OUT);
|
||||
statusClassName = "text-red-300";
|
||||
} else if (isInvalidApiKey) {
|
||||
statusLabel = t(I18nKey.AUTH$INVALID_KEY);
|
||||
statusClassName = "text-red-300";
|
||||
} else if (health?.isConnected === true) {
|
||||
@@ -100,7 +113,22 @@ export function BackendRow({
|
||||
: t(I18nKey.BACKEND$KIND_LOCAL)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-0.5 px-3 py-3">
|
||||
<div className="flex shrink-0 items-center gap-2 px-3 py-3">
|
||||
{isCloudLoggedOut && onLogin ? (
|
||||
<DeviceFlowAuth
|
||||
host={backend.host}
|
||||
onSuccess={onLogin}
|
||||
testIdRoot={`manage-backends-login-${backend.id}`}
|
||||
idleButtonLabel={t(I18nKey.BACKEND$LOG_BACK_IN)}
|
||||
idleButtonContent={
|
||||
<RefreshCw className="size-4" strokeWidth={2} aria-hidden />
|
||||
}
|
||||
className="w-auto"
|
||||
buttonVariant="unstyled"
|
||||
buttonClassName={ROW_ACTION_BUTTON_CLASS}
|
||||
statusDisplay="modal"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { useDeviceFlow } from "#/hooks/use-device-flow";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { useDeviceFlow, type DeviceFlowStatus } from "#/hooks/use-device-flow";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
type DeviceFlowButtonVariant =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "tertiary"
|
||||
| "unstyled";
|
||||
type DeviceFlowStatusDisplay = "inline" | "modal";
|
||||
|
||||
interface DeviceFlowAuthProps {
|
||||
/** The host URL for the cloud backend */
|
||||
@@ -13,6 +23,18 @@ interface DeviceFlowAuthProps {
|
||||
testIdRoot: string;
|
||||
/** Whether the login button should be disabled (e.g., when no host is entered) */
|
||||
isDisabled?: boolean;
|
||||
/** Override for the idle button label and icon-only accessible name. */
|
||||
idleButtonLabel?: string;
|
||||
/** Optional visible content for the idle button. Defaults to the idle label. */
|
||||
idleButtonContent?: React.ReactNode;
|
||||
/** Optional classes for the root wrapper. */
|
||||
className?: string;
|
||||
/** Optional classes for the idle button. */
|
||||
buttonClassName?: string;
|
||||
/** Visual variant for the idle button. */
|
||||
buttonVariant?: DeviceFlowButtonVariant;
|
||||
/** Whether in-progress auth content should render inline or in a modal. */
|
||||
statusDisplay?: DeviceFlowStatusDisplay;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +62,12 @@ export function DeviceFlowAuth({
|
||||
onSuccess,
|
||||
testIdRoot,
|
||||
isDisabled = false,
|
||||
idleButtonLabel,
|
||||
idleButtonContent,
|
||||
className,
|
||||
buttonClassName,
|
||||
buttonVariant = "primary",
|
||||
statusDisplay = "inline",
|
||||
}: DeviceFlowAuthProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const deviceFlow = useDeviceFlow();
|
||||
@@ -124,98 +152,179 @@ export function DeviceFlowAuth({
|
||||
deviceFlow.start(fullHost);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
deviceFlow.cancel();
|
||||
popupRef.current?.close();
|
||||
};
|
||||
|
||||
const statusContent = (
|
||||
<DeviceFlowStatusContent
|
||||
status={deviceFlow.status}
|
||||
error={deviceFlow.error}
|
||||
verificationUrl={deviceFlow.verificationUrl}
|
||||
testIdRoot={testIdRoot}
|
||||
onCancel={handleCancel}
|
||||
onRetry={handleStartAuth}
|
||||
/>
|
||||
);
|
||||
const showStatusModal =
|
||||
statusDisplay === "modal" && deviceFlow.status !== "idle";
|
||||
const idleLabel = idleButtonLabel ?? t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`${testIdRoot}-device-flow`}
|
||||
className="flex flex-col gap-3"
|
||||
className={cn("flex flex-col gap-3", className)}
|
||||
>
|
||||
{deviceFlow.status === "idle" && (
|
||||
{deviceFlow.status === "idle" && buttonVariant === "unstyled" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartAuth}
|
||||
data-testid={`${testIdRoot}-login-button`}
|
||||
className={buttonClassName}
|
||||
disabled={isDisabled}
|
||||
aria-label={idleButtonContent ? idleLabel : undefined}
|
||||
>
|
||||
{idleButtonContent ?? idleLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{deviceFlow.status === "idle" && buttonVariant !== "unstyled" && (
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
variant={buttonVariant}
|
||||
onClick={handleStartAuth}
|
||||
testId={`${testIdRoot}-login-button`}
|
||||
className="w-full"
|
||||
className={cn("w-full", buttonClassName)}
|
||||
isDisabled={isDisabled}
|
||||
ariaLabel={idleButtonContent ? idleLabel : undefined}
|
||||
>
|
||||
{t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS)}
|
||||
{idleButtonContent ?? idleLabel}
|
||||
</BrandButton>
|
||||
)}
|
||||
|
||||
{deviceFlow.status === "starting" && (
|
||||
<div
|
||||
className="flex items-center gap-2 p-3 bg-base-tertiary rounded-lg"
|
||||
data-testid={`${testIdRoot}-auth-starting`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
{statusDisplay === "inline" ? statusContent : null}
|
||||
|
||||
{showStatusModal ? (
|
||||
<ModalBackdrop
|
||||
onClose={handleCancel}
|
||||
aria-label={t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS)}
|
||||
closeOnBackdropClick={false}
|
||||
>
|
||||
<ModalBody
|
||||
testID={`${testIdRoot}-auth-modal`}
|
||||
width="sm"
|
||||
className="items-stretch border border-[var(--oh-border)]"
|
||||
>
|
||||
{statusContent}
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeviceFlowStatusContentProps {
|
||||
status: DeviceFlowStatus;
|
||||
error: string | null;
|
||||
verificationUrl: string | null;
|
||||
testIdRoot: string;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function DeviceFlowStatusContent({
|
||||
status,
|
||||
error,
|
||||
verificationUrl,
|
||||
testIdRoot,
|
||||
onCancel,
|
||||
onRetry,
|
||||
}: DeviceFlowStatusContentProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
if (status === "idle" || status === "success") return null;
|
||||
|
||||
if (status === "starting") {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg bg-base-tertiary p-3"
|
||||
data-testid={`${testIdRoot}-auth-starting`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<LoadingSpinner />
|
||||
<span className="text-sm text-[var(--oh-text-tertiary)]">
|
||||
{t(I18nKey.BACKEND$AUTH_STARTING)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "awaiting_authorization") {
|
||||
const validVerificationUrl =
|
||||
verificationUrl && isValidVerificationUrl(verificationUrl)
|
||||
? verificationUrl
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4 rounded-lg bg-base-tertiary p-4"
|
||||
data-testid={`${testIdRoot}-auth-awaiting`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LoadingSpinner />
|
||||
<span className="text-sm text-[var(--oh-text-tertiary)]">
|
||||
{t(I18nKey.BACKEND$AUTH_STARTING)}
|
||||
<span className="text-sm font-medium text-white">
|
||||
{t(I18nKey.BACKEND$AUTH_AWAITING)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deviceFlow.status === "awaiting_authorization" && (
|
||||
<div
|
||||
className="flex flex-col gap-3 p-4 bg-base-tertiary rounded-lg"
|
||||
data-testid={`${testIdRoot}-auth-awaiting`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LoadingSpinner />
|
||||
<span className="text-sm font-medium text-white">
|
||||
{t(I18nKey.BACKEND$AUTH_AWAITING)}
|
||||
</span>
|
||||
<p className="text-sm leading-5 text-[var(--oh-text-tertiary)]">
|
||||
{t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)}
|
||||
</p>
|
||||
{validVerificationUrl ? (
|
||||
<div className="flex flex-col gap-1 rounded-md border border-[var(--oh-border)] bg-[var(--oh-surface)] p-3 text-xs text-[var(--oh-muted)]">
|
||||
<p>{t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}</p>
|
||||
<a
|
||||
href={validVerificationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="max-h-16 overflow-auto break-all text-blue-400 hover:underline"
|
||||
>
|
||||
{validVerificationUrl}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--oh-text-tertiary)]">
|
||||
{t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)}
|
||||
</p>
|
||||
{deviceFlow.verificationUrl &&
|
||||
isValidVerificationUrl(deviceFlow.verificationUrl) && (
|
||||
<div className="text-xs text-[var(--oh-muted)]">
|
||||
<p>{t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}</p>
|
||||
<a
|
||||
href={deviceFlow.verificationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline break-all"
|
||||
>
|
||||
{deviceFlow.verificationUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={deviceFlow.cancel}
|
||||
testId={`${testIdRoot}-auth-cancel`}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
{t(I18nKey.BACKEND$AUTH_CANCEL)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deviceFlow.status === "error" && (
|
||||
<div
|
||||
className="flex flex-col gap-3 p-4 bg-red-900/20 border border-red-700 rounded-lg"
|
||||
data-testid={`${testIdRoot}-auth-error`}
|
||||
role="alert"
|
||||
) : null}
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
testId={`${testIdRoot}-auth-cancel`}
|
||||
className="w-full"
|
||||
>
|
||||
<p className="text-sm text-red-400">{deviceFlow.error}</p>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleStartAuth}
|
||||
testId={`${testIdRoot}-auth-retry`}
|
||||
className="w-full"
|
||||
>
|
||||
{t(I18nKey.BACKEND$AUTH_RETRY)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
)}
|
||||
{t(I18nKey.BACKEND$AUTH_CANCEL)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3 rounded-lg border border-red-700 bg-red-900/20 p-4"
|
||||
data-testid={`${testIdRoot}-auth-error`}
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onRetry}
|
||||
testId={`${testIdRoot}-auth-retry`}
|
||||
className="w-full"
|
||||
>
|
||||
{t(I18nKey.BACKEND$AUTH_RETRY)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ManageBackendsModal({
|
||||
recoveryMode = false,
|
||||
}: ManageBackendsModalProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { backends, active, removeBackend, setActive } =
|
||||
const { backends, active, removeBackend, setActive, updateBackend } =
|
||||
useActiveBackendContext();
|
||||
const healthByBackendId = useBackendsHealth(backends, {
|
||||
probeDisabledOnce: true,
|
||||
@@ -66,6 +66,13 @@ export function ManageBackendsModal({
|
||||
[active.backend.id, active.orgId, onClose, setActive],
|
||||
);
|
||||
|
||||
const handleCloudLogin = React.useCallback(
|
||||
(backend: Backend, apiKey: string) => {
|
||||
updateBackend(backend.id, { apiKey });
|
||||
},
|
||||
[updateBackend],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBackdrop
|
||||
@@ -119,6 +126,7 @@ export function ManageBackendsModal({
|
||||
name: backend.name,
|
||||
})
|
||||
}
|
||||
onLogin={(apiKey) => handleCloudLogin(backend, apiKey)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
ServerClient,
|
||||
@@ -22,6 +23,7 @@ import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage"
|
||||
const REFRESH_INTERVAL_MS = 10000;
|
||||
const PROBE_TIMEOUT_MS = 4000;
|
||||
export const INVALID_BACKEND_API_KEY_ERROR = "Invalid API key";
|
||||
export const CLOUD_BACKEND_LOGGED_OUT_ERROR = "Logged out";
|
||||
|
||||
export function isInvalidBackendApiKeyHealthError(
|
||||
error: string | null | undefined,
|
||||
@@ -29,6 +31,12 @@ export function isInvalidBackendApiKeyHealthError(
|
||||
return error === INVALID_BACKEND_API_KEY_ERROR;
|
||||
}
|
||||
|
||||
export function isCloudBackendLoggedOutHealthError(
|
||||
error: string | null | undefined,
|
||||
): boolean {
|
||||
return error === CLOUD_BACKEND_LOGGED_OUT_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a single backend for connectivity. The probe path differs by
|
||||
* backend kind:
|
||||
@@ -48,7 +56,14 @@ export function isInvalidBackendApiKeyHealthError(
|
||||
*/
|
||||
async function probeBackend(backend: Backend): Promise<true> {
|
||||
if (backend.kind === "cloud") {
|
||||
await getCurrentCloudApiKey(backend);
|
||||
try {
|
||||
await getCurrentCloudApiKey(backend);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
throw new Error(CLOUD_BACKEND_LOGGED_OUT_ERROR);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -27335,6 +27335,40 @@
|
||||
"uk": "Увійти через OpenHands Cloud",
|
||||
"ca": "Inicia sessió amb OpenHands Cloud"
|
||||
},
|
||||
"BACKEND$LOG_BACK_IN": {
|
||||
"en": "Log back in",
|
||||
"ar": "تسجيل الدخول مجددًا",
|
||||
"ca": "Torna a iniciar sessió",
|
||||
"de": "Erneut anmelden",
|
||||
"es": "Volver a iniciar sesión",
|
||||
"fr": "Se reconnecter",
|
||||
"it": "Accedi di nuovo",
|
||||
"ja": "再ログイン",
|
||||
"ko-KR": "다시 로그인",
|
||||
"no": "Logg inn igjen",
|
||||
"pt": "Entrar novamente",
|
||||
"tr": "Tekrar giriş yap",
|
||||
"uk": "Увійти знову",
|
||||
"zh-CN": "重新登录",
|
||||
"zh-TW": "重新登入"
|
||||
},
|
||||
"BACKEND$LOGGED_OUT": {
|
||||
"en": "Logged out",
|
||||
"ar": "تم تسجيل الخروج",
|
||||
"ca": "Sessió tancada",
|
||||
"de": "Abgemeldet",
|
||||
"es": "Sesión cerrada",
|
||||
"fr": "Déconnecté",
|
||||
"it": "Disconnesso",
|
||||
"ja": "ログアウト済み",
|
||||
"ko-KR": "로그아웃됨",
|
||||
"no": "Logget ut",
|
||||
"pt": "Desconectado",
|
||||
"tr": "Oturum kapatıldı",
|
||||
"uk": "Вийшли з системи",
|
||||
"zh-CN": "已退出登录",
|
||||
"zh-TW": "已登出"
|
||||
},
|
||||
"BACKEND$LOGIN_CLOUD_HINT": {
|
||||
"en": "Works with OpenHands Cloud or your self-hosted OpenHands Cloud deployment.",
|
||||
"ja": "OpenHands Cloud またはセルフホスト型 OpenHands Cloud デプロイメントで使用できます。",
|
||||
|
||||
@@ -4,6 +4,7 @@ import i18n from "#/i18n";
|
||||
import { I18nKey } from "./i18n/declaration";
|
||||
import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message";
|
||||
import { displayErrorToast } from "./utils/custom-toast-handlers";
|
||||
import { getActiveBackend } from "#/api/backend-registry/active-store";
|
||||
import { recordBackendSuccess } from "#/api/backend-registry/health-store";
|
||||
|
||||
const handle401Error = (error: AxiosError, client: QueryClient) => {
|
||||
@@ -12,6 +13,19 @@ const handle401Error = (error: AxiosError, client: QueryClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isActiveCloudBackendAuthError = (error: unknown) => {
|
||||
if (!(error instanceof AxiosError)) return false;
|
||||
if (error.response?.status !== 401 && error.status !== 401) return false;
|
||||
|
||||
const activeBackend = getActiveBackend().backend;
|
||||
if (activeBackend.kind !== "cloud") return false;
|
||||
|
||||
const requestUrl = error.config?.url;
|
||||
return (
|
||||
!requestUrl || requestUrl.startsWith(activeBackend.host.replace(/\/+$/, ""))
|
||||
);
|
||||
};
|
||||
|
||||
const shownErrors = new Set<string>();
|
||||
|
||||
export const createAgentServerQueryClient = () => {
|
||||
@@ -34,7 +48,7 @@ export const createAgentServerQueryClient = () => {
|
||||
const disableToast =
|
||||
query.meta?.disableToast ?? query.options.meta?.disableToast;
|
||||
|
||||
if (!disableToast) {
|
||||
if (!disableToast && !isActiveCloudBackendAuthError(error)) {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
|
||||
if (!shownErrors.has(errorMessage || "")) {
|
||||
@@ -55,7 +69,7 @@ export const createAgentServerQueryClient = () => {
|
||||
const disableToast =
|
||||
mutation?.meta?.disableToast ?? mutation?.options.meta?.disableToast;
|
||||
|
||||
if (!disableToast) {
|
||||
if (!disableToast && !isActiveCloudBackendAuthError(error)) {
|
||||
const message = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(message || i18n.t(I18nKey.ERROR$GENERIC));
|
||||
}
|
||||
|
||||
+14
-1
@@ -19,6 +19,11 @@ import {
|
||||
} from "#/api/agent-server-compatibility";
|
||||
import { isAuthRequiredAndMissing } from "#/api/agent-server-config";
|
||||
import { getEffectiveLocalBackend } from "#/api/backend-registry/active-store";
|
||||
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
||||
import {
|
||||
isCloudBackendLoggedOutHealthError,
|
||||
useBackendsHealth,
|
||||
} from "#/hooks/query/use-backends-health";
|
||||
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
|
||||
import { TelemetryConsentBanner } from "#/components/features/analytics/telemetry-consent-banner";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -149,6 +154,14 @@ export default function App() {
|
||||
// Skip the /server_info probe entirely when we already know auth is
|
||||
// required and missing — it would just 401 and waste time.
|
||||
const config = useConfig({ enabled: !authMissing });
|
||||
const { active } = useActiveBackendContext();
|
||||
const activeCloudHealth = useBackendsHealth(
|
||||
active.backend.kind === "cloud" ? [active.backend] : [],
|
||||
)[active.backend.id];
|
||||
const activeCloudLoggedOut =
|
||||
active.backend.kind === "cloud" &&
|
||||
activeCloudHealth?.isConnected === false &&
|
||||
isCloudBackendLoggedOutHealthError(activeCloudHealth.lastError);
|
||||
|
||||
// No key at all → instant auth screen (no network).
|
||||
// Stale key → /server_info 401 → auth screen (public mode only).
|
||||
@@ -164,7 +177,7 @@ export default function App() {
|
||||
return <AgentServerBootstrapLoading />;
|
||||
}
|
||||
|
||||
if (isAgentServerUnavailableError(config.error)) {
|
||||
if (activeCloudLoggedOut || isAgentServerUnavailableError(config.error)) {
|
||||
return <MissingAgentServerScreen />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user