refactor(frontend): remove the Integrations page in favor of Workspaces (#215)

* refactor: remove the Integrations page in favor of Workspaces

* refactor: update the code based on feedback
This commit is contained in:
Hiep Le
2026-05-09 17:35:46 +07:00
committed by GitHub
parent 75d67e2ff3
commit b336afb3d5
54 changed files with 74 additions and 3101 deletions
+3 -2
View File
@@ -19,7 +19,8 @@ describe("OptionService", () => {
it("returns config in mock mode without a live backend", async () => {
const config = await OptionService.getConfig();
expect(config.feature_flags.hide_integrations_page).toBe(false);
expect(config.feature_flags.hide_llm_settings).toBe(false);
expect(config.feature_flags.hide_users_page).toBe(true);
expect(config.updated_at).toBeTruthy();
});
@@ -66,7 +67,7 @@ describe("OptionService", () => {
await expect(OptionService.getConfig()).resolves.toMatchObject({
feature_flags: expect.objectContaining({
hide_integrations_page: false,
hide_llm_settings: false,
}),
});
});
+2 -2
View File
@@ -128,8 +128,8 @@ describe("SettingsService", () => {
// Arrange: simulate post-save state. SecretsService.addGitProvider writes to
// this localStorage key after the server PUT /api/settings/secrets succeeds.
// The agent-server API never returns provider_tokens_set, so the GUI must
// derive it from local state for useUserProviders / git-settings to recognize
// the configured providers.
// derive it from local state for useUserProviders to recognize the
// configured providers.
window.localStorage.setItem(
"openhands-agent-server-git-provider-tokens",
JSON.stringify({
@@ -24,10 +24,6 @@ vi.mock("#/components/features/home/workspace-selection-form", () => ({
WorkspaceSelectionForm: () => <div data-testid="stub-workspace-form" />,
}));
vi.mock("#/components/features/home/connect-to-provider-message", () => ({
ConnectToProviderMessage: () => <div data-testid="stub-connect-message" />,
}));
const cloudBackend: Backend = {
id: "prod",
name: "Production",
@@ -78,7 +74,7 @@ describe("RepoConnector", () => {
).not.toBeInTheDocument();
});
it("shows the connect-provider message for cloud backends without providers", () => {
it("does not render the repository launcher for cloud backends without providers", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
mockUseUserProviders.mockReturnValue({
@@ -88,7 +84,6 @@ describe("RepoConnector", () => {
renderRepoConnector();
expect(screen.getByTestId("stub-connect-message")).toBeInTheDocument();
expect(screen.queryByTestId("stub-repo-form")).not.toBeInTheDocument();
expect(
screen.queryByTestId("stub-workspace-form"),
@@ -35,11 +35,15 @@ vi.mock("#/hooks/query/use-config", () => ({
}),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
const { useUserProvidersMock } = vi.hoisted(() => ({
useUserProvidersMock: vi.fn(() => ({
providers: [{ id: "github", name: "GitHub" }],
isLoading: false,
}),
})),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => useUserProvidersMock(),
}));
const renderTaskSuggestions = () => {
@@ -88,6 +92,21 @@ describe("TaskSuggestions", () => {
await screen.findByText("TASKS$NO_TASKS_AVAILABLE");
});
it("falls back to the generic empty message when no Git providers are connected and exposes no link to the removed Integrations page", async () => {
useUserProvidersMock.mockReturnValueOnce({
providers: [],
isLoading: false,
});
getSuggestedTasksSpy.mockResolvedValue([]);
renderTaskSuggestions();
await screen.findByText("TASKS$NO_TASKS_AVAILABLE");
expect(
screen.queryByRole("link", { name: /integrations/i }),
).not.toBeInTheDocument();
});
it("should render the task groups with the correct titles", async () => {
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
renderTaskSuggestions();
@@ -1,97 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WebhookStatusBadge } from "#/components/features/settings/git-settings/webhook-status-badge";
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("WebhookStatusBadge", () => {
it("should display installed status when webhook is installed", () => {
// Arrange
const props = {
webhookInstalled: true,
};
// Act
render(<WebhookStatusBadge {...props} />);
// Assert
const badge = screen.getByText("GITLAB$WEBHOOK_STATUS_INSTALLED");
expect(badge).toBeInTheDocument();
});
it("should display not installed status when webhook is not installed", () => {
// Arrange
const props = {
webhookInstalled: false,
};
// Act
render(<WebhookStatusBadge {...props} />);
// Assert
const badge = screen.getByText("GITLAB$WEBHOOK_STATUS_NOT_INSTALLED");
expect(badge).toBeInTheDocument();
});
it("should display installed status when installation result is successful", () => {
// Arrange
const props = {
webhookInstalled: false,
installationResult: {
success: true,
error: null,
},
};
// Act
render(<WebhookStatusBadge {...props} />);
// Assert
const badge = screen.getByText("GITLAB$WEBHOOK_STATUS_INSTALLED");
expect(badge).toBeInTheDocument();
});
it("should display failed status when installation result has error", () => {
// Arrange
const props = {
webhookInstalled: false,
installationResult: {
success: false,
error: "Installation failed",
},
};
// Act
render(<WebhookStatusBadge {...props} />);
// Assert
const badge = screen.getByText("GITLAB$WEBHOOK_STATUS_FAILED");
expect(badge).toBeInTheDocument();
});
it("should show error message when installation fails", () => {
// Arrange
const errorMessage = "Permission denied";
const props = {
webhookInstalled: false,
installationResult: {
success: false,
error: errorMessage,
},
};
// Act
render(<WebhookStatusBadge {...props} />);
// Assert
const badgeContainer = screen.getByText(
"GITLAB$WEBHOOK_STATUS_FAILED",
).parentElement;
expect(badgeContainer).toHaveAttribute("title", errorMessage);
});
});
@@ -20,7 +20,7 @@ function renderNavigationLink(
const result = render(
<NavigationProvider value={value}>
<NavigationLink to="/settings/integrations">Integrations</NavigationLink>
<NavigationLink to="/settings/mcp">MCP</NavigationLink>
</NavigationProvider>,
);
@@ -32,13 +32,13 @@ function renderNavigationLink(
describe("NavigationLink", () => {
it("renders the destination href and active state from navigation context", () => {
renderNavigationLink("/settings/integrations");
renderNavigationLink("/settings/mcp");
expect(screen.getByRole("link", { name: "Integrations" })).toHaveAttribute(
expect(screen.getByRole("link", { name: "MCP" })).toHaveAttribute(
"href",
"/settings/integrations",
"/settings/mcp",
);
expect(screen.getByRole("link", { name: "Integrations" })).toHaveAttribute(
expect(screen.getByRole("link", { name: "MCP" })).toHaveAttribute(
"aria-current",
"page",
);
@@ -46,11 +46,11 @@ describe("NavigationLink", () => {
it("uses the injected navigate callback on click", () => {
const { navigate } = renderNavigationLink();
const link = screen.getByRole("link", { name: "Integrations" });
const link = screen.getByRole("link", { name: "MCP" });
fireEvent.click(link);
expect(navigate).toHaveBeenCalledWith("/settings/integrations", {
expect(navigate).toHaveBeenCalledWith("/settings/mcp", {
replace: false,
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ import { ActiveBackendProvider } from "#/contexts/active-backend-context";
vi.mock("#/hooks/use-settings-nav-items", () => ({
useSettingsNavItems: () => [
{ type: "item", item: OSS_NAV_ITEMS[0] },
{ type: "item", item: OSS_NAV_ITEMS[6] },
{ type: "item", item: OSS_NAV_ITEMS[5] },
],
}));
@@ -1,70 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SecretsService } from "#/api/secrets-service";
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
import { Provider, ProviderToken } from "#/types/settings";
const mockTrackGitProviderConnected = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackGitProviderConnected: mockTrackGitProviderConnected,
}),
}));
const buildProviders = (
overrides: Partial<Record<Provider, ProviderToken>> = {},
): Record<Provider, ProviderToken> => ({
github: { token: "", host: null },
gitlab: { token: "", host: null },
bitbucket: { token: "", host: null },
bitbucket_data_center: { token: "", host: null },
azure_devops: { token: "", host: null },
forgejo: { token: "", host: null },
...overrides,
});
describe("useAddGitProviders", () => {
let queryClient: QueryClient;
beforeEach(() => {
vi.restoreAllMocks();
mockTrackGitProviderConnected.mockReset();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
it("invalidates personal settings queries after saving providers", async () => {
vi.spyOn(SecretsService, "addGitProvider").mockResolvedValue(undefined);
const personalSettingsQueryKey = ["settings", "personal"] as const;
queryClient.setQueryData(personalSettingsQueryKey, {
provider_tokens_set: {},
});
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useAddGitProviders(), { wrapper });
await result.current.mutateAsync({
providers: buildProviders({
github: { token: "ghp_test_123", host: null },
}),
});
expect(invalidateSpy).toHaveBeenCalled();
expect(
queryClient.getQueryState(personalSettingsQueryKey)?.isInvalidated,
).toBe(true);
});
});
@@ -5,49 +5,18 @@ import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
import { WebClientConfig } from "#/api/option-service/option.types";
const useConfigMock = vi.fn();
const useActiveBackendMock = vi.fn();
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => useConfigMock(),
}));
vi.mock("#/contexts/active-backend-context", () => ({
useActiveBackend: () => useActiveBackendMock(),
}));
const localActive = {
backend: {
id: "__bundled__",
name: "Local",
host: "http://localhost",
apiKey: "",
kind: "local" as const,
},
orgId: null,
};
const cloudActive = {
backend: {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer",
kind: "cloud" as const,
},
orgId: null,
};
const createConfig = (
feature_flags: Partial<WebClientConfig["feature_flags"]> = {},
): WebClientConfig => ({
posthog_client_key: null,
feature_flags: {
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: true,
hide_integrations_page: false,
...feature_flags,
},
providers_configured: [],
@@ -61,7 +30,6 @@ const createConfig = (
describe("useSettingsNavItems", () => {
beforeEach(() => {
vi.clearAllMocks();
useActiveBackendMock.mockReturnValue(localActive);
});
it("returns the OSS settings items in order", () => {
@@ -76,10 +44,7 @@ describe("useSettingsNavItems", () => {
it("filters hidden routes from the OSS settings items", () => {
useConfigMock.mockReturnValue({
data: createConfig({
hide_llm_settings: true,
hide_integrations_page: true,
}),
data: createConfig({ hide_llm_settings: true }),
});
const { result } = renderHook(() => useSettingsNavItems());
@@ -88,38 +53,18 @@ describe("useSettingsNavItems", () => {
.map((item) => (item.type === "item" ? item.item.to : null));
expect(paths).not.toContain("/settings");
expect(paths).not.toContain("/settings/integrations");
expect(paths).toContain("/settings/mcp");
expect(paths).toContain("/settings/secrets");
});
it("never lists the removed /settings/agent-server entry", () => {
it("never lists the removed /settings/integrations entry", () => {
useConfigMock.mockReturnValue({ data: createConfig() });
const { result } = renderHook(() => useSettingsNavItems());
const paths = result.current
.filter((item) => item.type === "item")
.map((item) => (item.type === "item" ? item.item.to : null));
expect(paths).not.toContain("/settings/agent-server");
});
it("hides local-only sub-pages when the active backend is cloud", () => {
useConfigMock.mockReturnValue({ data: createConfig() });
useActiveBackendMock.mockReturnValue(cloudActive);
const { result } = renderHook(() => useSettingsNavItems());
const paths = result.current
.filter((item) => item.type === "item")
.map((item) => (item.type === "item" ? item.item.to : null));
expect(paths).not.toContain("/settings/integrations");
expect(paths).toContain("/settings");
expect(paths).toContain("/settings/condenser");
expect(paths).toContain("/settings/verification");
expect(paths).toContain("/settings/mcp");
expect(paths).toContain("/settings/skills");
expect(paths).toContain("/settings/secrets");
});
});
-1
View File
@@ -34,7 +34,6 @@ describe("library public entrypoints", () => {
expect(filesApi.FileExplorer).toBeTypeOf("function");
expect(settingsApi.SettingsPanel).toBeTypeOf("function");
expect(settingsApi.AppSettings).toBeTypeOf("function");
expect(settingsApi.GitSettings).toBeTypeOf("function");
expect(settingsApi.LLMSettings).toBeTypeOf("function");
expect(settingsApi.MCPSettings).toBeTypeOf("function");
expect(settingsApi.SecretsSettings).toBeTypeOf("function");
+3 -3
View File
@@ -52,14 +52,14 @@ const RouterStub = createRoutesStub([
path: "/settings",
},
{
Component: () => <div data-testid="git-settings" />,
path: "/settings/integrations",
Component: () => <div data-testid="mcp-settings" />,
path: "/settings/mcp",
},
],
},
]);
render(<RouterStub initialEntries={["/settings/integrations"]} />);
render(<RouterStub initialEntries={["/settings/mcp"]} />);
```
> [!TIP]
-87
View File
@@ -1,87 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { GitSettingsScreen } from "#/routes/git-settings";
import { Settings } from "#/types/settings";
function buildSettings(overrides: Partial<Settings> = {}): Settings {
return {
...MOCK_DEFAULT_USER_SETTINGS,
...overrides,
provider_tokens_set: {
...MOCK_DEFAULT_USER_SETTINGS.provider_tokens_set,
...overrides.provider_tokens_set,
},
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
...overrides.agent_settings,
},
};
}
function renderGitSettingsScreen() {
return render(<GitSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={new QueryClient({
defaultOptions: { queries: { retry: false } },
})}
>
{children}
</QueryClientProvider>
),
});
}
describe("GitSettingsScreen", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("renders OSS git provider token inputs", async () => {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());
renderGitSettingsScreen();
const githubTokenInput = await screen.findByTestId("github-token-input");
expect(githubTokenInput).toBeInTheDocument();
expect(screen.getByTestId("gitlab-token-input")).toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeDisabled();
});
it("enables saving after a provider token changes", async () => {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());
renderGitSettingsScreen();
const user = userEvent.setup();
const githubTokenInput = await screen.findByTestId("github-token-input");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
await user.type(githubTokenInput, "ghp_test_token");
expect(submitButton).toBeEnabled();
});
it("enables disconnecting when a provider is already configured", async () => {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettings({
provider_tokens_set: {
github: "github.com",
},
}),
);
renderGitSettingsScreen();
await waitFor(() => {
expect(screen.getByTestId("disconnect-tokens-button")).toBeEnabled();
});
});
});
@@ -22,11 +22,7 @@ vi.mock("#/hooks/query/use-config", () => ({
const DEFAULT_FEATURE_FLAGS = {
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_integrations_page: false,
};
const RouterStub = createRoutesStub([
+19 -32
View File
@@ -4,12 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
import SettingsScreen, { clientLoader } from "#/routes/settings";
import OptionService from "#/api/option-service/option-service.api";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
import { getFirstAvailablePath } from "#/utils/settings-utils";
import { OSS_NAV_ITEMS } from "#/constants/settings-nav";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
@@ -17,18 +12,10 @@ import { ActiveBackendProvider } from "#/contexts/active-backend-context";
vi.mock("#/hooks/use-settings-nav-items", () => ({
useSettingsNavItems: () => [
{ type: "item", item: OSS_NAV_ITEMS[0] },
{ type: "item", item: OSS_NAV_ITEMS[6] },
{ type: "item", item: OSS_NAV_ITEMS[5] },
],
}));
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
describe("settings route", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -45,11 +32,7 @@ describe("settings route", () => {
expect(
getFirstAvailablePath({
hide_llm_settings: true,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: true,
hide_integrations_page: false,
}),
).toBe("/settings/mcp");
});
@@ -59,11 +42,7 @@ describe("settings route", () => {
posthog_client_key: null,
feature_flags: {
hide_llm_settings: true,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: true,
hide_integrations_page: false,
},
providers_configured: [],
maintenance_start_time: null,
@@ -83,20 +62,28 @@ describe("settings route", () => {
expect(response.headers.get("Location")).toBe("/settings/mcp");
});
it("redirects local-only settings paths to /settings when the active backend is cloud", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
it("no longer treats /settings/integrations as a redirect target", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
posthog_client_key: null,
feature_flags: {
hide_llm_settings: false,
hide_users_page: true,
},
providers_configured: [],
maintenance_start_time: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: new Date().toISOString(),
});
const integrationsResponse = (await clientLoader({
const result = await clientLoader({
request: new Request("http://localhost/settings/integrations"),
params: {},
context: {},
} as never)) as Response;
} as never);
expect(integrationsResponse.status).toBe(302);
expect(integrationsResponse.headers.get("Location")).toBe("/settings");
expect(getConfigSpy).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it("renders the current OSS section title", () => {
-76
View File
@@ -1,76 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
// Mock translation
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock suggested tasks hook
vi.mock("#/hooks/query/use-suggested-tasks", () => ({
useSuggestedTasks: () => ({
data: [],
isLoading: false,
}),
}));
// Mock config hook
vi.mock("#/hooks/query/use-config", () => ({
useConfig: vi.fn(),
}));
// Mock user providers hook
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: vi.fn(),
}));
import { useConfig } from "#/hooks/query/use-config";
import { useUserProviders } from "#/hooks/use-user-providers";
describe("TaskSuggestions empty states", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("OSS mode + no providers → shows Git provider empty state", () => {
(useConfig as any).mockReturnValue({
data: {},
});
(useUserProviders as any).mockReturnValue({
providers: [],
});
render(
<MemoryRouter>
<TaskSuggestions />
</MemoryRouter>
);
expect(screen.getByText("TASKS$NO_GIT_PROVIDERS_TITLE")).toBeInTheDocument();
expect(screen.getByText("TASKS$NO_GIT_PROVIDERS_DESCRIPTION")).toBeInTheDocument();
expect(screen.getByText("TASKS$NO_GIT_PROVIDERS_CTA")).toBeInTheDocument();
});
it("OSS mode + providers exist but no tasks → shows no tasks message", () => {
(useConfig as any).mockReturnValue({
data: {},
});
(useUserProviders as any).mockReturnValue({
providers: [{ id: "github" }],
});
render(
<MemoryRouter>
<TaskSuggestions />
</MemoryRouter>
);
expect(screen.getByText("TASKS$NO_TASKS_AVAILABLE")).toBeInTheDocument();
});
});
@@ -37,11 +37,7 @@ class OptionService {
posthog_client_key: null,
feature_flags: {
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: true,
hide_integrations_page: false,
},
providers_configured: [],
maintenance_start_time: null,
-4
View File
@@ -19,11 +19,7 @@ export interface ModelsResponse {
export interface WebClientFeatureFlags {
hide_llm_settings: boolean;
enable_jira: boolean;
enable_jira_dc: boolean;
enable_linear: boolean;
hide_users_page: boolean;
hide_integrations_page: boolean;
}
export interface WebClientConfig {
@@ -1,40 +0,0 @@
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { NavigationLink } from "#/components/shared/navigation-link";
import { useSettings } from "#/hooks/query/use-settings";
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
import { I18nKey } from "#/i18n/declaration";
export function ConnectToProviderMessage() {
const { isLoading } = useSettings();
const { t } = useTranslation("openhands");
return (
<div className="flex flex-col gap-4 justify-between h-full">
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-[10px]">
<RepoForkedIcon width={24} height={24} />
<span className="leading-5 font-bold text-base text-white">
{t(I18nKey.COMMON$OPEN_REPOSITORY)}
</span>
</div>
<p>{t("HOME$CONNECT_PROVIDER_MESSAGE")}</p>
</div>
<NavigationLink
data-testid="navigate-to-settings-button"
to="/settings/integrations"
className="self-start w-full"
>
<BrandButton
type="button"
variant="primary"
isDisabled={isLoading}
className="w-full font-semibold"
>
{!isLoading && t("SETTINGS$TITLE")}
{isLoading && t("HOME$LOADING")}
</BrandButton>
</NavigationLink>
</div>
);
}
@@ -2,7 +2,6 @@ import { useActiveBackend } from "#/contexts/active-backend-context";
import { useUserProviders } from "#/hooks/use-user-providers";
import { GitRepository } from "#/types/git";
import { ConnectToProviderMessage } from "./connect-to-provider-message";
import { RepositorySelectionForm } from "./repo-selection-form";
import { WorkspaceSelectionForm } from "./workspace-selection-form";
@@ -19,16 +18,13 @@ export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
data-testid="repo-connector"
className="w-full flex flex-col gap-6 rounded-[12px] p-[20px] border border-[#727987] bg-[#26282D] min-h-[263.5px] relative"
>
{isCloud ? (
providers.length > 0 ? (
<RepositorySelectionForm
onRepoSelection={onRepoSelection}
isLoadingSettings={isLoadingSettings}
/>
) : (
<ConnectToProviderMessage />
)
) : (
{isCloud && providers.length > 0 && (
<RepositorySelectionForm
onRepoSelection={onRepoSelection}
isLoadingSettings={isLoadingSettings}
/>
)}
{!isCloud && (
<WorkspaceSelectionForm isLoadingSettings={isLoadingSettings} />
)}
</section>
@@ -1,13 +1,11 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TaskGroup } from "./task-group";
import { NavigationLink } from "#/components/shared/navigation-link";
import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks";
import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
import { cn, getDisplayedTaskGroups, getTotalTaskCount } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { GitRepository } from "#/types/git";
import { useUserProviders } from "#/hooks/use-user-providers";
import { Typography } from "#/ui/typography";
interface TaskSuggestionsProps {
@@ -19,9 +17,6 @@ export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { data: tasks, isLoading } = useSuggestedTasks();
const { providers } = useUserProviders();
const hasNoProviders = providers.length === 0;
const suggestedTasks = filterFor
? tasks?.filter(
@@ -70,32 +65,11 @@ export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
<TaskSuggestionsSkeleton />
</div>
)}
{!hasSuggestedTasks &&
!isLoading &&
(hasNoProviders ? (
<div className="px-[14px] flex flex-col gap-3 pb-6 sm:pb-8">
<Typography.Text className="text-xs leading-4 text-white font-medium">
{t(I18nKey.TASKS$NO_GIT_PROVIDERS_TITLE)}
</Typography.Text>
<Typography.Text className="text-xs leading-4 text-[#C9C9C9] font-normal">
{t(I18nKey.TASKS$NO_GIT_PROVIDERS_DESCRIPTION)}
</Typography.Text>
<NavigationLink
to="/settings/integrations"
className="w-fit hover:underline"
>
<Typography.Text className="text-xs leading-4 text-[#FAFAFA] font-normal">
{t(I18nKey.TASKS$NO_GIT_PROVIDERS_CTA)}
</Typography.Text>
</NavigationLink>
</div>
) : (
<Typography.Text className="text-xs leading-4 text-white font-medium px-[14px]">
{t(I18nKey.TASKS$NO_TASKS_AVAILABLE)}
</Typography.Text>
))}
{!hasSuggestedTasks && !isLoading && (
<Typography.Text className="text-xs leading-4 text-white font-medium px-[14px]">
{t(I18nKey.TASKS$NO_TASKS_AVAILABLE)}
</Typography.Text>
)}
{!isLoading &&
displayedTaskGroups &&
@@ -1,20 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function AzureDevOpsTokenHelpAnchor() {
const { t } = useTranslation("openhands");
return (
<p data-testid="azure-devops-token-help-anchor" className="text-xs">
<a
href="https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
aria-label={t(I18nKey.GIT$AZURE_DEVOPS_TOKEN_HELP)}
>
{t(I18nKey.GIT$AZURE_DEVOPS_TOKEN_HELP)}
</a>
</p>
);
}
@@ -1,64 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { AzureDevOpsTokenHelpAnchor } from "./azure-devops-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface AzureDevOpsTokenInputProps {
onChange: (value: string) => void;
onAzureDevOpsHostChange: (value: string) => void;
isAzureDevOpsTokenSet: boolean;
name: string;
azureDevOpsHostSet: string | null | undefined;
}
export function AzureDevOpsTokenInput({
onChange,
onAzureDevOpsHostChange,
isAzureDevOpsTokenSet,
name,
azureDevOpsHostSet,
}: AzureDevOpsTokenInputProps) {
const { t } = useTranslation("openhands");
return (
<div className="flex flex-col gap-6">
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GIT$AZURE_DEVOPS_TOKEN)}
type="password"
className="w-full max-w-[680px]"
placeholder={isAzureDevOpsTokenSet ? "<hidden>" : ""}
startContent={
isAzureDevOpsTokenSet && (
<KeyStatusIcon
testId="azure-devops-set-token-indicator"
isSet={isAzureDevOpsTokenSet}
/>
)
}
/>
<SettingsInput
onChange={onAzureDevOpsHostChange || (() => {})}
name="azure-devops-host-input"
testId="azure-devops-host-input"
label={t(I18nKey.GIT$AZURE_DEVOPS_HOST)}
type="text"
className="w-full max-w-[680px]"
placeholder={t(I18nKey.GIT$AZURE_DEVOPS_HOST_PLACEHOLDER)}
defaultValue={azureDevOpsHostSet || undefined}
startContent={
azureDevOpsHostSet &&
azureDevOpsHostSet.trim() !== "" && (
<KeyStatusIcon testId="azure-devops-set-host-indicator" isSet />
)
}
/>
<AzureDevOpsTokenHelpAnchor />
</div>
);
}
@@ -1,28 +0,0 @@
import { Trans, useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function BitbucketDCTokenHelpAnchor() {
const { t } = useTranslation("openhands");
return (
<p
data-testid="bitbucket-dc-token-help-anchor"
className="text-xs max-w-[680px]"
>
<Trans
ns="openhands"
i18nKey={I18nKey.BITBUCKET_DATA_CENTER$TOKEN_HELP_TEXT}
components={[
<a
key="bitbucket-dc-token-help-anchor-link"
aria-label={t(I18nKey.GIT$BITBUCKET_DC_TOKEN_HELP_LINK)}
href="https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}
@@ -1,67 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { BitbucketDCTokenHelpAnchor } from "./bitbucket-dc-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
import { cn } from "#/utils/utils";
interface BitbucketDCTokenInputProps {
onChange: (value: string) => void;
onBitbucketDCHostChange: (value: string) => void;
isBitbucketDCTokenSet: boolean;
name: string;
bitbucketDCHostSet: string | null | undefined;
className?: string;
}
export function BitbucketDCTokenInput({
onChange,
onBitbucketDCHostChange,
isBitbucketDCTokenSet,
name,
bitbucketDCHostSet,
className,
}: BitbucketDCTokenInputProps) {
const { t } = useTranslation("openhands");
return (
<div className={cn("flex flex-col gap-6", className)}>
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.BITBUCKET_DATA_CENTER$TOKEN_LABEL)}
type="password"
className="w-full max-w-[680px]"
placeholder={isBitbucketDCTokenSet ? "<hidden>" : "username:token"}
startContent={
isBitbucketDCTokenSet && (
<KeyStatusIcon
testId="bb-dc-set-token-indicator"
isSet={isBitbucketDCTokenSet}
/>
)
}
/>
<SettingsInput
onChange={onBitbucketDCHostChange || (() => {})}
name="bitbucket-dc-host-input"
testId="bitbucket-dc-host-input"
label={t(I18nKey.BITBUCKET_DATA_CENTER$HOST_LABEL)}
type="text"
className="w-full max-w-[680px]"
placeholder="bitbucket.your-company.com"
defaultValue={bitbucketDCHostSet || undefined}
startContent={
bitbucketDCHostSet &&
bitbucketDCHostSet.trim() !== "" && (
<KeyStatusIcon testId="bb-dc-set-host-indicator" isSet />
)
}
/>
<BitbucketDCTokenHelpAnchor />
</div>
);
}
@@ -1,33 +0,0 @@
import { Trans, useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function BitbucketTokenHelpAnchor() {
const { t } = useTranslation("openhands");
return (
<p data-testid="bitbucket-token-help-anchor" className="text-xs">
<Trans
ns="openhands"
i18nKey={I18nKey.BITBUCKET$TOKEN_HELP_TEXT}
components={[
<a
key="bitbucket-token-help-anchor-link"
aria-label={t(I18nKey.GIT$BITBUCKET_TOKEN_HELP_LINK)}
href="https://bitbucket.org/account/settings/app-passwords/new?scopes=repository:write,pullrequest:write,issue:write"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="bitbucket-token-help-anchor-link-2"
aria-label={t(I18nKey.GIT$BITBUCKET_TOKEN_SEE_MORE_LINK)}
href="https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}
@@ -1,67 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { BitbucketTokenHelpAnchor } from "./bitbucket-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
import { cn } from "#/utils/utils";
interface BitbucketTokenInputProps {
onChange: (value: string) => void;
onBitbucketHostChange: (value: string) => void;
isBitbucketTokenSet: boolean;
name: string;
bitbucketHostSet: string | null | undefined;
className?: string;
}
export function BitbucketTokenInput({
onChange,
onBitbucketHostChange,
isBitbucketTokenSet,
name,
bitbucketHostSet,
className,
}: BitbucketTokenInputProps) {
const { t } = useTranslation("openhands");
return (
<div className={cn("flex flex-col gap-6", className)}>
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.BITBUCKET$TOKEN_LABEL)}
type="password"
className="w-full max-w-[680px]"
placeholder={isBitbucketTokenSet ? "<hidden>" : "username:app_password"}
startContent={
isBitbucketTokenSet && (
<KeyStatusIcon
testId="bb-set-token-indicator"
isSet={isBitbucketTokenSet}
/>
)
}
/>
<SettingsInput
onChange={onBitbucketHostChange || (() => {})}
name="bitbucket-host-input"
testId="bitbucket-host-input"
label={t(I18nKey.BITBUCKET$HOST_LABEL)}
type="text"
className="w-full max-w-[680px]"
placeholder="bitbucket.org"
defaultValue={bitbucketHostSet || undefined}
startContent={
bitbucketHostSet &&
bitbucketHostSet.trim() !== "" && (
<KeyStatusIcon testId="bb-set-host-indicator" isSet />
)
}
/>
<BitbucketTokenHelpAnchor />
</div>
);
}
@@ -1,32 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ConfigureGitHubRepositoriesAnchorProps {
slug: string;
}
export function ConfigureGitHubRepositoriesAnchor({
slug,
}: ConfigureGitHubRepositoriesAnchorProps) {
const { t } = useTranslation("openhands");
return (
<div data-testid="configure-github-repositories-button" className="py-9">
<BrandButton
type="button"
variant="primary"
className="w-55"
onClick={() =>
window.open(
`https://github.com/apps/${slug}/installations/new`,
"_blank",
"noreferrer noopener",
)
}
>
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</div>
);
}
@@ -1,64 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { KeyStatusIcon } from "../key-status-icon";
import { cn } from "#/utils/utils";
interface ForgejoTokenInputProps {
onChange: (value: string) => void;
onForgejoHostChange: (value: string) => void;
isForgejoTokenSet: boolean;
name: string;
forgejoHostSet: string | null | undefined;
className?: string;
}
export function ForgejoTokenInput({
onChange,
onForgejoHostChange,
isForgejoTokenSet,
name,
forgejoHostSet,
className,
}: ForgejoTokenInputProps) {
const { t } = useTranslation("openhands");
return (
<div className={cn("flex flex-col gap-6", className)}>
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.FORGEJO$TOKEN_LABEL)}
type="password"
className="w-full max-w-[680px]"
placeholder={isForgejoTokenSet ? "<hidden>" : ""}
startContent={
isForgejoTokenSet && (
<KeyStatusIcon
testId="forgejo-set-token-indicator"
isSet={isForgejoTokenSet}
/>
)
}
/>
<SettingsInput
onChange={onForgejoHostChange || (() => {})}
name="forgejo-host-input"
testId="forgejo-host-input"
label={t(I18nKey.FORGEJO$HOST_LABEL)}
type="text"
className="w-full max-w-[680px]"
placeholder="codeberg.org"
defaultValue={forgejoHostSet || undefined}
startContent={
forgejoHostSet &&
forgejoHostSet.trim() !== "" && (
<KeyStatusIcon testId="forgejo-set-host-indicator" isSet />
)
}
/>
</div>
);
}
@@ -1,18 +0,0 @@
import { InputSkeleton } from "../input-skeleton";
import { SubtextSkeleton } from "../subtext-skeleton";
export function GitSettingInputsSkeleton() {
return (
<div className="px-11 py-9 flex flex-col gap-12">
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
</div>
);
}
@@ -1,33 +0,0 @@
import { Trans, useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitHubTokenHelpAnchor() {
const { t } = useTranslation("openhands");
return (
<p data-testid="github-token-help-anchor" className="text-xs">
<Trans
ns="openhands"
i18nKey={I18nKey.GITHUB$TOKEN_HELP_TEXT}
components={[
<a
key="github-token-help-anchor-link"
aria-label={t(I18nKey.GIT$GITHUB_TOKEN_HELP_LINK)}
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="github-token-help-anchor-link-2"
aria-label={t(I18nKey.GIT$GITHUB_TOKEN_SEE_MORE_LINK)}
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}
@@ -1,67 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitHubTokenHelpAnchor } from "./github-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
import { cn } from "#/utils/utils";
interface GitHubTokenInputProps {
onChange: (value: string) => void;
onGitHubHostChange: (value: string) => void;
isGitHubTokenSet: boolean;
name: string;
githubHostSet: string | null | undefined;
className?: string;
}
export function GitHubTokenInput({
onChange,
onGitHubHostChange,
isGitHubTokenSet,
name,
githubHostSet,
className,
}: GitHubTokenInputProps) {
const { t } = useTranslation("openhands");
return (
<div className={cn("flex flex-col gap-6", className)}>
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-full max-w-[680px]"
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
startContent={
isGitHubTokenSet && (
<KeyStatusIcon
testId="gh-set-token-indicator"
isSet={isGitHubTokenSet}
/>
)
}
/>
<SettingsInput
onChange={onGitHubHostChange || (() => {})}
name="github-host-input"
testId="github-host-input"
label={t(I18nKey.GITHUB$HOST_LABEL)}
type="text"
className="w-full max-w-[680px]"
placeholder="github.com"
defaultValue={githubHostSet || undefined}
startContent={
githubHostSet &&
githubHostSet.trim() !== "" && (
<KeyStatusIcon testId="gh-set-host-indicator" isSet />
)
}
/>
<GitHubTokenHelpAnchor />
</div>
);
}
@@ -1,33 +0,0 @@
import { Trans, useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitLabTokenHelpAnchor() {
const { t } = useTranslation("openhands");
return (
<p data-testid="gitlab-token-help-anchor" className="text-xs">
<Trans
ns="openhands"
i18nKey={I18nKey.GITLAB$TOKEN_HELP_TEXT}
components={[
<a
key="gitlab-token-help-anchor-link"
aria-label={t(I18nKey.GIT$GITLAB_TOKEN_HELP_LINK)}
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="gitlab-token-help-anchor-link-2"
aria-label={t(I18nKey.GIT$GITLAB_TOKEN_SEE_MORE_LINK)}
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}
@@ -1,67 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitLabTokenHelpAnchor } from "./gitlab-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
import { cn } from "#/utils/utils";
interface GitLabTokenInputProps {
onChange: (value: string) => void;
onGitLabHostChange: (value: string) => void;
isGitLabTokenSet: boolean;
name: string;
gitlabHostSet: string | null | undefined;
className?: string;
}
export function GitLabTokenInput({
onChange,
onGitLabHostChange,
isGitLabTokenSet,
name,
gitlabHostSet,
className,
}: GitLabTokenInputProps) {
const { t } = useTranslation("openhands");
return (
<div className={cn("flex flex-col gap-6", className)}>
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
type="password"
className="w-full max-w-[680px]"
placeholder={isGitLabTokenSet ? "<hidden>" : ""}
startContent={
isGitLabTokenSet && (
<KeyStatusIcon
testId="gl-set-token-indicator"
isSet={isGitLabTokenSet}
/>
)
}
/>
<SettingsInput
onChange={onGitLabHostChange || (() => {})}
name="gitlab-host-input"
testId="gitlab-host-input"
label={t(I18nKey.GITLAB$HOST_LABEL)}
type="text"
className="w-full max-w-[680px]"
placeholder="gitlab.com"
defaultValue={gitlabHostSet || undefined}
startContent={
gitlabHostSet &&
gitlabHostSet.trim() !== "" && (
<KeyStatusIcon testId="gl-set-host-indicator" isSet />
)
}
/>
<GitLabTokenHelpAnchor />
</div>
);
}
@@ -1,26 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
export function InstallSlackAppAnchor() {
const { t } = useTranslation("openhands");
return (
<div data-testid="install-slack-app-button" className="py-9">
<BrandButton
type="button"
variant="primary"
className="w-55"
onClick={() =>
window.open(
"https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope=",
"_blank",
"noreferrer noopener",
)
}
>
{t(I18nKey.SLACK$INSTALL_APP)}
</BrandButton>
</div>
);
}
@@ -1,46 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
export interface WebhookStatusBadgeProps {
webhookInstalled: boolean;
installationResult?: { success: boolean; error: string | null } | null;
}
export function WebhookStatusBadge({
webhookInstalled,
installationResult,
}: WebhookStatusBadgeProps) {
const { t } = useTranslation("openhands");
if (installationResult) {
if (installationResult.success) {
return (
<Typography.Text className="px-2 py-1 text-xs rounded bg-green-500/20 text-green-400">
{t(I18nKey.GITLAB$WEBHOOK_STATUS_INSTALLED)}
</Typography.Text>
);
}
return (
<span title={installationResult.error || undefined}>
<Typography.Text className="px-2 py-1 text-xs rounded bg-red-500/20 text-red-400">
{t(I18nKey.GITLAB$WEBHOOK_STATUS_FAILED)}
</Typography.Text>
</span>
);
}
if (webhookInstalled) {
return (
<Typography.Text className="px-2 py-1 text-xs rounded bg-green-500/20 text-green-400">
{t(I18nKey.GITLAB$WEBHOOK_STATUS_INSTALLED)}
</Typography.Text>
);
}
return (
<Typography.Text className="px-2 py-1 text-xs rounded bg-gray-500/20 text-gray-400">
{t(I18nKey.GITLAB$WEBHOOK_STATUS_NOT_INSTALLED)}
</Typography.Text>
);
}
@@ -1,456 +0,0 @@
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import {
BaseModalDescription,
BaseModalTitle,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { useValidateIntegration } from "#/hooks/mutation/use-validate-integration";
interface ConfigureButtonProps {
onClick: () => void;
isDisabled: boolean;
text?: string;
"data-testid"?: string;
}
export function ConfigureButton({
onClick,
isDisabled,
text,
"data-testid": dataTestId,
}: ConfigureButtonProps) {
const { t } = useTranslation("openhands");
return (
<BrandButton
data-testid={dataTestId}
variant="primary"
onClick={onClick}
isDisabled={isDisabled}
type="button"
className="w-30 min-w-20"
>
{text || t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL)}
</BrandButton>
);
}
interface ConfigureModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (data: {
workspace: string;
webhookSecret: string;
serviceAccountEmail: string;
serviceAccountApiKey: string;
isActive: boolean;
}) => void;
onLink: (workspace: string) => void;
onUnlink?: () => void;
platformName: string;
platform: "jira" | "jira-dc" | "linear";
integrationData?: {
id: number;
keycloak_user_id: string;
status: string;
workspace?: {
id: number;
name: string;
status: string;
editable: boolean;
};
} | null;
}
export function ConfigureModal({
isOpen,
onClose,
onConfirm,
onLink,
onUnlink,
platformName,
platform,
integrationData,
}: ConfigureModalProps) {
const { t } = useTranslation("openhands");
const [workspace, setWorkspace] = useState("");
const [webhookSecret, setWebhookSecret] = useState("");
const [serviceAccountEmail, setServiceAccountEmail] = useState("");
const [serviceAccountApiKey, setServiceAccountApiKey] = useState("");
const [isActive, setIsActive] = useState(true);
const [showConfigurationFields, setShowConfigurationFields] = useState(false);
// Determine initial state based on integrationData
const existingWorkspace = integrationData?.workspace;
const isWorkspaceEditable = existingWorkspace?.editable ?? false;
// Set initial workspace value when modal opens
React.useEffect(() => {
if (isOpen && existingWorkspace) {
setWorkspace(existingWorkspace.name);
setShowConfigurationFields(isWorkspaceEditable);
} else if (isOpen && !existingWorkspace) {
setWorkspace("");
setShowConfigurationFields(false);
}
}, [isOpen, existingWorkspace, isWorkspaceEditable]);
// Helper function to get platform-specific placeholder
const getWorkspacePlaceholder = () => {
if (platform === "jira") {
return I18nKey.PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER;
}
if (platform === "jira-dc") {
return I18nKey.PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER;
}
return I18nKey.PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER;
};
// Validation states
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [webhookSecretError, setWebhookSecretError] = useState<string | null>(
null,
);
const [emailError, setEmailError] = useState<string | null>(null);
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const validateMutation = useValidateIntegration(platform, {
onSuccess: (data) => {
if (data.data.status === "active") {
// Validation successful, proceed with linking
onLink(workspace.trim());
} else {
// Show configuration fields for further setup
setShowConfigurationFields(true);
setIsActive(true);
}
},
onError: (error) => {
if (error.response?.status === 404) {
// Integration not found, show configuration fields
setShowConfigurationFields(true);
setIsActive(true);
} else {
// Other errors - still show configuration fields as fallback
setShowConfigurationFields(true);
setIsActive(true);
}
},
});
// Validation functions
const validateWorkspace = (value: string) => {
const isValid = /^[a-zA-Z0-9\-_.]*$/.test(value);
if (!isValid && value.length > 0) {
setWorkspaceError(
t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR),
);
} else {
setWorkspaceError(null);
}
return isValid;
};
const validateWebhookSecret = (value: string) => {
const hasSpaces = /\s/.test(value);
if (hasSpaces) {
setWebhookSecretError(
t(I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR),
);
} else {
setWebhookSecretError(null);
}
return !hasSpaces;
};
const validateEmail = (value: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValid = emailRegex.test(value) || value.length === 0;
if (!isValid && value.length > 0) {
setEmailError(
t(I18nKey.PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR),
);
} else {
setEmailError(null);
}
return isValid;
};
const validateApiKey = (value: string) => {
const hasSpaces = /\s/.test(value);
if (hasSpaces) {
setApiKeyError(
t(I18nKey.PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR),
);
} else {
setApiKeyError(null);
}
return !hasSpaces;
};
// Input handlers with validation
const handleWorkspaceChange = (value: string) => {
setWorkspace(value);
validateWorkspace(value);
};
const handleWebhookSecretChange = (value: string) => {
setWebhookSecret(value);
validateWebhookSecret(value);
};
const handleEmailChange = (value: string) => {
setServiceAccountEmail(value);
validateEmail(value);
};
const handleApiKeyChange = (value: string) => {
setServiceAccountApiKey(value);
validateApiKey(value);
};
const handleClose = () => {
setWorkspace("");
setWebhookSecret("");
setServiceAccountEmail("");
setServiceAccountApiKey("");
setIsActive(false);
setShowConfigurationFields(false);
setWorkspaceError(null);
setWebhookSecretError(null);
setEmailError(null);
setApiKeyError(null);
onClose();
};
if (!isOpen) {
return null;
}
const handleConnect = () => {
if (showConfigurationFields) {
// Full configuration flow (either new configuration or editing existing)
onConfirm({
workspace,
webhookSecret,
serviceAccountEmail,
serviceAccountApiKey,
isActive,
});
} else if (!existingWorkspace) {
// First check the workspace with validation for new integrations
validateMutation.mutate(workspace.trim());
}
// For existing workspace that's not editable, no action needed
// This case shouldn't happen as the button should be hidden
};
const isConnectDisabled = showConfigurationFields
? !workspace.trim() ||
!webhookSecret.trim() ||
!serviceAccountEmail.trim() ||
!serviceAccountApiKey.trim() ||
workspaceError !== null ||
webhookSecretError !== null ||
emailError !== null ||
apiKeyError !== null ||
validateMutation.isPending
: !workspace.trim() ||
workspaceError !== null ||
validateMutation.isPending;
return (
<ModalBackdrop onClose={handleClose}>
<ModalBody className="items-start border border-tertiary w-96">
<BaseModalTitle
title={
showConfigurationFields
? t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE, {
platform: platformName,
})
: t(I18nKey.PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE)
}
/>
<BaseModalDescription>
{showConfigurationFields ? (
<Trans
ns="openhands"
i18nKey={
I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2
}
components={{
b: <b />,
a: (
<a
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
Check the document for more information
</a>
),
}}
/>
) : (
<Trans
ns="openhands"
i18nKey={
I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1
}
components={{
b: <b />,
a: (
<a
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
Check the document for more information
</a>
),
}}
/>
)}
<p className="mt-4">
{t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT, {
platform: platformName,
})}
</p>
</BaseModalDescription>
<div className="w-full flex flex-col gap-4 mt-1">
<div>
<div className="flex gap-2 items-end">
<div className="flex-1">
<SettingsInput
label={t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL)}
placeholder={t(getWorkspacePlaceholder())}
value={workspace}
onChange={handleWorkspaceChange}
className="w-full"
type="text"
pattern="^[a-zA-Z0-9\-_.]*$"
isDisabled={!!existingWorkspace}
/>
</div>
{existingWorkspace && onUnlink && (
<BrandButton
variant="secondary"
onClick={onUnlink}
data-testid="unlink-button"
type="button"
className="mb-0"
>
{t(I18nKey.PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL)}
</BrandButton>
)}
</div>
{workspaceError && (
<p className="text-red-500 text-sm mt-2">{workspaceError}</p>
)}
</div>
{showConfigurationFields && (
<>
<div>
<SettingsInput
label={t(I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER,
)}
value={webhookSecret}
onChange={handleWebhookSecretChange}
className="w-full"
type="password"
/>
{webhookSecretError && (
<p className="text-red-500 text-sm mt-2">
{webhookSecretError}
</p>
)}
</div>
<div>
<SettingsInput
label={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL,
)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_PLACEHOLDER,
)}
value={serviceAccountEmail}
onChange={handleEmailChange}
className="w-full"
type="email"
/>
{emailError && (
<p className="text-red-500 text-sm mt-2">{emailError}</p>
)}
</div>
<div>
<SettingsInput
label={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_LABEL,
)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER,
)}
value={serviceAccountApiKey}
onChange={handleApiKeyChange}
className="w-full"
type="password"
/>
{apiKeyError && (
<p className="text-red-500 text-sm mt-2">{apiKeyError}</p>
)}
</div>
<div className="mt-4">
<SettingsSwitch
testId="active-toggle"
onToggle={setIsActive}
isToggled={isActive}
>
{t(I18nKey.PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL)}
</SettingsSwitch>
</div>
</>
)}
</div>
<div className="flex flex-col gap-2 w-full mt-4">
{/* Hide the connect/edit button if workspace exists but is not editable */}
{(!existingWorkspace || isWorkspaceEditable) && (
<BrandButton
variant="primary"
onClick={handleConnect}
data-testid="connect-button"
type="button"
className="w-full"
isDisabled={isConnectDisabled}
>
{(() => {
if (existingWorkspace && showConfigurationFields) {
return t(I18nKey.PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL);
}
return t(I18nKey.PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL);
})()}
</BrandButton>
)}
<BrandButton
variant="secondary"
onClick={handleClose}
data-testid="cancel-button"
type="button"
className="w-full"
>
{t(I18nKey.FEEDBACK$CANCEL_LABEL)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>
);
}
@@ -1,113 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useIntegrationStatus } from "#/hooks/query/use-integration-status";
import { useLinkIntegration } from "#/hooks/mutation/use-link-integration";
import { useUnlinkIntegration } from "#/hooks/mutation/use-unlink-integration";
import { useConfigureIntegration } from "#/hooks/mutation/use-configure-integration";
import { I18nKey } from "#/i18n/declaration";
import {
ConfigureButton,
ConfigureModal,
} from "#/components/features/settings/project-management/configure-modal";
interface IntegrationRowProps {
platform: "jira" | "jira-dc" | "linear";
platformName: string;
"data-testid"?: string;
}
export function IntegrationRow({
platform,
platformName,
"data-testid": dataTestId,
}: IntegrationRowProps) {
const [isConfigureModalOpen, setConfigureModalOpen] = React.useState(false);
const { t } = useTranslation("openhands");
const { data: integrationData, isLoading: isStatusLoading } =
useIntegrationStatus(platform);
const linkMutation = useLinkIntegration(platform, {
onSettled: () => {
setConfigureModalOpen(false);
},
});
const unlinkMutation = useUnlinkIntegration(platform, {
onSettled: () => {
setConfigureModalOpen(false);
},
});
const configureMutation = useConfigureIntegration(platform, {
onSettled: () => {
setConfigureModalOpen(false);
},
});
const handleConfigure = () => {
setConfigureModalOpen(true);
};
const handleLink = (workspace: string) => {
linkMutation.mutate(workspace);
};
const handleUnlink = () => {
unlinkMutation.mutate();
};
const handleConfigureConfirm = (data: {
workspace: string;
webhookSecret: string;
serviceAccountEmail: string;
serviceAccountApiKey: string;
isActive: boolean;
}) => {
configureMutation.mutate(data);
};
const isLoading =
isStatusLoading ||
linkMutation.isPending ||
unlinkMutation.isPending ||
configureMutation.isPending;
// Determine if integration is active and workspace exists
const isIntegrationActive = integrationData?.status === "active";
const hasWorkspace = integrationData?.workspace;
// Determine button text based on integration state
const buttonText =
isIntegrationActive && hasWorkspace
? t(I18nKey.PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL)
: t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL);
return (
<div
className="flex items-center justify-between flex-wrap gap-2"
data-testid={dataTestId}
>
<span className="font-medium">{platformName}</span>
<div className="flex items-center gap-6">
<ConfigureButton
onClick={handleConfigure}
isDisabled={isLoading}
text={buttonText}
data-testid={`${platform}-configure-button`}
/>
</div>
<ConfigureModal
isOpen={isConfigureModalOpen}
onClose={() => setConfigureModalOpen(false)}
onConfirm={handleConfigureConfirm}
onLink={handleLink}
onUnlink={handleUnlink}
platformName={platformName}
platform={platform}
integrationData={integrationData}
/>
</div>
);
}
@@ -1,41 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { IntegrationRow } from "./integration-row";
import { useConfig } from "#/hooks/query/use-config";
export function ProjectManagementIntegration() {
const { t } = useTranslation("openhands");
const { data: config } = useConfig();
return (
<div className="flex flex-col gap-4 w-1/4">
<h3 className="text-xl font-medium text-white">
{t(I18nKey.PROJECT_MANAGEMENT$TITLE)}
</h3>
<div className="flex flex-col gap-4">
{config?.feature_flags?.enable_jira && (
<IntegrationRow
platform="jira"
platformName="Jira Cloud"
data-testid="jira-integration-row"
/>
)}
{config?.feature_flags?.enable_jira_dc && (
<IntegrationRow
platform="jira-dc"
platformName="Jira Data Center"
data-testid="jira-dc-integration-row"
/>
)}
{config?.feature_flags?.enable_linear && (
<IntegrationRow
platform="linear"
platformName="Linear"
data-testid="linear-integration-row"
/>
)}
</div>
</div>
);
}
-1
View File
@@ -1,6 +1,5 @@
export * from "../features/settings";
export { AppSettingsScreen as AppSettings } from "../../routes/app-settings";
export { GitSettingsScreen as GitSettings } from "../../routes/git-settings";
export { LlmSettingsScreen as LLMSettings } from "../../routes/llm-settings";
export { MCPSettingsScreen as MCPSettings } from "../../routes/mcp-settings";
export { SecretsSettingsScreen as SecretsSettings } from "../../routes/secrets-settings";
-6
View File
@@ -5,7 +5,6 @@ import MemoryIcon from "#/icons/memory_icon.svg?react";
import ServerProcessIcon from "#/icons/server-process.svg?react";
import SettingsGearIcon from "#/icons/settings-gear.svg?react";
import CircuitIcon from "#/icons/u-circuit.svg?react";
import PuzzlePieceIcon from "#/icons/u-puzzle-piece.svg?react";
export interface SettingsNavItem {
icon: React.ReactElement;
@@ -39,11 +38,6 @@ export const OSS_NAV_ITEMS: SettingsNavItem[] = [
to: "/settings/skills",
text: "SETTINGS$NAV_SKILLS",
},
{
icon: <PuzzlePieceIcon width={22} height={22} />,
to: "/settings/integrations",
text: "SETTINGS$NAV_INTEGRATIONS",
},
{
icon: <SettingsGearIcon width={22} height={22} />,
to: "/settings/app",
@@ -1,36 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { Provider, ProviderToken } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
export const useAddGitProviders = () => {
const queryClient = useQueryClient();
const { trackGitProviderConnected } = useTracking();
return useMutation({
mutationFn: ({
providers,
}: {
providers: Partial<Record<Provider, ProviderToken>>;
}) => SecretsService.addGitProvider(providers),
onSuccess: async (_, { providers }) => {
const connectedProviders = Object.entries(providers)
.filter(([, value]) => value?.token && value.token.trim() !== "")
.map(([key]) => key);
if (connectedProviders.length > 0) {
trackGitProviderConnected({
providers: connectedProviders,
});
}
await queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(),
});
},
meta: {
disableToast: true,
},
});
};
@@ -1,72 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
interface ConfigureIntegrationData {
workspace: string;
webhookSecret: string;
serviceAccountEmail: string;
serviceAccountApiKey: string;
isActive: boolean;
}
export function useConfigureIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSettled,
}: {
onSettled: () => void;
},
) {
const queryClient = useQueryClient();
const { t } = useTranslation("openhands");
return useMutation({
mutationFn: async (data: ConfigureIntegrationData) => {
const input = {
workspace_name: data.workspace,
webhook_secret: data.webhookSecret,
svc_acc_email: data.serviceAccountEmail,
svc_acc_api_key: data.serviceAccountApiKey,
is_active: data.isActive,
};
const response = await openHands.post(
`/integration/${platform}/workspaces`,
input,
);
const { success, redirect, authorizationUrl } = response.data;
if (success) {
if (redirect) {
if (authorizationUrl) {
window.location.href = authorizationUrl;
} else {
throw new Error("Could not get authorization URL from the server.");
}
} else {
window.location.reload();
}
} else {
throw new Error("Configuration failed");
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["integration-status", platform],
});
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled,
});
}
@@ -1,19 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
export const useDeleteGitProviders = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => SecretsService.deleteGitProviders(),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(),
});
},
meta: {
disableToast: true,
},
});
};
@@ -1,60 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export function useLinkIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSettled,
}: {
onSettled: () => void;
},
) {
const queryClient = useQueryClient();
const { t } = useTranslation("openhands");
return useMutation({
mutationFn: async (workspace: string) => {
const input = {
workspace_name: workspace,
};
const response = await openHands.post(
`/integration/${platform}/workspaces/link`,
input,
);
const { success, redirect, authorizationUrl } = response.data;
if (success) {
if (redirect) {
if (authorizationUrl) {
window.location.href = authorizationUrl;
} else {
throw new Error("Could not get authorization URL from the server.");
}
} else {
window.location.reload();
}
} else {
throw new Error("Link integration failed");
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["integration-status", platform],
});
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled,
});
}
@@ -1,38 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export function useUnlinkIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSettled,
}: {
onSettled: () => void;
},
) {
const queryClient = useQueryClient();
const { t } = useTranslation("openhands");
return useMutation({
mutationFn: () =>
openHands.post(`/integration/${platform}/workspaces/unlink`),
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
queryClient.invalidateQueries({
queryKey: ["integration-status", platform],
});
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled,
});
}
@@ -1,43 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export function useValidateIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSuccess,
onError,
}: {
onSuccess: (data: any) => void;
onError: (error: any) => void;
},
) {
const { t } = useTranslation("openhands");
return useMutation({
mutationFn: (workspace?: string) => {
const workspaceParam = workspace ? `/${workspace}` : "";
return openHands.get(
`/integration/${platform}/workspaces/validate${workspaceParam}`,
);
},
onSuccess,
onError: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 404) {
onError(error);
} else {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(
errorMessage ||
t(I18nKey.PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR),
);
}
},
});
}
-22
View File
@@ -1,22 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { openHands } from "#/api/open-hands-axios";
export function useIntegrationStatus(platform: "jira" | "jira-dc" | "linear") {
return useQuery({
queryKey: ["integration-status", platform],
queryFn: async () => {
try {
const response = await openHands.get(
`/integration/${platform}/workspaces/link`,
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
});
}
+2 -10
View File
@@ -1,10 +1,6 @@
import { useConfig } from "#/hooks/query/use-config";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { OSS_NAV_ITEMS, SettingsNavItem } from "#/constants/settings-nav";
import {
isLocalOnlySettingsPath,
isSettingsPageHidden,
} from "#/utils/settings-utils";
import { isSettingsPageHidden } from "#/utils/settings-utils";
import { I18nKey } from "#/i18n/declaration";
export type SettingsNavRenderedItem =
@@ -15,12 +11,8 @@ export type SettingsNavRenderedItem =
export function useSettingsNavItems(): SettingsNavRenderedItem[] {
const { data: config } = useConfig();
const featureFlags = config?.feature_flags;
const active = useActiveBackend();
const isCloud = active.backend.kind === "cloud";
return OSS_NAV_ITEMS.filter(
(item) =>
!isSettingsPageHidden(item.to, featureFlags) &&
!(isCloud && isLocalOnlySettingsPath(item.to)),
(item) => !isSettingsPageHidden(item.to, featureFlags),
).map((item) => ({ type: "item", item }));
}
-629
View File
@@ -1478,23 +1478,6 @@
"uk": "Невірний JSON",
"ca": "JSON no vàlid"
},
"HOME$CONNECT_PROVIDER_MESSAGE": {
"en": "To get started with suggested tasks, please connect your GitHub, GitLab, Bitbucket, or Azure DevOps account.",
"ja": "提案されたタスクを始めるには、GitHub、GitLab、Bitbucket、またはAzure DevOpsアカウントを接続してください。",
"zh-CN": "要开始使用建议的任务,请连接您的GitHub、GitLab、Bitbucket或Azure DevOps账户。",
"zh-TW": "要開始使用建議的任務,請連接您的GitHub、GitLab、Bitbucket或Azure DevOps帳戶。",
"ko-KR": "제안된 작업을 시작하려면 GitHub, GitLab, Bitbucket 또는 Azure DevOps 계정을 연결하세요.",
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub, GitLab, Bitbucket eller Azure DevOps-kontoen din.",
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub, GitLab, Bitbucket o Azure DevOps.",
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub, GitLab, Bitbucket ou Azure DevOps.",
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub, GitLab, Bitbucket o Azure DevOps.",
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab أو Bitbucket أو Azure DevOps الخاص بك.",
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub, GitLab, Bitbucket ou Azure DevOps.",
"tr": "Önerilen görevlerle başlamak için lütfen GitHub, GitLab, Bitbucket veya Azure DevOps hesabınızı bağlayın.",
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub-, GitLab-, Bitbucket- oder Azure DevOps-Konto.",
"uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub, GitLab, Bitbucket або Azure DevOps.",
"ca": "Per començar amb les tasques suggerides, connecteu el vostre compte de GitHub, GitLab, Bitbucket o Azure DevOps."
},
"HOME$LETS_START_BUILDING": {
"en": "Let's Start Building!",
"ja": "開発を始めましょう!",
@@ -4742,23 +4725,6 @@
"uk": "Аналізатор безпеки",
"ca": "Analitzador de seguretat"
},
"SETTINGS$NAV_INTEGRATIONS": {
"en": "Integrations",
"ja": "統合",
"zh-CN": "集成",
"zh-TW": "整合",
"ko-KR": "통합",
"no": "Integrasjoner",
"it": "Integrazioni",
"pt": "Integrações",
"es": "Integraciones",
"ar": "التكامل",
"fr": "Intégrations",
"tr": "Entegrasyonlar",
"de": "Integrationen",
"uk": "Інтеграції",
"ca": "Integracions"
},
"SETTINGS$NAV_APPLICATION": {
"en": "Application",
"ja": "アプリケーション",
@@ -16795,57 +16761,6 @@
"uk": "Вибрати репозиторій",
"ca": "Selecciona un repositori"
},
"TASKS$NO_GIT_PROVIDERS_TITLE": {
"en": "Connect a Git provider",
"ja": "Gitプロバイダーを接続",
"zh-CN": "连接 Git 提供商",
"zh-TW": "連接 Git 提供商",
"ko-KR": "Git 제공자 연결",
"no": "Koble til en Git-leverandør",
"ar": "قم بتوصيل موفر Git",
"de": "Git-Anbieter verbinden",
"fr": "Connecter un fournisseur Git",
"it": "Collega un provider Git",
"pt": "Conectar um provedor Git",
"es": "Conectar un proveedor Git",
"tr": "Git sağlayıcısını bağla",
"uk": "Підключити постачальник Git",
"ca": "Connecta un proveïdor de Git"
},
"TASKS$NO_GIT_PROVIDERS_DESCRIPTION": {
"en": "Connect a Git provider to see suggested tasks from your repositories.",
"ja": "Gitプロバイダーを接続して、リポジトリからの提案タスクを表示します。",
"zh-CN": "连接 Git 提供商以查看来自存储库的建议任务。",
"zh-TW": "連接 Git 提供商以查看來自存儲庫的建議任務。",
"ko-KR": "Git 제공자를 연결하여 저장소의 추천 작업을 확인하세요.",
"no": "Koble til en Git-leverandør for å se foreslåtte oppgaver fra dine repositorier.",
"ar": "قم بتوصيل موفر Git لرؤية المهام المقترحة من مستودعاتك.",
"de": "Verbinde einen Git-Anbieter, um vorgeschlagene Aufgaben aus deinen Repositories zu sehen.",
"fr": "Connectez un fournisseur Git pour voir les tâches suggérées depuis vos dépôts.",
"it": "Collega un provider Git per vedere le attività suggerite dai tuoi repository.",
"pt": "Conecte um provedor Git para ver tarefas sugeridas dos seus repositórios.",
"es": "Conecta un proveedor Git para ver tareas sugeridas de tus repositorios.",
"tr": "Depolarınızdan önerilen görevleri görmek için bir Git sağlayıcısı bağlayın.",
"uk": "Підключіть постачальник Git, щоб бачити запропоновані завдання з ваших репозиторіїв.",
"ca": "Connecteu un proveïdor de Git per veure les tasques suggerides dels vostres repositoris."
},
"TASKS$NO_GIT_PROVIDERS_CTA": {
"en": "Go to Integrations",
"ja": "統合へ移動",
"zh-CN": "前往集成",
"zh-TW": "前往整合",
"ko-KR": "통합으로 이동",
"no": "Gå til integrasjoner",
"ar": "الانتقال إلى التكاملات",
"de": "Zu Integrationen",
"fr": "Aller aux intégrations",
"it": "Vai alle integrazioni",
"pt": "Ir para integrações",
"es": "Ir a integraciones",
"tr": "Entegrasyonlara git",
"uk": "Перейти до інтеграцій",
"ca": "Vés a Integracions"
},
"TASKS$SUGGESTED_TASKS": {
"en": "Suggested Tasks",
"ja": "推奨タスク",
@@ -17135,23 +17050,6 @@
"uk": "Максимальний бюджет на розмову в доларах США",
"ca": "Pressupost màxim per conversa en USD"
},
"GIT$DISCONNECT_TOKENS": {
"en": "Disconnect Tokens",
"ja": "トークンを切断",
"zh-CN": "断开令牌连接",
"zh-TW": "中斷令牌連接",
"ko-KR": "토큰 연결 해제",
"no": "Koble fra tokens",
"it": "Disconnetti token",
"pt": "Desconectar tokens",
"es": "Desconectar tokens",
"ar": "قطع اتصال الرموز",
"fr": "Déconnecter les jetons",
"tr": "Token bağlantısını kes",
"de": "Token trennen",
"uk": "Відключити токени",
"ca": "Desconnecta els tokens"
},
"API$TAVILY_KEY_EXAMPLE": {
"en": "tvly-dev-...",
"ja": "tvly-dev-...",
@@ -18002,482 +17900,6 @@
"uk": "Електронна пошта для git-комітів",
"ca": "Correu electrònic per als commits de Git"
},
"PROJECT_MANAGEMENT$TITLE": {
"en": "Project Management",
"ja": "プロジェクト管理",
"zh-CN": "项目管理",
"zh-TW": "專案管理",
"ko-KR": "프로젝트 관리",
"no": "Prosjektledelse",
"it": "Gestione del progetto",
"pt": "Gerenciamento de projetos",
"es": "Gestión de proyectos",
"ar": "إدارة المشاريع",
"fr": "Gestion de projet",
"tr": "Proje Yönetimi",
"de": "Projektmanagement",
"uk": "Управління проектами",
"ca": "Gestió de projectes"
},
"PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR": {
"en": "Failed to validate integration. Please try again later.",
"ja": "統合の検証に失敗しました。しばらくしてからもう一度お試しください.",
"zh-CN": "无法验证集成。请稍后重试.",
"zh-TW": "無法驗證整合。請稍後重試.",
"ko-KR": "통합을 검증하지 못했습니다. 나중에 다시 시도해 주세요.",
"no": "Kunne ikke validere integrasjonen. Prøv på nytt senere.",
"it": "Impossibile convalidare l'integrazione. Riprova più tardi.",
"pt": "Falha ao validar a integração. Tente novamente mais tarde.",
"es": "No se pudo validar la integración. Inténtelo de nuevo más tarde.",
"ar": "فشل التحقق من التكامل. يُرجى المحاولة لاحقًا.",
"fr": "Échec de la validation de l'intégration. Veuillez réessayer ultérieurement.",
"tr": "Entegrasyon doğrulanamadı. Lütfen daha sonra tekrar deneyin.",
"de": "Die Integration konnte nicht validiert werden. Bitte versuchen Sie es später noch einmal.",
"uk": "Не вдалося перевірити інтеграцію. Спробуйте пізніше.",
"ca": "No s'ha pogut validar la integració. Torneu-ho a intentar més tard."
},
"PROJECT_MANAGEMENT$LINK_BUTTON_LABEL": {
"en": "Link",
"ja": "リンク",
"zh-CN": "关联",
"zh-TW": "關聯",
"ko-KR": "링크",
"no": "Link",
"it": "Collegamento",
"pt": "Link",
"es": "Enlace",
"ar": "وصلة",
"fr": "Lien",
"tr": "Bağlantı",
"de": "Link",
"uk": "Посилання",
"ca": "Vincula"
},
"PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL": {
"en": "Unlink",
"ja": "リンクを解除する",
"zh-CN": "取消链接",
"zh-TW": "取消連結",
"ko-KR": "풀리다",
"no": "Fjern tilknytningen",
"it": "Scollega",
"pt": "Desvincular",
"es": "Desconectar",
"ar": "إلغاء الارتباط",
"fr": "Dissocier",
"tr": "Bağlantıyı kaldır",
"de": "Verknüpfung aufheben",
"uk": "Від’єднати",
"ca": "Desvincula"
},
"PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE": {
"en": "Link Workspace",
"ja": "ワークスペースをリンク",
"zh-CN": "链接工作区",
"zh-TW": "連結工作區",
"ko-KR": "작업공간 링크",
"no": "Link arbeidsområde",
"it": "Collega area di lavoro",
"pt": "Vincular espaço de trabalho",
"es": "Vincular espacio de trabajo",
"ar": "ربط مساحة العمل",
"fr": "Lier l'espace de travail",
"tr": "Çalışma Alanını Bağla",
"de": "Arbeitsbereich verknüpfen",
"uk": "Пов'язати робочу область",
"ca": "Vincula l'espai de treball"
},
"PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL": {
"en": "Configure",
"ja": "設定する",
"zh-CN": "配置",
"zh-TW": "配置",
"ko-KR": "구성",
"no": "Konfigurer",
"it": "Configura",
"pt": "Configurar",
"es": "Configurar",
"ar": "تكوين",
"fr": "Configurer",
"tr": "Yapılandır",
"de": "Konfigurieren",
"uk": "Налаштувати",
"ca": "Configura"
},
"PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL": {
"en": "Edit",
"ja": "編集",
"zh-CN": "编辑",
"zh-TW": "編輯",
"ko-KR": "편집",
"no": "Rediger",
"it": "Modifica",
"pt": "Editar",
"es": "Editar",
"ar": "تحرير",
"fr": "Modifier",
"tr": "Düzenle",
"de": "Bearbeiten",
"uk": "Редагувати",
"ca": "Edita"
},
"PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL": {
"en": "Update",
"ja": "更新",
"zh-CN": "更新",
"zh-TW": "更新",
"ko-KR": "업데이트",
"no": "Oppdater",
"it": "Aggiorna",
"pt": "Atualizar",
"es": "Actualizar",
"ar": "تحديث",
"fr": "Mettre à jour",
"tr": "Güncelle",
"de": "Aktualisieren",
"uk": "Оновити",
"ca": "Actualitza"
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE": {
"en": "Configure {{platform}} Integration",
"ja": "{{platform}}統合を設定",
"zh-CN": "配置{{platform}}集成",
"zh-TW": "配置{{platform}}集成",
"ko-KR": "{{platform}} 통합 구성",
"no": "Konfigurer {{platform}}-integrasjon",
"it": "Configura integrazione {{platform}}",
"pt": "Configurar integração {{platform}}",
"es": "Configurar integración de {{platform}}",
"ar": "تكوين تكامل {{platform}}",
"fr": "Configurer l'intégration {{platform}}",
"tr": "{{platform}} Entegrasyonunu Yapılandır",
"de": "{{platform}}-Integration konfigurieren",
"uk": "Налаштувати інтеграцію {{platform}}",
"ca": "Configura la integració de {{platform}}"
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1": {
"en": "<b>Important: </b>Make sure the workspace integration for your target workspace is already configured. Check the <a>documentation</a> for more information.",
"ja": "<b>重要: </b>対象のワークスペースのワークスペース統合がすでに設定されていることを確認してください。詳しくは<a>ドキュメント</a>をご覧ください。",
"zh-CN": "<b>重要提示:</b>请确保目标工作区的工作区集成已配置完毕。查看<a>文档</a>了解更多信息。",
"zh-TW": "<b>重要提示:</b>請確保目標工作區的工作區整合已設定完成。查看<a>文件</a>以了解更多資訊。",
"ko-KR": "<b>중요:</b>대상 작업 공간에 대한 작업 공간 통합이 이미 구성되어 있는지 확인하세요. 자세한 내용은 <a>설명서</a>를 참조하세요.",
"no": "<b>Viktig:</b>Sørg for at arbeidsområdeintegrasjonen for målarbeidsområdet ditt allerede er konfigurert. Se <a>dokumentasjonen</a> for mer informasjon.",
"it": "<b>Importante: </b>Assicurati che l'integrazione dell'area di lavoro per l'area di lavoro di destinazione sia già configurata. Consulta la <a>documentazione</a> per ulteriori informazioni.",
"pt": "<b>Importante:</b>Certifique-se de que a integração do workspace de destino já esteja configurada. Consulte a <a>documentação</a> para obter mais informações.",
"es": "Importante: Asegúrate de que la integración del espacio de trabajo de destino ya esté configurada. Consulta la documentación para obtener más información.",
"ar": "<b>هام: </b>تأكد من إعداد تكامل مساحة العمل لمساحة العمل المستهدفة. <a>راجع المستند لمزيد من المعلومات</a>.",
"fr": "<b>Important :</b>Assurez-vous que l'intégration de l'espace de travail cible est déjà configurée. Consultez la <a>documentation</a> pour plus d'informations.",
"tr": "<b>Önemli: </b>Hedef çalışma alanınız için çalışma alanı entegrasyonunun zaten yapılandırılmış olduğundan emin olun. Daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b>Stellen Sie sicher, dass die Arbeitsbereichsintegration für Ihren Zielarbeitsbereich bereits konfiguriert ist. Weitere Informationen finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо: </b>Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте <a>документацію</a> для отримання додаткової інформації.",
"ca": "<b>Important: </b>Assegureu-vos que la integració de l'espai de treball per al vostre espai de treball de destinació ja estigui configurada. Consulteu la <a>documentació</a> per obtenir més informació."
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2": {
"en": "<b>Important:</b> Check the <a>documentation</a> for more information about configuring the workspace integration or updating an existing integration.",
"ja": "<b>重要:</b> ワークスペース統合の設定や既存の統合の更新について詳しくは<a>ドキュメント</a>をご確認ください。",
"zh-CN": "<b>重要提示:</b>有关配置工作区集成或更新现有集成的更多信息,请查看<a>文档</a>。",
"zh-TW": "<b>重要提示:</b>有關配置工作區整合或更新現有整合的更多資訊,請查看<a>文件</a>。",
"ko-KR": "<b>중요:</b> 작업공간 통합 구성이나 기존 통합 업데이트에 대한 자세한 내용은 <a>설명서</a>를 확인하세요.",
"no": "<b>Viktig:</b> Se <a>dokumentasjonen</a> for mer informasjon om konfigurering av arbeidsområdeintegrasjon eller oppdatering av eksisterende integrasjon.",
"it": "<b>Importante:</b> Consulta la <a>documentazione</a> per ulteriori informazioni sulla configurazione dell'integrazione dell'area di lavoro o sull'aggiornamento di un'integrazione esistente.",
"pt": "<b>Importante:</b> Consulte a <a>documentação</a> para obter mais informações sobre como configurar a integração do workspace ou atualizar uma integração existente.",
"es": "<b>Importante:</b> Consulte la <a>documentación</a> para obtener más información sobre la configuración de la integración del espacio de trabajo o la actualización de una integración existente.",
"ar": "<b>هام:</b> راجع <a>الوثائق</a> لمزيد من المعلومات حول تكوين تكامل مساحة العمل أو تحديث تكامل موجود.",
"fr": "<b>Important :</b> Consultez la <a>documentation</a> pour plus d'informations sur la configuration de l'intégration de l'espace de travail ou la mise à jour d'une intégration existante.",
"tr": "<b>Önemli:</b> Çalışma alanı entegrasyonunu yapılandırma veya mevcut bir entegrasyonu güncelleme hakkında daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b> Weitere Informationen zur Konfiguration der Arbeitsbereichsintegration oder zur Aktualisierung einer bestehenden Integration finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо:</b> Перегляньте <a>документацію</a> для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції.",
"ca": "<b>Important:</b> Consulteu la <a>documentació</a> per obtenir més informació sobre la configuració de la integració de l'espai de treball o l'actualització d'una integració existent."
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT": {
"en": "Workspace name can be found in the browser URL when you're accessing a resource (eg: issue) in {{platform}}.",
"ja": "ワークスペース名は、{{platform}}のリソース(例:イシュー)にアクセスする際のブラウザURLで確認できます。",
"zh-CN": "工作区名称可以在访问{{platform}}资源(如:问题)时的浏览器URL中找到。",
"zh-TW": "工作區名稱可以在存取{{platform}}資源(如:問題)時的瀏覽器URL中找到。",
"ko-KR": "작업공간 이름은 {{platform}}의 리소스(예: 이슈)에 액세스할 때 브라우저 URL에서 찾을 수 있습니다.",
"no": "Arbeidsområdenavn kan finnes i nettleser-URL-en når du får tilgang til en ressurs (f.eks: sak) i {{platform}}.",
"it": "Il nome dell'area di lavoro può essere trovato nell'URL del browser quando stai accedendo a una risorsa (es: issue) in {{platform}}.",
"pt": "O nome do workspace pode ser encontrado na URL do navegador quando você está acessando um recurso (ex: issue) em {{platform}}.",
"es": "El nombre del espacio de trabajo se puede encontrar en la URL del navegador cuando accedes a un recurso (ej: issue) en {{platform}}.",
"ar": "يمكن العثور على اسم مساحة العمل في عنوان URL للمتصفح عند الوصول إلى مورد (مثل: مشكلة) في {{platform}}.",
"fr": "Le nom de l'espace de travail peut être trouvé dans l'URL du navigateur lorsque vous accédez à une ressource (ex : issue) dans {{platform}}.",
"tr": "Çalışma alanı adı, {{platform}}'da bir kaynağa (örn: sorun) erişirken tarayıcı URL'sinde bulunabilir.",
"de": "Der Arbeitsbereichsname ist in der Browser-URL zu finden, wenn Sie auf eine Ressource (z.B.: Issue) in {{platform}} zugreifen.",
"uk": "Назву робочого простору можна знайти в URL браузера під час доступу до ресурсу (наприклад: проблема) в {{platform}}.",
"ca": "El nom de l'espai de treball es pot trobar a la URL del navegador quan accediu a un recurs (p. ex.: problema) a {{platform}}."
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL": {
"en": "Workspace Name",
"ja": "ワークスペース名",
"zh-CN": "工作区名称",
"zh-TW": "工作區名稱",
"ko-KR": "작업공간 이름",
"no": "Navn på arbeidsområde",
"it": "Nome dell'area di lavoro",
"pt": "Nome do espaço de trabalho",
"es": "Nombre del espacio de trabajo",
"ar": "اسم مساحة العمل",
"fr": "Nom de l'espace de travail",
"tr": "Çalışma Alanı Adı",
"de": "Arbeitsbereichsname",
"uk": "Назва робочої області",
"ca": "Nom de l'espai de treball"
},
"PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER": {
"en": "yourcompany.atlassian.net",
"ja": "yourcompany.atlassian.net",
"zh-CN": "yourcompany.atlassian.net",
"zh-TW": "yourcompany.atlassian.net",
"ko-KR": "yourcompany.atlassian.net",
"no": "yourcompany.atlassian.net",
"it": "yourcompany.atlassian.net",
"pt": "yourcompany.atlassian.net",
"es": "yourcompany.atlassian.net",
"ar": "yourcompany.atlassian.net",
"fr": "yourcompany.atlassian.net",
"tr": "yourcompany.atlassian.net",
"de": "yourcompany.atlassian.net",
"uk": "yourcompany.atlassian.net",
"ca": "yourcompany.atlassian.net"
},
"PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER": {
"en": "jira.yourcompany.com",
"ja": "jira.yourcompany.com",
"zh-CN": "jira.yourcompany.com",
"zh-TW": "jira.yourcompany.com",
"ko-KR": "jira.yourcompany.com",
"no": "jira.yourcompany.com",
"it": "jira.yourcompany.com",
"pt": "jira.yourcompany.com",
"es": "jira.yourcompany.com",
"ar": "jira.yourcompany.com",
"fr": "jira.yourcompany.com",
"tr": "jira.yourcompany.com",
"de": "jira.yourcompany.com",
"uk": "jira.yourcompany.com",
"ca": "jira.yourcompany.com"
},
"PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER": {
"en": "yourcompany",
"ja": "yourcompany",
"zh-CN": "yourcompany",
"zh-TW": "yourcompany",
"ko-KR": "yourcompany",
"no": "yourcompany",
"it": "yourcompany",
"pt": "yourcompany",
"es": "yourcompany",
"ar": "yourcompany",
"fr": "yourcompany",
"tr": "yourcompany",
"de": "yourcompany",
"uk": "yourcompany",
"ca": "yourcompany"
},
"PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL": {
"en": "Webhook Secret",
"ja": "Webhook シークレット",
"zh-CN": "Webhook 秘密",
"zh-TW": "Webhook 秘密",
"ko-KR": "웹훅 비밀",
"no": "Webhook Secret",
"it": "Segreto del webhook",
"pt": "Segredo do webhook",
"es": "Secreto del webhook",
"ar": "سر الويب هوك",
"fr": "Secret du webhook",
"tr": "Web Kancası Sırrı",
"de": "Webhook-Geheimnis",
"uk": "Секрет вебхуку",
"ca": "Secret del webhook"
},
"PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER": {
"en": "whsec_abcd1234efgh5678ijkl",
"ja": "whsec_abcd1234efgh5678ijkl",
"zh-CN": "whsec_abcd1234efgh5678ijkl",
"zh-TW": "whsec_abcd1234efgh5678ijkl",
"ko-KR": "whsec_abcd1234efgh5678ijkl",
"no": "whsec_abcd1234efgh5678ijkl",
"it": "whsec_abcd1234efgh5678ijkl",
"pt": "whsec_abcd1234efgh5678ijkl",
"es": "whsec_abcd1234efgh5678ijkl",
"ar": "whsec_abcd1234efgh5678ijkl",
"fr": "whsec_abcd1234efgh5678ijkl",
"tr": "whsec_abcd1234efgh5678ijkl",
"de": "whsec_abcd1234efgh5678ijkl",
"uk": "whsec_abcd1234efgh5678ijkl",
"ca": "whsec_abcd1234efgh5678ijkl"
},
"PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL": {
"en": "Service Account Email",
"ja": "サービスアカウントのメールアドレス",
"zh-CN": "服务帐户电子邮件",
"zh-TW": "服務帳號電子郵件",
"ko-KR": "서비스 계정 이메일",
"no": "Tjenestekonto e-post",
"it": "E-mail dell'account di servizio",
"pt": "E-mail da conta de serviço",
"es": "Correo electrónico de cuenta de servicio",
"ar": "البريد الإلكتروني لحساب الخدمة",
"fr": "E-mail du compte de service",
"tr": "Hizmet Hesabı E-postası",
"de": "E-Mail-Adresse des Dienstkontos",
"uk": "Електронна адреса облікового запису служби",
"ca": "Correu electrònic del compte de servei"
},
"PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_PLACEHOLDER": {
"en": "test@example.com",
"ja": "test@example.com",
"zh-CN": "test@example.com",
"zh-TW": "test@example.com",
"ko-KR": "test@example.com",
"no": "test@example.com",
"it": "test@example.com",
"pt": "test@example.com",
"es": "test@example.com",
"ar": "test@example.com",
"fr": "test@example.com",
"tr": "test@example.com",
"de": "test@example.com",
"uk": "test@example.com",
"ca": "test@example.com"
},
"PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_LABEL": {
"en": "Service Account API Key",
"ja": "サービスアカウントAPIキー",
"zh-CN": "服务帐户 API 密钥",
"zh-TW": "服務帳戶 API 金鑰",
"ko-KR": "서비스 계정 API 키",
"no": "Tjenestekonto API-nøkkel",
"it": "Chiave API dell'account di servizio",
"pt": "Chave de API da conta de serviço",
"es": "Clave API de cuenta de servicio",
"ar": "مفتاح API لحساب الخدمة",
"fr": "Clé API du compte de service",
"tr": "Hizmet Hesabı API Anahtarı",
"de": "API-Schlüssel des Dienstkontos",
"uk": "Ключ API облікового запису служби",
"ca": "Clau d'API del compte de servei"
},
"PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER": {
"en": "sk-1234567890abcdef...",
"ja": "sk-1234567890abcdef...",
"zh-CN": "sk-1234567890abcdef...",
"zh-TW": "sk-1234567890abcdef...",
"ko-KR": "sk-1234567890abcdef...",
"no": "sk-1234567890abcdef...",
"it": "sk-1234567890abcdef...",
"pt": "sk-1234567890abcdef...",
"es": "sk-1234567890abcdef...",
"ar": "sk-1234567890abcdef...",
"fr": "sk-1234567890abcdef...",
"tr": "sk-1234567890abcdef...",
"de": "sk-1234567890abcdef...",
"uk": "sk-1234567890abcdef...",
"ca": "sk-1234567890abcdef..."
},
"PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL": {
"en": "Connect",
"ja": "接続する",
"zh-CN": "连接",
"zh-TW": "連接",
"ko-KR": "연결하다",
"no": "Koble til",
"it": "Collegare",
"pt": "Conectar",
"es": "Conectar",
"ar": "يتصل",
"fr": "Connecter",
"tr": "Bağlamak",
"de": "Verbinden",
"uk": "Підключитися",
"ca": "Connecta"
},
"PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL": {
"en": "Active",
"ja": "アクティブ",
"zh-CN": "积极的",
"zh-TW": "積極的",
"ko-KR": "활동적인",
"no": "Aktiv",
"it": "Attiva",
"pt": "Ativa",
"es": "Activa",
"ar": "نشيط",
"fr": "Actif",
"tr": "Aktif",
"de": "Aktiv",
"uk": "Активний",
"ca": "Actiu"
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR": {
"en": "Workspace name can only contain letters, numbers, hyphens, and underscores",
"ja": "ワークスペース名は文字、数字、ハイフン、アンダースコアのみ使用できます",
"zh-CN": "工作区名称只能包含字母、数字、连字符和下划线",
"zh-TW": "工作區名稱只能包含字母、數字、連字符和底線",
"ko-KR": "작업공간 이름은 문자, 숫자, 하이픈, 밑줄만 포함할 수 있습니다",
"no": "Arbeidsområdenavn kan bare inneholde bokstaver, tall, bindestrek og understrek",
"it": "Il nome dell'area di lavoro può contenere solo lettere, numeri, trattini e trattini bassi",
"pt": "O nome do workspace pode conter apenas letras, números, hífens e sublinhados",
"es": "El nombre del espacio de trabajo solo puede contener letras, números, guiones y guiones bajos",
"ar": "يمكن أن يحتوي اسم مساحة العمل على أحرف وأرقام وشرطات وشرطات سفلية فقط",
"fr": "Le nom de l'espace de travail ne peut contenir que des lettres, des chiffres, des tirets et des traits de soulignement",
"tr": "Çalışma alanı adı yalnızca harfler, sayılar, tire ve alt çizgi içerebilir",
"de": "Der Arbeitsbereichsname darf nur Buchstaben, Zahlen, Bindestriche und Unterstriche enthalten",
"uk": "Назва робочого простору може містити тільки літери, цифри, дефіси та підкреслення",
"ca": "El nom de l'espai de treball només pot contenir lletres, números, guions i guions baixos"
},
"PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR": {
"en": "Webhook secret cannot contain spaces",
"ja": "Webhookシークレットにはスペースを含めることはできません",
"zh-CN": "Webhook密钥不能包含空格",
"zh-TW": "Webhook密鑰不能包含空格",
"ko-KR": "웹훅 시크릿에는 공백을 포함할 수 없습니다",
"no": "Webhook-hemmelighet kan ikke inneholde mellomrom",
"it": "Il segreto del webhook non può contenere spazi",
"pt": "O segredo do webhook não pode conter espaços",
"es": "El secreto del webhook no puede contener espacios",
"ar": "لا يمكن أن يحتوي سر الويب هوك على مسافات",
"fr": "Le secret du webhook ne peut pas contenir d'espaces",
"tr": "Webhook sırrı boşluk içeremez",
"de": "Das Webhook-Geheimnis darf keine Leerzeichen enthalten",
"uk": "Секрет веб-хука не може містити пробіли",
"ca": "El secret del webhook no pot contenir espais"
},
"PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR": {
"en": "Please enter a valid email address",
"ja": "有効なメールアドレスを入力してください",
"zh-CN": "请输入有效的电子邮件地址",
"zh-TW": "請輸入有效的電子郵件地址",
"ko-KR": "유효한 이메일 주소를 입력하세요",
"no": "Vennligst skriv inn en gyldig e-postadresse",
"it": "Inserisci un indirizzo email valido",
"pt": "Por favor, insira um endereço de email válido",
"es": "Por favor, introduce una dirección de correo electrónico válida",
"ar": "يرجى إدخال عنوان بريد إلكتروني صحيح",
"fr": "Veuillez saisir une adresse e-mail valide",
"tr": "Lütfen geçerli bir e-posta adresi girin",
"de": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"uk": "Будь ласка, введіть дійсну адресу електронної пошти",
"ca": "Introduïu una adreça de correu electrònic vàlida"
},
"PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR": {
"en": "API key cannot contain spaces",
"ja": "APIキーにはスペースを含めることはできません",
"zh-CN": "API密钥不能包含空格",
"zh-TW": "API密鑰不能包含空格",
"ko-KR": "API 키에는 공백을 포함할 수 없습니다",
"no": "API-nøkkel kan ikke inneholde mellomrom",
"it": "La chiave API non può contenere spazi",
"pt": "A chave da API não pode conter espaços",
"es": "La clave API no puede contener espacios",
"ar": "لا يمكن أن يحتوي مفتاح API على مسافات",
"fr": "La clé API ne peut pas contenir d'espaces",
"tr": "API anahtarı boşluk içeremez",
"de": "Der API-Schlüssel darf keine Leerzeichen enthalten",
"uk": "Ключ API не може містити пробіли",
"ca": "La clau d'API no pot contenir espais"
},
"SETTINGS$MCP_SERVER_TYPE_SSE": {
"en": "SSE",
"ja": "SSE",
@@ -18971,57 +18393,6 @@
"uk": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}",
"ca": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}"
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER": {
"en": "myworkspace",
"ja": "私のワークスペース",
"zh-CN": "我的工作空间",
"zh-TW": "我的工作區",
"ko-KR": "내워크스페이스",
"no": "mittarbeidsområde",
"it": "mioworkspace",
"pt": "meuworkspace",
"es": "miespaciodetrabajo",
"ar": "مساحةعملي",
"fr": "monworkspace",
"tr": "benimworkspace",
"de": "meinarbeitsbereich",
"uk": "моя-робоча-область",
"ca": "myworkspace"
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION": {
"en": "<b>Important:</b> Check the <a>documentation</a> for more information about configuring the workspace integration or updating an existing integration.",
"ja": "<b>重要:</b> ワークスペース統合の設定や既存の統合の更新について詳しくは<a>ドキュメント</a>をご確認ください。",
"zh-CN": "<b>重要提示:</b>有关配置工作区集成或更新现有集成的更多信息,请查看<a>文档</a>。",
"zh-TW": "<b>重要提示:</b>有關配置工作區整合或更新現有整合的更多資訊,請查看<a>文件</a>。",
"ko-KR": "<b>중요:</b> 작업공간 통합 구성이나 기존 통합 업데이트에 대한 자세한 내용은 <a>설명서</a>를 확인하세요.",
"no": "<b>Viktig:</b> Se <a>dokumentasjonen</a> for mer informasjon om konfigurering av arbeidsområdeintegrasjon eller oppdatering av eksisterende integrasjon.",
"it": "<b>Importante:</b> Consulta la <a>documentazione</a> per ulteriori informazioni sulla configurazione dell'integrazione dell'area di lavoro o sull'aggiornamento di un'integrazione esistente.",
"pt": "<b>Importante:</b> Consulte a <a>documentação</a> para obter mais informações sobre como configurar a integração do workspace ou atualizar uma integração existente.",
"es": "<b>Importante:</b> Consulte la <a>documentación</a> para obtener más información sobre la configuración de la integración del espacio de trabajo o la actualización de una integración existente.",
"ar": "<b>هام:</b> راجع <a>الوثائق</a> لمزيد من المعلومات حول تكوين تكامل مساحة العمل أو تحديث تكامل موجود.",
"fr": "<b>Important :</b> Consultez la <a>documentation</a> pour plus d'informations sur la configuration de l'intégration de l'espace de travail ou la mise à jour d'une intégration existante.",
"tr": "<b>Önemli:</b> Çalışma alanı entegrasyonunu yapılandırma veya mevcut bir entegrasyonu güncelleme hakkında daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b> Weitere Informationen zur Konfiguration der Arbeitsbereichsintegration oder zur Aktualisierung einer bestehenden Integration finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо:</b> Перегляньте <a>документацію</a> для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції.",
"ca": "<b>Important:</b> Consulteu la <a>documentació</a> per obtenir més informació sobre la configuració de la integració de l'espai de treball o l'actualització d'una integració existent."
},
"PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION": {
"en": "<b>Important: </b>Make sure the workspace integration for your target workspace is already configured. Check the <a>documentation</a> for more information.",
"ja": "<b>重要: </b>対象のワークスペースのワークスペース統合がすでに設定されていることを確認してください。詳しくは<a>ドキュメント</a>をご覧ください。",
"zh-CN": "<b>重要提示:</b>请确保目标工作区的工作区集成已配置完毕。查看<a>文档</a>了解更多信息。",
"zh-TW": "<b>重要提示:</b>請確保目標工作區的工作區整合已設定完成。查看<a>文件</a>以了解更多資訊。",
"ko-KR": "<b>중요:</b>대상 작업 공간에 대한 작업 공간 통합이 이미 구성되어 있는지 확인하세요. 자세한 내용은 <a>설명서</a>를 참조하세요.",
"no": "<b>Viktig:</b>Sørg for at arbeidsområdeintegrasjonen for målarbeidsområdet ditt allerede er konfigurert. Se <a>dokumentasjonen</a> for mer informasjon.",
"it": "<b>Importante: </b>Assicurati che l'integrazione dell'area di lavoro per l'area di lavoro di destinazione sia già configurata. Consulta la <a>documentazione</a> per ulteriori informazioni.",
"pt": "<b>Importante:</b>Certifique-se de que a integração do workspace de destino já esteja configurada. Consulte a <a>documentação</a> para obter mais informações.",
"es": "Importante: Asegúrate de que la integración del espacio de trabajo de destino ya esté configurada. Consulta la documentación para obtener más información.",
"ar": "<b>هام: </b>تأكد من إعداد تكامل مساحة العمل لمساحة العمل المستهدفة. <a>راجع المستند لمزيد من المعلومات</a>.",
"fr": "<b>Important :</b>Assurez-vous que l'intégration de l'espace de travail cible est déjà configurée. Consultez la <a>documentation</a> pour plus d'informations.",
"tr": "<b>Önemli: </b>Hedef çalışma alanınız için çalışma alanı entegrasyonunun zaten yapılandırılmış olduğundan emin olun. Daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b>Stellen Sie sicher, dass die Arbeitsbereichsintegration für Ihren Zielarbeitsbereich bereits konfiguriert ist. Weitere Informationen finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо: </b>Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте <a>документацію</a> для отримання додаткової інформації.",
"ca": "<b>Important: </b>Assegureu-vos que la integració de l'espai de treball per al vostre espai de treball de destinació ja estigui configurada. Consulteu la <a>documentació</a> per obtenir més informació."
},
"SETTINGS": {
"en": "Environment variables must follow KEY=value format",
"ja": "A server with this URL already exists for the selected type",
-8
View File
@@ -44,11 +44,7 @@ export const createMockWebClientConfig = (
posthog_client_key: "test-posthog-key",
feature_flags: {
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_integrations_page: false,
...overrides.feature_flags,
},
providers_configured: [],
@@ -452,11 +448,7 @@ export const SETTINGS_HANDLERS = [
posthog_client_key: "fake-posthog-client-key",
feature_flags: {
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_integrations_page: false,
},
providers_configured: [],
maintenance_start_time: null,
-1
View File
@@ -15,7 +15,6 @@ export default [
route("verification", "routes/verification-settings.tsx"),
route("mcp", "routes/mcp-settings.tsx"),
route("skills", "routes/skills-settings.tsx"),
route("integrations", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("secrets", "routes/secrets-settings.tsx"),
]),
-311
View File
@@ -1,311 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
import { BitbucketTokenInput } from "#/components/features/settings/git-settings/bitbucket-token-input";
import { BitbucketDCTokenInput } from "#/components/features/settings/git-settings/bitbucket-dc-token-help-input";
import { AzureDevOpsTokenInput } from "#/components/features/settings/git-settings/azure-devops-token-input";
import { ForgejoTokenInput } from "#/components/features/settings/git-settings/forgejo-token-input";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
import { useDeleteGitProviders } from "#/hooks/mutation/use-delete-git-providers";
import { useUserProviders } from "#/hooks/use-user-providers";
import { ProjectManagementIntegration } from "#/components/features/settings/project-management/project-management-integration";
import { Provider, ProviderToken } from "#/types/settings";
export function GitSettingsScreen() {
const { t } = useTranslation("openhands");
const { mutate: saveGitProviders, isPending } = useAddGitProviders();
const { mutate: disconnectGitTokens, isPending: isDisconnecting } =
useDeleteGitProviders();
const { data: settings, isLoading } = useSettings();
const { providers } = useUserProviders();
const { data: config } = useConfig();
const [githubTokenInputHasValue, setGithubTokenInputHasValue] =
React.useState(false);
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
React.useState(false);
const [bitbucketTokenInputHasValue, setBitbucketTokenInputHasValue] =
React.useState(false);
const [bitbucketDCTokenInputHasValue, setBitbucketDCTokenInputHasValue] =
React.useState(false);
const [azureDevOpsTokenInputHasValue, setAzureDevOpsTokenInputHasValue] =
React.useState(false);
const [forgejoTokenInputHasValue, setForgejoTokenInputHasValue] =
React.useState(false);
const [githubHostInputHasValue, setGithubHostInputHasValue] =
React.useState(false);
const [gitlabHostInputHasValue, setGitlabHostInputHasValue] =
React.useState(false);
const [bitbucketHostInputHasValue, setBitbucketHostInputHasValue] =
React.useState(false);
const [bitbucketDCHostInputHasValue, setBitbucketDCHostInputHasValue] =
React.useState(false);
const [azureDevOpsHostInputHasValue, setAzureDevOpsHostInputHasValue] =
React.useState(false);
const [forgejoHostInputHasValue, setForgejoHostInputHasValue] =
React.useState(false);
const existingGithubHost = settings?.provider_tokens_set.github;
const existingGitlabHost = settings?.provider_tokens_set.gitlab;
const existingBitbucketHost = settings?.provider_tokens_set.bitbucket;
const existingBitbucketDCHost =
settings?.provider_tokens_set.bitbucket_data_center;
const existingAzureDevOpsHost = settings?.provider_tokens_set.azure_devops;
const existingForgejoHost = settings?.provider_tokens_set.forgejo;
const isGitHubTokenSet = providers.includes("github");
const isGitLabTokenSet = providers.includes("gitlab");
const isBitbucketTokenSet = providers.includes("bitbucket");
const isBitbucketDCTokenSet = providers.includes("bitbucket_data_center");
const isAzureDevOpsTokenSet = providers.includes("azure_devops");
const isForgejoTokenSet = providers.includes("forgejo");
const formAction = async (formData: FormData) => {
const disconnectButtonClicked =
formData.get("disconnect-tokens-button") !== null;
if (disconnectButtonClicked) {
disconnectGitTokens(undefined, {
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
});
return;
}
const githubToken = (
formData.get("github-token-input")?.toString() || ""
).trim();
const gitlabToken = (
formData.get("gitlab-token-input")?.toString() || ""
).trim();
const bitbucketToken = (
formData.get("bitbucket-token-input")?.toString() || ""
).trim();
const bitbucketDCToken = (
formData.get("bitbucket-dc-token-input")?.toString() || ""
).trim();
const azureDevOpsToken = (
formData.get("azure-devops-token-input")?.toString() || ""
).trim();
const forgejoToken = (
formData.get("forgejo-token-input")?.toString() || ""
).trim();
const githubHost = (
formData.get("github-host-input")?.toString() || ""
).trim();
const gitlabHost = (
formData.get("gitlab-host-input")?.toString() || ""
).trim();
const bitbucketHost = (
formData.get("bitbucket-host-input")?.toString() || ""
).trim();
const bitbucketDCHost = (
formData.get("bitbucket-dc-host-input")?.toString() || ""
).trim();
const azureDevOpsHost = (
formData.get("azure-devops-host-input")?.toString() || ""
).trim();
const forgejoHost = (
formData.get("forgejo-host-input")?.toString() || ""
).trim();
const providerTokens: Partial<Record<Provider, ProviderToken>> = {
github: { token: githubToken, host: githubHost },
gitlab: { token: gitlabToken, host: gitlabHost },
bitbucket: { token: bitbucketToken, host: bitbucketHost },
bitbucket_data_center: { token: bitbucketDCToken, host: bitbucketDCHost },
azure_devops: { token: azureDevOpsToken, host: azureDevOpsHost },
forgejo: { token: forgejoToken, host: forgejoHost },
};
saveGitProviders(
{
providers: providerTokens,
},
{
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled: () => {
setGithubTokenInputHasValue(false);
setGitlabTokenInputHasValue(false);
setBitbucketTokenInputHasValue(false);
setBitbucketDCTokenInputHasValue(false);
setAzureDevOpsTokenInputHasValue(false);
setForgejoTokenInputHasValue(false);
setGithubHostInputHasValue(false);
setGitlabHostInputHasValue(false);
setBitbucketHostInputHasValue(false);
setBitbucketDCHostInputHasValue(false);
setAzureDevOpsHostInputHasValue(false);
setForgejoHostInputHasValue(false);
},
},
);
};
const formIsClean =
!githubTokenInputHasValue &&
!gitlabTokenInputHasValue &&
!bitbucketTokenInputHasValue &&
!bitbucketDCTokenInputHasValue &&
!azureDevOpsTokenInputHasValue &&
!forgejoTokenInputHasValue &&
!githubHostInputHasValue &&
!gitlabHostInputHasValue &&
!bitbucketHostInputHasValue &&
!bitbucketDCHostInputHasValue &&
!azureDevOpsHostInputHasValue &&
!forgejoHostInputHasValue;
const shouldRenderProjectManagementIntegrations =
config?.feature_flags?.enable_jira ||
config?.feature_flags?.enable_jira_dc ||
config?.feature_flags?.enable_linear;
return (
<form
data-testid="git-settings-screen"
action={formAction}
className="flex flex-col h-full justify-between"
>
{!isLoading && (
<div className="flex flex-col gap-4">
{shouldRenderProjectManagementIntegrations && (
<div className="mt-6">
<ProjectManagementIntegration />
</div>
)}
<GitHubTokenInput
name="github-token-input"
isGitHubTokenSet={isGitHubTokenSet}
onChange={(value) => {
setGithubTokenInputHasValue(!!value);
}}
onGitHubHostChange={(value) => {
setGithubHostInputHasValue(!!value);
}}
githubHostSet={existingGithubHost}
/>
<GitLabTokenInput
name="gitlab-token-input"
isGitLabTokenSet={isGitLabTokenSet}
onChange={(value) => {
setGitlabTokenInputHasValue(!!value);
}}
onGitLabHostChange={(value) => {
setGitlabHostInputHasValue(!!value);
}}
gitlabHostSet={existingGitlabHost}
/>
<BitbucketTokenInput
name="bitbucket-token-input"
isBitbucketTokenSet={isBitbucketTokenSet}
onChange={(value) => {
setBitbucketTokenInputHasValue(!!value);
}}
onBitbucketHostChange={(value) => {
setBitbucketHostInputHasValue(!!value);
}}
bitbucketHostSet={existingBitbucketHost}
/>
<BitbucketDCTokenInput
name="bitbucket-dc-token-input"
isBitbucketDCTokenSet={isBitbucketDCTokenSet}
onChange={(value) => {
setBitbucketDCTokenInputHasValue(!!value);
}}
onBitbucketDCHostChange={(value) => {
setBitbucketDCHostInputHasValue(!!value);
}}
bitbucketDCHostSet={existingBitbucketDCHost}
/>
<AzureDevOpsTokenInput
name="azure-devops-token-input"
isAzureDevOpsTokenSet={isAzureDevOpsTokenSet}
onChange={(value) => {
setAzureDevOpsTokenInputHasValue(!!value);
}}
onAzureDevOpsHostChange={(value) => {
setAzureDevOpsHostInputHasValue(!!value);
}}
azureDevOpsHostSet={existingAzureDevOpsHost}
/>
<ForgejoTokenInput
name="forgejo-token-input"
isForgejoTokenSet={isForgejoTokenSet}
onChange={(value) => {
setForgejoTokenInputHasValue(!!value);
}}
onForgejoHostChange={(value) => {
setForgejoHostInputHasValue(!!value);
}}
forgejoHostSet={existingForgejoHost}
/>
</div>
)}
{isLoading && <GitSettingInputsSkeleton />}
<div className="flex gap-6 p-6 justify-end">
<BrandButton
testId="disconnect-tokens-button"
name="disconnect-tokens-button"
type="submit"
variant="secondary"
isDisabled={
isDisconnecting ||
(!isGitHubTokenSet &&
!isGitLabTokenSet &&
!isBitbucketTokenSet &&
!isBitbucketDCTokenSet &&
!isAzureDevOpsTokenSet &&
!isForgejoTokenSet)
}
>
{t(I18nKey.GIT$DISCONNECT_TOKENS)}
</BrandButton>
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={isPending || isDisconnecting || formIsClean}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
</form>
);
}
export default GitSettingsScreen;
-11
View File
@@ -3,7 +3,6 @@ import { Outlet, redirect, useLocation, useMatches } from "react-router";
import { useTranslation } from "react-i18next";
import { Route } from "./+types/settings";
import OptionService from "#/api/option-service/option-service.api";
import { getActiveBackend } from "#/api/backend-registry/active-store";
import { queryClient } from "#/query-client-config";
import { SettingsLayout } from "#/components/features/settings";
import { WebClientConfig } from "#/api/option-service/option.types";
@@ -13,7 +12,6 @@ import { BackendSyncedSettingsBadge } from "#/components/features/settings/backe
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
import {
getFirstAvailablePath,
isLocalOnlySettingsPath,
isSettingsPageHidden,
} from "#/utils/settings-utils";
@@ -21,15 +19,6 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
const url = new URL(request.url);
const { pathname } = url;
// Cloud backends hide local-only settings pages. Block direct URL
// navigation, not just the menu link.
if (
getActiveBackend().backend.kind === "cloud" &&
isLocalOnlySettingsPath(pathname)
) {
return redirect("/settings");
}
const config = await queryClient.fetchQuery<WebClientConfig>({
queryKey: QUERY_KEYS.WEB_CLIENT_CONFIG,
queryFn: OptionService.getConfig,
-21
View File
@@ -54,36 +54,15 @@ export function isSettingsPageHidden(
featureFlags: WebClientFeatureFlags | undefined,
): boolean {
if (featureFlags?.hide_llm_settings && path === "/settings") return true;
if (
featureFlags?.hide_integrations_page &&
path === "/settings/integrations"
) {
return true;
}
return false;
}
// Settings sub-pages that only make sense against a local agent-server.
// Hidden from navigation and blocked at the route loader when the active
// backend is a cloud SaaS environment.
export const LOCAL_ONLY_SETTINGS_PATHS = new Set<string>([
"/settings/integrations",
]);
export function isLocalOnlySettingsPath(path: string): boolean {
return LOCAL_ONLY_SETTINGS_PATHS.has(path);
}
export function getFirstAvailablePath(
featureFlags: WebClientFeatureFlags | undefined,
): string | null {
const fallbackOrder = [
{ path: "/settings", hidden: !!featureFlags?.hide_llm_settings },
{ path: "/settings/mcp", hidden: false },
{
path: "/settings/integrations",
hidden: !!featureFlags?.hide_integrations_page,
},
{ path: "/settings/app", hidden: false },
{ path: "/settings/secrets", hidden: false },
];