mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 15:03:43 +08:00
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:
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
-32
@@ -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>
|
||||
);
|
||||
}
|
||||
-41
@@ -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,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";
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
]),
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user