mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
Feat: enterprise banner option during device oauth (#13361)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
|
||||
|
||||
const mockCapture = vi.fn();
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: () => ({
|
||||
capture: mockCapture,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
describe("EnterpriseBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Feature Flag", () => {
|
||||
it("should not render when proj_user_journey feature flag is disabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
|
||||
const { container } = renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render when proj_user_journey feature flag is enabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the self-hosted label", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$SELF_HOSTED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the enterprise title", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the enterprise description", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$DESCRIPTION")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all four enterprise feature items", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_DATA_PRIVACY"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_DEPLOYMENT"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ENTERPRISE$FEATURE_SSO")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_SUPPORT"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the learn more link", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveTextContent("ENTERPRISE$LEARN_MORE");
|
||||
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Learn More Link Interaction", () => {
|
||||
it("should capture PostHog event when learn more link is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
await user.click(link);
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith("saas_selfhosted_inquiry");
|
||||
});
|
||||
|
||||
it("should have correct href attribute for opening in new tab", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
});
|
||||
659
frontend/__tests__/routes/device-verify.test.tsx
Normal file
659
frontend/__tests__/routes/device-verify.test.tsx
Normal file
@@ -0,0 +1,659 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import DeviceVerify from "#/routes/device-verify";
|
||||
|
||||
const { useIsAuthedMock, PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
useIsAuthedMock: vi.fn(() => ({
|
||||
data: false as boolean | undefined,
|
||||
isLoading: false,
|
||||
})),
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => useIsAuthedMock(),
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: () => ({
|
||||
capture: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: DeviceVerify,
|
||||
path: "/device-verify",
|
||||
},
|
||||
]);
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
describe("DeviceVerify", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("close", vi.fn());
|
||||
// Mock fetch for API calls
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Enable feature flag by default
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Loading State", () => {
|
||||
it("should show loading spinner while checking authentication", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Not Authenticated State", () => {
|
||||
it("should show authentication required message when not authenticated", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$AUTH_REQUIRED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$SIGN_IN_PROMPT")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authenticated without User Code", () => {
|
||||
it("should show manual code entry form when authenticated but no code in URL", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("DEVICE$AUTHORIZATION_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$ENTER_CODE_PROMPT")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$CONTINUE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should submit manually entered code", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText("DEVICE$CODE_INPUT_LABEL");
|
||||
await user.type(input, "TESTCODE");
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "DEVICE$CONTINUE",
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/oauth/device/verify-authenticated",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "user_code=TESTCODE",
|
||||
credentials: "include",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authenticated with User Code", () => {
|
||||
it("should show authorization confirmation when authenticated with code in URL", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("DEVICE$AUTHORIZATION_REQUEST"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$CODE_LABEL")).toBeInTheDocument();
|
||||
expect(screen.getByText("ABC-123")).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$SECURITY_NOTICE")).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$SECURITY_WARNING")).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$CONFIRM_PROMPT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show cancel and authorize buttons", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$CANCEL" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should include the EnterpriseBanner component when feature flag is enabled", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include the EnterpriseBanner and be center-aligned when feature flag is disabled", async () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("DEVICE$AUTHORIZATION_REQUEST"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Banner should not be rendered
|
||||
expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument();
|
||||
|
||||
// Container should use max-w-md (centered layout) instead of max-w-4xl
|
||||
const container = document.querySelector(".max-w-md");
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(document.querySelector(".max-w-4xl")).not.toBeInTheDocument();
|
||||
|
||||
// Authorization card should have mx-auto for centering
|
||||
const authCard = container?.querySelector(".mx-auto");
|
||||
expect(authCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call window.close when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$CANCEL" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "DEVICE$CANCEL" });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(window.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should submit device verification when authorize button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/oauth/device/verify-authenticated",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "user_code=ABC-123",
|
||||
credentials: "include",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Processing State", () => {
|
||||
it("should show processing spinner during verification", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Make fetch hang to show processing state
|
||||
const mockFetch = vi.fn(() => new Promise(() => {}));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Success State", () => {
|
||||
it("should show success message after successful verification", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$SUCCESS_MESSAGE")).toBeInTheDocument();
|
||||
// Should show success icon (checkmark)
|
||||
const successIcon = document.querySelector(".text-green-600");
|
||||
expect(successIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show try again button on success", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "DEVICE$TRY_AGAIN" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error State", () => {
|
||||
it("should show error message when verification fails with non-ok response", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ error: "invalid_code" }),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=INVALID"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$ERROR_FAILED")).toBeInTheDocument();
|
||||
// Should show error icon (X)
|
||||
const errorIcon = document.querySelector(".text-red-600");
|
||||
expect(errorIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error message when fetch throws an exception", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() => Promise.reject(new Error("Network error")));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$ERROR_OCCURRED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show try again button on error", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=INVALID"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should reload page when try again button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const reloadMock = vi.fn();
|
||||
vi.stubGlobal("location", { reload: reloadMock });
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=INVALID"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tryAgainButton = screen.getByRole("button", {
|
||||
name: "DEVICE$TRY_AGAIN",
|
||||
});
|
||||
await user.click(tryAgainButton);
|
||||
|
||||
expect(reloadMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { H2, Text } from "#/ui/typography";
|
||||
import CheckCircleFillIcon from "#/icons/check-circle-fill.svg?react";
|
||||
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
const ENTERPRISE_FEATURE_KEYS: I18nKey[] = [
|
||||
I18nKey.ENTERPRISE$FEATURE_DATA_PRIVACY,
|
||||
I18nKey.ENTERPRISE$FEATURE_DEPLOYMENT,
|
||||
I18nKey.ENTERPRISE$FEATURE_SSO,
|
||||
I18nKey.ENTERPRISE$FEATURE_SUPPORT,
|
||||
];
|
||||
|
||||
export function EnterpriseBanner() {
|
||||
const { t } = useTranslation();
|
||||
const posthog = usePostHog();
|
||||
|
||||
if (!PROJ_USER_JOURNEY()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLearnMore = () => {
|
||||
posthog?.capture("saas_selfhosted_inquiry");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto lg:mx-0 lg:w-80 p-6 rounded-lg bg-gradient-to-b from-slate-800 to-slate-900 border border-slate-700 h-fit">
|
||||
{/* Self-Hosted Label */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="px-8 py-0.5 rounded-full bg-gradient-to-r from-blue-900 to-blue-950 border border-blue-800">
|
||||
<Text className="text-xs font-medium text-blue-400 tracking-wider uppercase">
|
||||
{t(I18nKey.ENTERPRISE$SELF_HOSTED)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<H2 className="text-center mb-3">{t(I18nKey.ENTERPRISE$TITLE)}</H2>
|
||||
|
||||
{/* Description */}
|
||||
<Text className="text-sm text-gray-400 text-center mb-6 block">
|
||||
{t(I18nKey.ENTERPRISE$DESCRIPTION)}
|
||||
</Text>
|
||||
|
||||
{/* Features List */}
|
||||
<ul className="space-y-3 mb-6">
|
||||
{ENTERPRISE_FEATURE_KEYS.map((featureKey) => (
|
||||
<li key={featureKey} className="flex items-center gap-2">
|
||||
<CheckCircleFillIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
<Text className="text-sm text-gray-300">{t(featureKey)}</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Learn More Button */}
|
||||
<a
|
||||
href="https://openhands.dev/enterprise"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleLearnMore}
|
||||
aria-label={t(I18nKey.ENTERPRISE$LEARN_MORE_ARIA)}
|
||||
className="block w-full py-2.5 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors text-center"
|
||||
>
|
||||
{t(I18nKey.ENTERPRISE$LEARN_MORE)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1136,4 +1136,34 @@ export enum I18nKey {
|
||||
ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON",
|
||||
ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON",
|
||||
ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON",
|
||||
ENTERPRISE$SELF_HOSTED = "ENTERPRISE$SELF_HOSTED",
|
||||
ENTERPRISE$TITLE = "ENTERPRISE$TITLE",
|
||||
ENTERPRISE$DESCRIPTION = "ENTERPRISE$DESCRIPTION",
|
||||
ENTERPRISE$FEATURE_DATA_PRIVACY = "ENTERPRISE$FEATURE_DATA_PRIVACY",
|
||||
ENTERPRISE$FEATURE_DEPLOYMENT = "ENTERPRISE$FEATURE_DEPLOYMENT",
|
||||
ENTERPRISE$FEATURE_SSO = "ENTERPRISE$FEATURE_SSO",
|
||||
ENTERPRISE$FEATURE_SUPPORT = "ENTERPRISE$FEATURE_SUPPORT",
|
||||
ENTERPRISE$LEARN_MORE = "ENTERPRISE$LEARN_MORE",
|
||||
ENTERPRISE$LEARN_MORE_ARIA = "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
DEVICE$SUCCESS_TITLE = "DEVICE$SUCCESS_TITLE",
|
||||
DEVICE$ERROR_TITLE = "DEVICE$ERROR_TITLE",
|
||||
DEVICE$SUCCESS_MESSAGE = "DEVICE$SUCCESS_MESSAGE",
|
||||
DEVICE$ERROR_FAILED = "DEVICE$ERROR_FAILED",
|
||||
DEVICE$ERROR_OCCURRED = "DEVICE$ERROR_OCCURRED",
|
||||
DEVICE$TRY_AGAIN = "DEVICE$TRY_AGAIN",
|
||||
DEVICE$PROCESSING = "DEVICE$PROCESSING",
|
||||
DEVICE$AUTHORIZATION_REQUEST = "DEVICE$AUTHORIZATION_REQUEST",
|
||||
DEVICE$CODE_LABEL = "DEVICE$CODE_LABEL",
|
||||
DEVICE$SECURITY_NOTICE = "DEVICE$SECURITY_NOTICE",
|
||||
DEVICE$SECURITY_WARNING = "DEVICE$SECURITY_WARNING",
|
||||
DEVICE$CONFIRM_PROMPT = "DEVICE$CONFIRM_PROMPT",
|
||||
DEVICE$CANCEL = "DEVICE$CANCEL",
|
||||
DEVICE$AUTHORIZE = "DEVICE$AUTHORIZE",
|
||||
DEVICE$AUTHORIZATION_TITLE = "DEVICE$AUTHORIZATION_TITLE",
|
||||
DEVICE$ENTER_CODE_PROMPT = "DEVICE$ENTER_CODE_PROMPT",
|
||||
DEVICE$CODE_INPUT_LABEL = "DEVICE$CODE_INPUT_LABEL",
|
||||
DEVICE$CODE_PLACEHOLDER = "DEVICE$CODE_PLACEHOLDER",
|
||||
DEVICE$CONTINUE = "DEVICE$CONTINUE",
|
||||
DEVICE$AUTH_REQUIRED = "DEVICE$AUTH_REQUIRED",
|
||||
DEVICE$SIGN_IN_PROMPT = "DEVICE$SIGN_IN_PROMPT",
|
||||
}
|
||||
|
||||
@@ -18174,5 +18174,485 @@
|
||||
"es": "Finalizar",
|
||||
"tr": "Bitir",
|
||||
"uk": "Завершити"
|
||||
},
|
||||
"ENTERPRISE$SELF_HOSTED": {
|
||||
"en": "Self-Hosted",
|
||||
"ja": "セルフホスト",
|
||||
"zh-CN": "自托管",
|
||||
"zh-TW": "自託管",
|
||||
"ko-KR": "셀프 호스팅",
|
||||
"no": "Selvhosted",
|
||||
"ar": "مستضاف ذاتياً",
|
||||
"de": "Selbst gehostet",
|
||||
"fr": "Auto-hébergé",
|
||||
"it": "Self-hosted",
|
||||
"pt": "Auto-hospedado",
|
||||
"es": "Autoalojado",
|
||||
"tr": "Kendi Sunucunuzda",
|
||||
"uk": "Самостійний хостинг"
|
||||
},
|
||||
"ENTERPRISE$TITLE": {
|
||||
"en": "OpenHands Enterprise",
|
||||
"ja": "OpenHands Enterprise",
|
||||
"zh-CN": "OpenHands 企业版",
|
||||
"zh-TW": "OpenHands 企業版",
|
||||
"ko-KR": "OpenHands 엔터프라이즈",
|
||||
"no": "OpenHands Enterprise",
|
||||
"ar": "OpenHands للمؤسسات",
|
||||
"de": "OpenHands Enterprise",
|
||||
"fr": "OpenHands Enterprise",
|
||||
"it": "OpenHands Enterprise",
|
||||
"pt": "OpenHands Enterprise",
|
||||
"es": "OpenHands Enterprise",
|
||||
"tr": "OpenHands Kurumsal",
|
||||
"uk": "OpenHands Enterprise"
|
||||
},
|
||||
"ENTERPRISE$DESCRIPTION": {
|
||||
"en": "Complete data control with your own self-hosted AI development platform.",
|
||||
"ja": "独自のセルフホストAI開発プラットフォームで完全なデータ管理を実現。",
|
||||
"zh-CN": "通过自托管AI开发平台实现完全的数据控制。",
|
||||
"zh-TW": "透過自託管AI開發平台實現完全的資料控制。",
|
||||
"ko-KR": "셀프 호스팅 AI 개발 플랫폼으로 완벽한 데이터 제어를 실현하세요.",
|
||||
"no": "Fullstendig datakontroll med din egen selvhostede AI-utviklingsplattform.",
|
||||
"ar": "تحكم كامل في البيانات مع منصة تطوير الذكاء الاصطناعي المستضافة ذاتياً.",
|
||||
"de": "Vollständige Datenkontrolle mit Ihrer eigenen selbst gehosteten KI-Entwicklungsplattform.",
|
||||
"fr": "Contrôle total des données avec votre propre plateforme de développement IA auto-hébergée.",
|
||||
"it": "Controllo completo dei dati con la tua piattaforma di sviluppo AI self-hosted.",
|
||||
"pt": "Controle completo de dados com sua própria plataforma de desenvolvimento de IA auto-hospedada.",
|
||||
"es": "Control completo de datos con tu propia plataforma de desarrollo de IA autoalojada.",
|
||||
"tr": "Kendi barındırdığınız yapay zeka geliştirme platformuyla tam veri kontrolü.",
|
||||
"uk": "Повний контроль над даними з власною самостійно розміщеною платформою розробки ШІ."
|
||||
},
|
||||
"ENTERPRISE$FEATURE_DATA_PRIVACY": {
|
||||
"en": "Full data privacy & control",
|
||||
"ja": "完全なデータプライバシーと管理",
|
||||
"zh-CN": "完全的数据隐私和控制",
|
||||
"zh-TW": "完全的資料隱私和控制",
|
||||
"ko-KR": "완벽한 데이터 프라이버시 및 제어",
|
||||
"no": "Full datapersonvern og kontroll",
|
||||
"ar": "خصوصية وتحكم كامل في البيانات",
|
||||
"de": "Vollständiger Datenschutz und Kontrolle",
|
||||
"fr": "Confidentialité et contrôle complets des données",
|
||||
"it": "Privacy e controllo completo dei dati",
|
||||
"pt": "Privacidade e controle total de dados",
|
||||
"es": "Privacidad y control total de datos",
|
||||
"tr": "Tam veri gizliliği ve kontrolü",
|
||||
"uk": "Повна конфіденційність та контроль даних"
|
||||
},
|
||||
"ENTERPRISE$FEATURE_DEPLOYMENT": {
|
||||
"en": "Custom deployment options",
|
||||
"ja": "カスタムデプロイオプション",
|
||||
"zh-CN": "自定义部署选项",
|
||||
"zh-TW": "自訂部署選項",
|
||||
"ko-KR": "맞춤형 배포 옵션",
|
||||
"no": "Tilpassede distribusjonsalternativer",
|
||||
"ar": "خيارات نشر مخصصة",
|
||||
"de": "Individuelle Bereitstellungsoptionen",
|
||||
"fr": "Options de déploiement personnalisées",
|
||||
"it": "Opzioni di distribuzione personalizzate",
|
||||
"pt": "Opções de implantação personalizadas",
|
||||
"es": "Opciones de despliegue personalizadas",
|
||||
"tr": "Özel dağıtım seçenekleri",
|
||||
"uk": "Налаштовані варіанти розгортання"
|
||||
},
|
||||
"ENTERPRISE$FEATURE_SSO": {
|
||||
"en": "SSO & enterprise auth",
|
||||
"ja": "SSOとエンタープライズ認証",
|
||||
"zh-CN": "SSO和企业认证",
|
||||
"zh-TW": "SSO和企業認證",
|
||||
"ko-KR": "SSO 및 엔터프라이즈 인증",
|
||||
"no": "SSO og bedriftsautentisering",
|
||||
"ar": "تسجيل دخول موحد ومصادقة المؤسسات",
|
||||
"de": "SSO und Unternehmensauthentifizierung",
|
||||
"fr": "SSO et authentification d'entreprise",
|
||||
"it": "SSO e autenticazione aziendale",
|
||||
"pt": "SSO e autenticação empresarial",
|
||||
"es": "SSO y autenticación empresarial",
|
||||
"tr": "SSO ve kurumsal kimlik doğrulama",
|
||||
"uk": "SSO та корпоративна автентифікація"
|
||||
},
|
||||
"ENTERPRISE$FEATURE_SUPPORT": {
|
||||
"en": "Dedicated support",
|
||||
"ja": "専用サポート",
|
||||
"zh-CN": "专属支持",
|
||||
"zh-TW": "專屬支援",
|
||||
"ko-KR": "전담 지원",
|
||||
"no": "Dedikert støtte",
|
||||
"ar": "دعم مخصص",
|
||||
"de": "Dedizierter Support",
|
||||
"fr": "Support dédié",
|
||||
"it": "Supporto dedicato",
|
||||
"pt": "Suporte dedicado",
|
||||
"es": "Soporte dedicado",
|
||||
"tr": "Özel destek",
|
||||
"uk": "Виділена підтримка"
|
||||
},
|
||||
"ENTERPRISE$LEARN_MORE": {
|
||||
"en": "Learn More",
|
||||
"ja": "詳細を見る",
|
||||
"zh-CN": "了解更多",
|
||||
"zh-TW": "了解更多",
|
||||
"ko-KR": "더 알아보기",
|
||||
"no": "Les mer",
|
||||
"ar": "اعرف المزيد",
|
||||
"de": "Mehr erfahren",
|
||||
"fr": "En savoir plus",
|
||||
"it": "Scopri di più",
|
||||
"pt": "Saiba mais",
|
||||
"es": "Más información",
|
||||
"tr": "Daha Fazla Bilgi",
|
||||
"uk": "Дізнатися більше"
|
||||
},
|
||||
"ENTERPRISE$LEARN_MORE_ARIA": {
|
||||
"en": "Learn more about OpenHands Enterprise (opens in new window)",
|
||||
"ja": "OpenHands Enterpriseの詳細を見る(新しいウィンドウで開く)",
|
||||
"zh-CN": "了解更多关于 OpenHands 企业版的信息(在新窗口中打开)",
|
||||
"zh-TW": "了解更多關於 OpenHands 企業版的資訊(在新視窗中開啟)",
|
||||
"ko-KR": "OpenHands 엔터프라이즈에 대해 더 알아보기 (새 창에서 열림)",
|
||||
"no": "Les mer om OpenHands Enterprise (åpnes i nytt vindu)",
|
||||
"ar": "اعرف المزيد عن OpenHands Enterprise (يفتح في نافذة جديدة)",
|
||||
"de": "Erfahren Sie mehr über OpenHands Enterprise (öffnet in neuem Fenster)",
|
||||
"fr": "En savoir plus sur OpenHands Enterprise (s'ouvre dans une nouvelle fenêtre)",
|
||||
"it": "Scopri di più su OpenHands Enterprise (si apre in una nuova finestra)",
|
||||
"pt": "Saiba mais sobre OpenHands Enterprise (abre em nova janela)",
|
||||
"es": "Más información sobre OpenHands Enterprise (abre en nueva ventana)",
|
||||
"tr": "OpenHands Enterprise hakkında daha fazla bilgi edinin (yeni pencerede açılır)",
|
||||
"uk": "Дізнатися більше про OpenHands Enterprise (відкривається в новому вікні)"
|
||||
},
|
||||
"DEVICE$SUCCESS_TITLE": {
|
||||
"en": "Success!",
|
||||
"ja": "成功!",
|
||||
"zh-CN": "成功!",
|
||||
"zh-TW": "成功!",
|
||||
"ko-KR": "성공!",
|
||||
"no": "Suksess!",
|
||||
"ar": "نجاح!",
|
||||
"de": "Erfolg!",
|
||||
"fr": "Succès !",
|
||||
"it": "Successo!",
|
||||
"pt": "Sucesso!",
|
||||
"es": "¡Éxito!",
|
||||
"tr": "Başarılı!",
|
||||
"uk": "Успіх!"
|
||||
},
|
||||
"DEVICE$ERROR_TITLE": {
|
||||
"en": "Error",
|
||||
"ja": "エラー",
|
||||
"zh-CN": "错误",
|
||||
"zh-TW": "錯誤",
|
||||
"ko-KR": "오류",
|
||||
"no": "Feil",
|
||||
"ar": "خطأ",
|
||||
"de": "Fehler",
|
||||
"fr": "Erreur",
|
||||
"it": "Errore",
|
||||
"pt": "Erro",
|
||||
"es": "Error",
|
||||
"tr": "Hata",
|
||||
"uk": "Помилка"
|
||||
},
|
||||
"DEVICE$SUCCESS_MESSAGE": {
|
||||
"en": "Device authorized successfully! You can now return to your CLI and close this window.",
|
||||
"ja": "デバイスが正常に認証されました!CLIに戻り、このウィンドウを閉じてください。",
|
||||
"zh-CN": "设备授权成功!您现在可以返回CLI并关闭此窗口。",
|
||||
"zh-TW": "設備授權成功!您現在可以返回 CLI 並關閉此視窗。",
|
||||
"ko-KR": "기기가 성공적으로 인증되었습니다! CLI로 돌아가서 이 창을 닫으세요.",
|
||||
"no": "Enheten er autorisert! Du kan nå gå tilbake til CLI og lukke dette vinduet.",
|
||||
"ar": "تم ترخيص الجهاز بنجاح! يمكنك الآن العودة إلى CLI وإغلاق هذه النافذة.",
|
||||
"de": "Gerät erfolgreich autorisiert! Sie können jetzt zu Ihrer CLI zurückkehren und dieses Fenster schließen.",
|
||||
"fr": "Appareil autorisé avec succès ! Vous pouvez maintenant retourner à votre CLI et fermer cette fenêtre.",
|
||||
"it": "Dispositivo autorizzato con successo! Ora puoi tornare alla CLI e chiudere questa finestra.",
|
||||
"pt": "Dispositivo autorizado com sucesso! Você pode voltar ao CLI e fechar esta janela.",
|
||||
"es": "¡Dispositivo autorizado exitosamente! Ahora puedes volver a tu CLI y cerrar esta ventana.",
|
||||
"tr": "Cihaz başarıyla yetkilendirildi! Artık CLI'nize dönebilir ve bu pencereyi kapatabilirsiniz.",
|
||||
"uk": "Пристрій успішно авторизовано! Тепер ви можете повернутися до CLI та закрити це вікно."
|
||||
},
|
||||
"DEVICE$ERROR_FAILED": {
|
||||
"en": "Failed to authorize device. Please try again.",
|
||||
"ja": "デバイスの認証に失敗しました。もう一度お試しください。",
|
||||
"zh-CN": "设备授权失败。请重试。",
|
||||
"zh-TW": "設備授權失敗。請重試。",
|
||||
"ko-KR": "기기 인증에 실패했습니다. 다시 시도해 주세요.",
|
||||
"no": "Kunne ikke autorisere enheten. Vennligst prøv igjen.",
|
||||
"ar": "فشل في ترخيص الجهاز. يرجى المحاولة مرة أخرى.",
|
||||
"de": "Geräteautorisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"fr": "Échec de l'autorisation de l'appareil. Veuillez réessayer.",
|
||||
"it": "Autorizzazione dispositivo fallita. Riprova.",
|
||||
"pt": "Falha ao autorizar o dispositivo. Por favor, tente novamente.",
|
||||
"es": "Error al autorizar el dispositivo. Por favor, inténtalo de nuevo.",
|
||||
"tr": "Cihaz yetkilendirilemedi. Lütfen tekrar deneyin.",
|
||||
"uk": "Не вдалося авторизувати пристрій. Будь ласка, спробуйте ще раз."
|
||||
},
|
||||
"DEVICE$ERROR_OCCURRED": {
|
||||
"en": "An error occurred while authorizing the device. Please try again.",
|
||||
"ja": "デバイスの認証中にエラーが発生しました。もう一度お試しください。",
|
||||
"zh-CN": "授权设备时发生错误。请重试。",
|
||||
"zh-TW": "授權設備時發生錯誤。請重試。",
|
||||
"ko-KR": "기기 인증 중 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||
"no": "En feil oppstod under autorisering av enheten. Vennligst prøv igjen.",
|
||||
"ar": "حدث خطأ أثناء ترخيص الجهاز. يرجى المحاولة مرة أخرى.",
|
||||
"de": "Bei der Geräteautorisierung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
"fr": "Une erreur s'est produite lors de l'autorisation de l'appareil. Veuillez réessayer.",
|
||||
"it": "Si è verificato un errore durante l'autorizzazione del dispositivo. Riprova.",
|
||||
"pt": "Ocorreu um erro ao autorizar o dispositivo. Por favor, tente novamente.",
|
||||
"es": "Ocurrió un error al autorizar el dispositivo. Por favor, inténtalo de nuevo.",
|
||||
"tr": "Cihaz yetkilendirirken bir hata oluştu. Lütfen tekrar deneyin.",
|
||||
"uk": "Під час авторизації пристрою сталася помилка. Будь ласка, спробуйте ще раз."
|
||||
},
|
||||
"DEVICE$TRY_AGAIN": {
|
||||
"en": "Try Again",
|
||||
"ja": "再試行",
|
||||
"zh-CN": "重试",
|
||||
"zh-TW": "重試",
|
||||
"ko-KR": "다시 시도",
|
||||
"no": "Prøv igjen",
|
||||
"ar": "حاول مرة أخرى",
|
||||
"de": "Erneut versuchen",
|
||||
"fr": "Réessayer",
|
||||
"it": "Riprova",
|
||||
"pt": "Tentar novamente",
|
||||
"es": "Intentar de nuevo",
|
||||
"tr": "Tekrar Dene",
|
||||
"uk": "Спробувати ще раз"
|
||||
},
|
||||
"DEVICE$PROCESSING": {
|
||||
"en": "Processing device verification...",
|
||||
"ja": "デバイス認証を処理中...",
|
||||
"zh-CN": "正在处理设备验证...",
|
||||
"zh-TW": "正在處理設備驗證...",
|
||||
"ko-KR": "기기 인증 처리 중...",
|
||||
"no": "Behandler enhetsverifisering...",
|
||||
"ar": "جارٍ معالجة التحقق من الجهاز...",
|
||||
"de": "Geräteverifizierung wird verarbeitet...",
|
||||
"fr": "Traitement de la vérification de l'appareil...",
|
||||
"it": "Elaborazione verifica dispositivo...",
|
||||
"pt": "Processando verificação do dispositivo...",
|
||||
"es": "Procesando verificación del dispositivo...",
|
||||
"tr": "Cihaz doğrulaması işleniyor...",
|
||||
"uk": "Обробка перевірки пристрою..."
|
||||
},
|
||||
"DEVICE$AUTHORIZATION_REQUEST": {
|
||||
"en": "Device Authorization Request",
|
||||
"ja": "デバイス認証リクエスト",
|
||||
"zh-CN": "设备授权请求",
|
||||
"zh-TW": "設備授權請求",
|
||||
"ko-KR": "기기 인증 요청",
|
||||
"no": "Forespørsel om enhetsautorisasjon",
|
||||
"ar": "طلب ترخيص الجهاز",
|
||||
"de": "Geräteautorisierungsanfrage",
|
||||
"fr": "Demande d'autorisation d'appareil",
|
||||
"it": "Richiesta di autorizzazione dispositivo",
|
||||
"pt": "Solicitação de autorização do dispositivo",
|
||||
"es": "Solicitud de autorización del dispositivo",
|
||||
"tr": "Cihaz Yetkilendirme Talebi",
|
||||
"uk": "Запит на авторизацію пристрою"
|
||||
},
|
||||
"DEVICE$CODE_LABEL": {
|
||||
"en": "DEVICE CODE",
|
||||
"ja": "デバイスコード",
|
||||
"zh-CN": "设备代码",
|
||||
"zh-TW": "設備代碼",
|
||||
"ko-KR": "기기 코드",
|
||||
"no": "ENHETSKODE",
|
||||
"ar": "رمز الجهاز",
|
||||
"de": "GERÄTECODE",
|
||||
"fr": "CODE DE L'APPAREIL",
|
||||
"it": "CODICE DISPOSITIVO",
|
||||
"pt": "CÓDIGO DO DISPOSITIVO",
|
||||
"es": "CÓDIGO DE DISPOSITIVO",
|
||||
"tr": "CİHAZ KODU",
|
||||
"uk": "КОД ПРИСТРОЮ"
|
||||
},
|
||||
"DEVICE$SECURITY_NOTICE": {
|
||||
"en": "Security Notice",
|
||||
"ja": "セキュリティ通知",
|
||||
"zh-CN": "安全提示",
|
||||
"zh-TW": "安全提示",
|
||||
"ko-KR": "보안 알림",
|
||||
"no": "Sikkerhetsvarsel",
|
||||
"ar": "إشعار أمني",
|
||||
"de": "Sicherheitshinweis",
|
||||
"fr": "Avis de sécurité",
|
||||
"it": "Avviso di sicurezza",
|
||||
"pt": "Aviso de segurança",
|
||||
"es": "Aviso de seguridad",
|
||||
"tr": "Güvenlik Bildirimi",
|
||||
"uk": "Повідомлення про безпеку"
|
||||
},
|
||||
"DEVICE$SECURITY_WARNING": {
|
||||
"en": "Only authorize this device if you initiated this request from your CLI or application.",
|
||||
"ja": "CLIまたはアプリケーションからこのリクエストを開始した場合のみ、このデバイスを認証してください。",
|
||||
"zh-CN": "仅当您从 CLI 或应用程序发起此请求时,才授权此设备。",
|
||||
"zh-TW": "僅當您從 CLI 或應用程式發起此請求時,才授權此設備。",
|
||||
"ko-KR": "CLI 또는 애플리케이션에서 이 요청을 시작한 경우에만 이 기기를 인증하세요.",
|
||||
"no": "Bare autoriser denne enheten hvis du startet denne forespørselen fra CLI eller applikasjonen din.",
|
||||
"ar": "قم بترخيص هذا الجهاز فقط إذا كنت قد بدأت هذا الطلب من CLI أو التطبيق الخاص بك.",
|
||||
"de": "Autorisieren Sie dieses Gerät nur, wenn Sie diese Anfrage von Ihrer CLI oder Anwendung aus gestartet haben.",
|
||||
"fr": "N'autorisez cet appareil que si vous avez initié cette demande depuis votre CLI ou application.",
|
||||
"it": "Autorizza questo dispositivo solo se hai avviato questa richiesta dalla tua CLI o applicazione.",
|
||||
"pt": "Autorize este dispositivo apenas se você iniciou esta solicitação do seu CLI ou aplicativo.",
|
||||
"es": "Solo autoriza este dispositivo si iniciaste esta solicitud desde tu CLI o aplicación.",
|
||||
"tr": "Bu cihazı yalnızca bu isteği CLI veya uygulamanızdan başlattıysanız yetkilendirin.",
|
||||
"uk": "Авторизуйте цей пристрій лише якщо ви ініціювали цей запит з вашого CLI або додатку."
|
||||
},
|
||||
"DEVICE$CONFIRM_PROMPT": {
|
||||
"en": "Do you want to authorize this device to access your OpenHands account?",
|
||||
"ja": "このデバイスにOpenHandsアカウントへのアクセスを許可しますか?",
|
||||
"zh-CN": "您想授权此设备访问您的 OpenHands 帐户吗?",
|
||||
"zh-TW": "您想授權此設備訪問您的 OpenHands 帳戶嗎?",
|
||||
"ko-KR": "이 기기가 OpenHands 계정에 액세스하도록 인증하시겠습니까?",
|
||||
"no": "Vil du autorisere denne enheten til å få tilgang til din OpenHands-konto?",
|
||||
"ar": "هل تريد ترخيص هذا الجهاز للوصول إلى حسابك في OpenHands؟",
|
||||
"de": "Möchten Sie dieses Gerät autorisieren, um auf Ihr OpenHands-Konto zuzugreifen?",
|
||||
"fr": "Voulez-vous autoriser cet appareil à accéder à votre compte OpenHands ?",
|
||||
"it": "Vuoi autorizzare questo dispositivo ad accedere al tuo account OpenHands?",
|
||||
"pt": "Deseja autorizar este dispositivo a acessar sua conta OpenHands?",
|
||||
"es": "¿Deseas autorizar este dispositivo para acceder a tu cuenta de OpenHands?",
|
||||
"tr": "Bu cihazın OpenHands hesabınıza erişmesine izin vermek istiyor musunuz?",
|
||||
"uk": "Бажаєте авторизувати цей пристрій для доступу до вашого облікового запису OpenHands?"
|
||||
},
|
||||
"DEVICE$CANCEL": {
|
||||
"en": "Cancel",
|
||||
"ja": "キャンセル",
|
||||
"zh-CN": "取消",
|
||||
"zh-TW": "取消",
|
||||
"ko-KR": "취소",
|
||||
"no": "Avbryt",
|
||||
"ar": "إلغاء",
|
||||
"de": "Abbrechen",
|
||||
"fr": "Annuler",
|
||||
"it": "Annulla",
|
||||
"pt": "Cancelar",
|
||||
"es": "Cancelar",
|
||||
"tr": "İptal",
|
||||
"uk": "Скасувати"
|
||||
},
|
||||
"DEVICE$AUTHORIZE": {
|
||||
"en": "Authorize Device",
|
||||
"ja": "デバイスを認証",
|
||||
"zh-CN": "授权设备",
|
||||
"zh-TW": "授權設備",
|
||||
"ko-KR": "기기 인증",
|
||||
"no": "Autoriser enhet",
|
||||
"ar": "ترخيص الجهاز",
|
||||
"de": "Gerät autorisieren",
|
||||
"fr": "Autoriser l'appareil",
|
||||
"it": "Autorizza dispositivo",
|
||||
"pt": "Autorizar dispositivo",
|
||||
"es": "Autorizar dispositivo",
|
||||
"tr": "Cihazı Yetkilendir",
|
||||
"uk": "Авторизувати пристрій"
|
||||
},
|
||||
"DEVICE$AUTHORIZATION_TITLE": {
|
||||
"en": "Device Authorization",
|
||||
"ja": "デバイス認証",
|
||||
"zh-CN": "设备授权",
|
||||
"zh-TW": "設備授權",
|
||||
"ko-KR": "기기 인증",
|
||||
"no": "Enhetsautorisasjon",
|
||||
"ar": "ترخيص الجهاز",
|
||||
"de": "Geräteautorisierung",
|
||||
"fr": "Autorisation d'appareil",
|
||||
"it": "Autorizzazione dispositivo",
|
||||
"pt": "Autorização do dispositivo",
|
||||
"es": "Autorización del dispositivo",
|
||||
"tr": "Cihaz Yetkilendirme",
|
||||
"uk": "Авторизація пристрою"
|
||||
},
|
||||
"DEVICE$ENTER_CODE_PROMPT": {
|
||||
"en": "Enter the code displayed on your device:",
|
||||
"ja": "デバイスに表示されているコードを入力してください:",
|
||||
"zh-CN": "输入设备上显示的代码:",
|
||||
"zh-TW": "輸入設備上顯示的代碼:",
|
||||
"ko-KR": "기기에 표시된 코드를 입력하세요:",
|
||||
"no": "Skriv inn koden som vises på enheten din:",
|
||||
"ar": "أدخل الرمز المعروض على جهازك:",
|
||||
"de": "Geben Sie den auf Ihrem Gerät angezeigten Code ein:",
|
||||
"fr": "Entrez le code affiché sur votre appareil :",
|
||||
"it": "Inserisci il codice visualizzato sul tuo dispositivo:",
|
||||
"pt": "Digite o código exibido no seu dispositivo:",
|
||||
"es": "Ingresa el código mostrado en tu dispositivo:",
|
||||
"tr": "Cihazınızda görüntülenen kodu girin:",
|
||||
"uk": "Введіть код, відображений на вашому пристрої:"
|
||||
},
|
||||
"DEVICE$CODE_INPUT_LABEL": {
|
||||
"en": "Device Code:",
|
||||
"ja": "デバイスコード:",
|
||||
"zh-CN": "设备代码:",
|
||||
"zh-TW": "設備代碼:",
|
||||
"ko-KR": "기기 코드:",
|
||||
"no": "Enhetskode:",
|
||||
"ar": "رمز الجهاز:",
|
||||
"de": "Gerätecode:",
|
||||
"fr": "Code de l'appareil :",
|
||||
"it": "Codice dispositivo:",
|
||||
"pt": "Código do dispositivo:",
|
||||
"es": "Código del dispositivo:",
|
||||
"tr": "Cihaz Kodu:",
|
||||
"uk": "Код пристрою:"
|
||||
},
|
||||
"DEVICE$CODE_PLACEHOLDER": {
|
||||
"en": "Enter your device code",
|
||||
"ja": "デバイスコードを入力",
|
||||
"zh-CN": "输入您的设备代码",
|
||||
"zh-TW": "輸入您的設備代碼",
|
||||
"ko-KR": "기기 코드를 입력하세요",
|
||||
"no": "Skriv inn enhetskoden din",
|
||||
"ar": "أدخل رمز جهازك",
|
||||
"de": "Geben Sie Ihren Gerätecode ein",
|
||||
"fr": "Entrez votre code d'appareil",
|
||||
"it": "Inserisci il tuo codice dispositivo",
|
||||
"pt": "Digite o código do seu dispositivo",
|
||||
"es": "Ingresa tu código de dispositivo",
|
||||
"tr": "Cihaz kodunuzu girin",
|
||||
"uk": "Введіть код вашого пристрою"
|
||||
},
|
||||
"DEVICE$CONTINUE": {
|
||||
"en": "Continue",
|
||||
"ja": "続行",
|
||||
"zh-CN": "继续",
|
||||
"zh-TW": "繼續",
|
||||
"ko-KR": "계속",
|
||||
"no": "Fortsett",
|
||||
"ar": "متابعة",
|
||||
"de": "Fortfahren",
|
||||
"fr": "Continuer",
|
||||
"it": "Continua",
|
||||
"pt": "Continuar",
|
||||
"es": "Continuar",
|
||||
"tr": "Devam",
|
||||
"uk": "Продовжити"
|
||||
},
|
||||
"DEVICE$AUTH_REQUIRED": {
|
||||
"en": "Authentication Required",
|
||||
"ja": "認証が必要です",
|
||||
"zh-CN": "需要身份验证",
|
||||
"zh-TW": "需要身份驗證",
|
||||
"ko-KR": "인증 필요",
|
||||
"no": "Autentisering kreves",
|
||||
"ar": "المصادقة مطلوبة",
|
||||
"de": "Authentifizierung erforderlich",
|
||||
"fr": "Authentification requise",
|
||||
"it": "Autenticazione richiesta",
|
||||
"pt": "Autenticação necessária",
|
||||
"es": "Autenticación requerida",
|
||||
"tr": "Kimlik Doğrulama Gerekli",
|
||||
"uk": "Потрібна автентифікація"
|
||||
},
|
||||
"DEVICE$SIGN_IN_PROMPT": {
|
||||
"en": "Please sign in to authorize your device.",
|
||||
"ja": "デバイスを認証するにはサインインしてください。",
|
||||
"zh-CN": "请登录以授权您的设备。",
|
||||
"zh-TW": "請登入以授權您的設備。",
|
||||
"ko-KR": "기기를 인증하려면 로그인하세요.",
|
||||
"no": "Vennligst logg inn for å autorisere enheten din.",
|
||||
"ar": "يرجى تسجيل الدخول لترخيص جهازك.",
|
||||
"de": "Bitte melden Sie sich an, um Ihr Gerät zu autorisieren.",
|
||||
"fr": "Veuillez vous connecter pour autoriser votre appareil.",
|
||||
"it": "Accedi per autorizzare il tuo dispositivo.",
|
||||
"pt": "Por favor, faça login para autorizar seu dispositivo.",
|
||||
"es": "Por favor, inicia sesión para autorizar tu dispositivo.",
|
||||
"tr": "Cihazınızı yetkilendirmek için lütfen giriş yapın.",
|
||||
"uk": "Будь ласка, увійдіть, щоб авторизувати свій пристрій."
|
||||
}
|
||||
}
|
||||
|
||||
3
frontend/src/icons/check-circle-fill.svg
Normal file
3
frontend/src/icons/check-circle-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 279 B |
@@ -1,16 +1,22 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
import React, { useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { H1 } from "#/ui/typography";
|
||||
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
export default function DeviceVerify() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: isAuthed, isLoading: isAuthLoading } = useIsAuthed();
|
||||
const [verificationResult, setVerificationResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
messageKey: I18nKey;
|
||||
} | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const showEnterpriseBanner = PROJ_USER_JOURNEY();
|
||||
|
||||
// Get user_code from URL parameters
|
||||
const userCode = searchParams.get("user_code");
|
||||
@@ -33,21 +39,18 @@ export default function DeviceVerify() {
|
||||
// Show success message
|
||||
setVerificationResult({
|
||||
success: true,
|
||||
message:
|
||||
"Device authorized successfully! You can now return to your CLI and close this window.",
|
||||
messageKey: I18nKey.DEVICE$SUCCESS_MESSAGE,
|
||||
});
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
setVerificationResult({
|
||||
success: false,
|
||||
message: errorText || "Failed to authorize device. Please try again.",
|
||||
messageKey: I18nKey.DEVICE$ERROR_FAILED,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setVerificationResult({
|
||||
success: false,
|
||||
message:
|
||||
"An error occurred while authorizing the device. Please try again.",
|
||||
messageKey: I18nKey.DEVICE$ERROR_OCCURRED,
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
@@ -105,10 +108,12 @@ export default function DeviceVerify() {
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{verificationResult.success ? "Success!" : "Error"}
|
||||
{verificationResult.success
|
||||
? t(I18nKey.DEVICE$SUCCESS_TITLE)
|
||||
: t(I18nKey.DEVICE$ERROR_TITLE)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{verificationResult.message}
|
||||
{t(verificationResult.messageKey)}
|
||||
</p>
|
||||
{!verificationResult.success && (
|
||||
<button
|
||||
@@ -116,7 +121,7 @@ export default function DeviceVerify() {
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Try Again
|
||||
{t(I18nKey.DEVICE$TRY_AGAIN)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -133,7 +138,7 @@ export default function DeviceVerify() {
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
Processing device verification...
|
||||
{t(I18nKey.DEVICE$PROCESSING)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,63 +149,56 @@ export default function DeviceVerify() {
|
||||
// Show device authorization confirmation if user is authenticated and code is provided
|
||||
if (isAuthed && userCode) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="max-w-md w-full mx-auto p-6 bg-card rounded-lg shadow-lg">
|
||||
<h1 className="text-2xl font-bold mb-4 text-center">
|
||||
Device Authorization Request
|
||||
</h1>
|
||||
<div className="mb-6 p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-2">Device Code:</p>
|
||||
<p className="text-lg font-mono font-semibold text-center tracking-wider">
|
||||
{userCode}
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div
|
||||
className={`flex flex-col lg:flex-row items-center lg:items-start gap-6 w-full ${showEnterpriseBanner ? "max-w-4xl" : "max-w-md"}`}
|
||||
>
|
||||
{/* Device Authorization Card */}
|
||||
<div
|
||||
className={`flex-1 min-w-0 max-w-md w-full mx-auto p-6 bg-card rounded-lg shadow-lg border border-neutral-700 ${showEnterpriseBanner ? "lg:mx-0" : ""}`}
|
||||
>
|
||||
<H1 className="text-2xl mb-4 text-center">
|
||||
{t(I18nKey.DEVICE$AUTHORIZATION_REQUEST)}
|
||||
</H1>
|
||||
<div className="mb-6 p-4 bg-neutral-900 rounded-lg border border-neutral-700">
|
||||
<p className="text-xs text-neutral-500 mb-2 text-center uppercase tracking-wider">
|
||||
{t(I18nKey.DEVICE$CODE_LABEL)}
|
||||
</p>
|
||||
<p className="text-xl font-mono font-semibold text-center tracking-[0.3em]">
|
||||
{userCode}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 p-4 bg-amber-950/50 border-l-2 border-amber-500 rounded-r-lg">
|
||||
<p className="text-sm font-medium text-amber-500 mb-1">
|
||||
{t(I18nKey.DEVICE$SECURITY_NOTICE)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t(I18nKey.DEVICE$SECURITY_WARNING)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 text-center">
|
||||
{t(I18nKey.DEVICE$CONFIRM_PROMPT)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-start">
|
||||
<svg
|
||||
className="w-5 h-5 text-yellow-600 mt-0.5 mr-2 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="flex-1 px-4 py-2 border border-neutral-600 rounded-md hover:bg-muted text-gray-300"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800 mb-1">
|
||||
Security Notice
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700">
|
||||
Only authorize this device if you initiated this request from
|
||||
your CLI or application.
|
||||
</p>
|
||||
</div>
|
||||
{t(I18nKey.DEVICE$CANCEL)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => processDeviceVerification(userCode)}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
{t(I18nKey.DEVICE$AUTHORIZE)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 text-center">
|
||||
Do you want to authorize this device to access your OpenHands
|
||||
account?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="flex-1 px-4 py-2 border border-input rounded-md hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => processDeviceVerification(userCode)}
|
||||
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Authorize Device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Banner */}
|
||||
{showEnterpriseBanner && <EnterpriseBanner />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -211,11 +209,11 @@ export default function DeviceVerify() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="max-w-md w-full mx-auto p-6 bg-card rounded-lg shadow-lg">
|
||||
<h1 className="text-2xl font-bold mb-4 text-center">
|
||||
Device Authorization
|
||||
</h1>
|
||||
<H1 className="text-2xl mb-4 text-center">
|
||||
{t(I18nKey.DEVICE$AUTHORIZATION_TITLE)}
|
||||
</H1>
|
||||
<p className="text-muted-foreground mb-6 text-center">
|
||||
Enter the code displayed on your device:
|
||||
{t(I18nKey.DEVICE$ENTER_CODE_PROMPT)}
|
||||
</p>
|
||||
<form onSubmit={handleManualSubmit}>
|
||||
<div className="mb-4">
|
||||
@@ -223,7 +221,7 @@ export default function DeviceVerify() {
|
||||
htmlFor="user_code"
|
||||
className="block text-sm font-medium mb-2"
|
||||
>
|
||||
Device Code:
|
||||
{t(I18nKey.DEVICE$CODE_INPUT_LABEL)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -231,14 +229,14 @@ export default function DeviceVerify() {
|
||||
name="user_code"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Enter your device code"
|
||||
placeholder={t(I18nKey.DEVICE$CODE_PLACEHOLDER)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Continue
|
||||
{t(I18nKey.DEVICE$CONTINUE)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -253,7 +251,7 @@ export default function DeviceVerify() {
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
Processing device verification...
|
||||
{t(I18nKey.DEVICE$PROCESSING)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,9 +262,9 @@ export default function DeviceVerify() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="max-w-md w-full mx-auto p-6 bg-card rounded-lg shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Authentication Required</h1>
|
||||
<H1 className="text-2xl mb-4">{t(I18nKey.DEVICE$AUTH_REQUIRED)}</H1>
|
||||
<p className="text-muted-foreground">
|
||||
Please sign in to authorize your device.
|
||||
{t(I18nKey.DEVICE$SIGN_IN_PROMPT)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,3 +20,4 @@ export const ENABLE_TRAJECTORY_REPLAY = () =>
|
||||
export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING");
|
||||
export const ENABLE_SANDBOX_GROUPING = () =>
|
||||
loadFeatureFlag("SANDBOX_GROUPING");
|
||||
export const PROJ_USER_JOURNEY = () => loadFeatureFlag("PROJ_USER_JOURNEY");
|
||||
|
||||
Reference in New Issue
Block a user