chore: Move GitHub logic out of the frontend (#6307)

Co-authored-by: Robert Brennan <accounts@rbren.io>
This commit is contained in:
sp.wack
2025-01-28 17:14:32 +04:00
committed by GitHub
parent d6655f3470
commit 36c2abadc2
42 changed files with 704 additions and 612 deletions

View File

@@ -4,51 +4,22 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import OpenHands from "#/api/open-hands";
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
const renderSidebar = () => {
const RouterStub = createRoutesStub([
{
path: "/conversation/:conversationId",
Component: Sidebar,
},
]);
const RouterStub = createRoutesStub([
{
path: "/conversation/:conversationId",
Component: () => <Sidebar />,
},
]);
const renderSidebar = () =>
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
};
describe("Sidebar", () => {
it.skipIf(!MULTI_CONVERSATION_UI)(
"should have the conversation panel open by default",
() => {
renderSidebar();
expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
},
);
it.skipIf(!MULTI_CONVERSATION_UI)(
"should toggle the conversation panel",
async () => {
const user = userEvent.setup();
renderSidebar();
const projectPanelButton = screen.getByTestId(
"toggle-conversation-panel",
);
await user.click(projectPanelButton);
expect(
screen.queryByTestId("conversation-panel"),
).not.toBeInTheDocument();
},
);
describe("Settings", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
@@ -76,35 +47,12 @@ describe("Sidebar", () => {
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
// the actual values are falsey (null or "") but we're checking for undefined
llm_api_key: undefined,
llm_base_url: undefined,
security_analyzer: undefined,
});
});
it("should send all settings data when saving account settings", async () => {
const user = userEvent.setup();
renderSidebar();
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const menu = screen.getByTestId("account-settings-context-menu");
const accountSettingsButton = within(menu).getByTestId(
"account-settings-button",
);
await user.click(accountSettingsButton);
const accountSettingsModal = screen.getByTestId("account-settings-form");
const saveButton =
within(accountSettingsModal).getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
llm_api_key: undefined, // null or undefined
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
});
});
@@ -139,9 +87,15 @@ describe("Sidebar", () => {
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
github_token: "new-token",
language: "no",
llm_api_key: undefined, // null or undefined
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
});
});
@@ -169,11 +123,61 @@ describe("Sidebar", () => {
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
llm_api_key: undefined,
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_base_url: "",
security_analyzer: undefined,
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
});
});
});
describe("Settings Modal", () => {
it("should open the settings modal if the settings version is out of date", async () => {
const user = userEvent.setup();
localStorage.clear();
const { rerender } = renderSidebar();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
const saveSettingsButton = await within(settingsModal).findByTestId(
"save-settings-button",
);
await user.click(saveSettingsButton);
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
rerender(<RouterStub />);
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
});
it("should open the settings modal if the user clicks the settings button", async () => {
const user = userEvent.setup();
renderSidebar();
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
const settingsButton = screen.getByTestId("settings-button");
await user.click(settingsButton);
const settingsModal = screen.getByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
it("should open the settings modal if GET /settings fails", async () => {
vi.spyOn(OpenHands, "getSettings").mockRejectedValue(
new Error("Failed to fetch settings"),
);
renderSidebar();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
});
});

View File

@@ -14,7 +14,7 @@ describe("WaitlistModal", () => {
});
it("should render a tos checkbox that is unchecked by default", () => {
render(<WaitlistModal ghToken={null} githubAuthUrl={null} />);
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).not.toBeChecked();
@@ -22,7 +22,7 @@ describe("WaitlistModal", () => {
it("should only enable the GitHub button if the tos checkbox is checked", async () => {
const user = userEvent.setup();
render(<WaitlistModal ghToken={null} githubAuthUrl={null} />);
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
const checkbox = screen.getByRole("checkbox");
const button = screen.getByRole("button", { name: "Connect to GitHub" });
@@ -40,7 +40,7 @@ describe("WaitlistModal", () => {
);
const user = userEvent.setup();
render(<WaitlistModal ghToken={null} githubAuthUrl="mock-url" />);
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl="mock-url" />);
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);

View File

@@ -0,0 +1,116 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import OpenHands from "#/api/open-hands";
describe("AccountSettingsModal", () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
beforeEach(() => {
vi.resetAllMocks();
});
it("should send all settings data when saving account settings", async () => {
const user = userEvent.setup();
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const languageInput = screen.getByLabelText(/language/i);
await user.click(languageInput);
const norskOption = screen.getByText(/norsk/i);
await user.click(norskOption);
const tokenInput = screen.getByTestId("github-token-input");
await user.type(tokenInput, "new-token");
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "no",
github_token: "new-token",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
});
});
it("should render a checkmark and not the input if the github token is set", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
await waitFor(() => {
const checkmark = screen.queryByTestId("github-token-set-checkmark");
const input = screen.queryByTestId("github-token-input");
expect(checkmark).toBeInTheDocument();
expect(input).not.toBeInTheDocument();
});
});
it("should send an unset github token property when pressing disconnect", async () => {
const user = userEvent.setup();
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const disconnectButton = await screen.findByTestId("disconnect-github");
await user.click(disconnectButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
unset_github_token: true,
});
});
it("should not unset the github token when changing the language", async () => {
const user = userEvent.setup();
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const languageInput = screen.getByLabelText(/language/i);
await user.click(languageInput);
const norskOption = screen.getByText(/norsk/i);
await user.click(norskOption);
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "no",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
});
});
});

View File

@@ -105,7 +105,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -1594,7 +1593,6 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
@@ -1612,7 +1610,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -1625,7 +1622,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -3408,7 +3404,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -3422,7 +3417,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -3432,7 +3426,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -3533,7 +3526,6 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -5764,7 +5756,7 @@
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true,
"devOptional": true,
"dependencies": {
"undici-types": "~6.20.0"
}
@@ -5773,7 +5765,6 @@
"version": "19.0.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz",
"integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==",
"dev": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -6462,7 +6453,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6472,7 +6462,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -6488,14 +6477,12 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -6509,7 +6496,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -6835,7 +6821,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/basic-auth": {
@@ -6860,7 +6845,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6909,7 +6893,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -6919,7 +6902,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -7070,7 +7052,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -7646,7 +7627,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -7661,7 +7641,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -7684,7 +7663,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -7951,7 +7929,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dir-glob": {
@@ -7971,7 +7948,6 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@@ -8036,7 +8012,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/ee-first": {
@@ -8055,7 +8030,6 @@
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -9212,7 +9186,6 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -9229,7 +9202,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -9256,7 +9228,6 @@
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
"integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -9298,7 +9269,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -9418,7 +9388,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.0",
@@ -9689,7 +9658,6 @@
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -9710,7 +9678,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -10462,7 +10429,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -10505,7 +10471,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -10573,7 +10538,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10628,7 +10592,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -10681,7 +10644,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -10910,7 +10872,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -10989,7 +10950,6 @@
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
@@ -11005,7 +10965,6 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -11220,7 +11179,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -11233,7 +11191,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/lint-staged": {
@@ -12008,7 +11965,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -12589,7 +12545,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -12680,7 +12635,6 @@
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -12706,7 +12660,6 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -12866,7 +12819,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -12987,7 +12939,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13098,7 +13049,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13108,7 +13058,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -13350,7 +13299,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
@@ -13469,7 +13417,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -13479,14 +13426,12 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
@@ -13503,7 +13448,6 @@
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/path-to-regexp": {
@@ -13559,7 +13503,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -13585,7 +13528,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13595,7 +13537,6 @@
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -13674,7 +13615,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -13692,7 +13632,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"camelcase-css": "^2.0.1"
@@ -13712,7 +13651,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -13748,7 +13686,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -13774,7 +13711,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -13802,7 +13738,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/posthog-js": {
@@ -14064,7 +13999,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -14313,7 +14247,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -14642,7 +14575,6 @@
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.0",
@@ -14716,7 +14648,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -14841,7 +14772,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -15124,7 +15054,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -15137,7 +15066,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -15226,7 +15154,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -15546,7 +15473,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
@@ -15565,7 +15491,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -15580,14 +15505,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -15597,7 +15520,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -15610,7 +15532,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -15753,7 +15674,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -15767,7 +15687,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -15838,7 +15757,6 @@
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -15861,7 +15779,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -15884,7 +15801,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -15962,7 +15878,6 @@
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -16000,7 +15915,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -16025,7 +15939,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -16038,7 +15951,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -16052,7 +15964,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -16086,7 +15997,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -16096,7 +16006,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -16189,7 +16098,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -16278,7 +16186,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tsconfck": {
@@ -16460,7 +16367,7 @@
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16501,7 +16408,7 @@
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unified": {
@@ -16718,7 +16625,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/utils-merge": {
@@ -17256,7 +17162,6 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
@@ -17275,7 +17180,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -17293,14 +17197,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -17310,7 +17212,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -17325,7 +17226,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -17338,7 +17238,6 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -17351,7 +17250,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -17445,7 +17343,6 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz",
"integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

View File

@@ -1,107 +0,0 @@
import axios, { AxiosError } from "axios";
const github = axios.create({
baseURL: "https://api.github.com",
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
const setAuthTokenHeader = (token: string) => {
github.defaults.headers.common.Authorization = `Bearer ${token}`;
};
const removeAuthTokenHeader = () => {
if (github.defaults.headers.common.Authorization) {
delete github.defaults.headers.common.Authorization;
}
};
/**
* Checks if response has attributes to perform refresh
*/
const canRefresh = (error: unknown): boolean =>
!!(
error instanceof AxiosError &&
error.config &&
error.response &&
error.response.status
);
/**
* Checks if the data is a GitHub error response
* @param data The data to check
* @returns Boolean indicating if the data is a GitHub error response
*/
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
data: T | GitHubErrorReponse | null,
): data is GitHubErrorReponse =>
!!data && "message" in data && data.message !== undefined;
// Axios interceptor to handle token refresh
const setupAxiosInterceptors = (
appMode: string,
refreshToken: () => Promise<boolean>,
logout: () => void,
) => {
github.interceptors.response.use(
// Pass successful responses through
(response) => {
const parsedData = response.data;
if (isGitHubErrorReponse(parsedData)) {
const error = new AxiosError(
"Failed",
"",
response.config,
response.request,
response,
);
throw error;
}
return response;
},
// Retry request exactly once if token is expired
async (error) => {
if (!canRefresh(error)) {
return Promise.reject(new Error("Failed to refresh token"));
}
const originalRequest = error.config;
// Check if the error is due to an expired token
if (
error.response.status === 401 &&
!originalRequest._retry // Prevent infinite retry loops
) {
originalRequest._retry = true;
if (appMode === "saas") {
try {
const refreshed = await refreshToken();
if (refreshed) {
return await github(originalRequest);
}
logout();
return await Promise.reject(new Error("Failed to refresh token"));
} catch (refreshError) {
// If token refresh fails, evict the user
logout();
return Promise.reject(refreshError);
}
}
}
// If the error is not due to an expired token, propagate the error
return Promise.reject(error);
},
);
};
export {
github,
setAuthTokenHeader,
removeAuthTokenHeader,
setupAxiosInterceptors,
};

View File

@@ -242,13 +242,11 @@ class OpenHands {
}
static async createConversation(
githubToken?: string,
selectedRepository?: string,
initialUserMsg?: string,
imageUrls?: string[],
): Promise<Conversation> {
const body = {
github_token: githubToken,
selected_repository: selectedRepository,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
@@ -368,6 +366,10 @@ class OpenHands {
);
return data;
}
static async logout(): Promise<void> {
await openHands.post("/api/logout");
}
}
export default OpenHands;

View File

@@ -2,9 +2,9 @@ import posthog from "posthog-js";
import React from "react";
import { useSelector } from "react-redux";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import { useAuth } from "#/context/auth-context";
import { DownloadModal } from "#/components/shared/download-modal";
import type { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
@@ -13,7 +13,7 @@ interface ActionSuggestionsProps {
export function ActionSuggestions({
onSuggestionsClick,
}: ActionSuggestionsProps) {
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery,
);
@@ -32,7 +32,7 @@ export function ActionSuggestions({
onClose={handleDownloadClose}
isOpen={isDownloading}
/>
{gitHubToken && selectedRepository ? (
{githubTokenIsSet && selectedRepository ? (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>

View File

@@ -5,14 +5,12 @@ import { SuggestionBox } from "#/components/features/suggestions/suggestion-box"
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { GitHubRepositorySelector } from "./github-repo-selector";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useDebounce } from "#/hooks/use-debounce";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
@@ -51,7 +49,7 @@ export function GitHubRepositoriesSuggestionBox({
}
};
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
const isLoggedIn = !!user;
return (
<>
@@ -76,11 +74,9 @@ export function GitHubRepositoriesSuggestionBox({
}
/>
{connectToGitHubModalOpen && (
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
<ConnectToGitHubModal
onClose={() => setConnectToGitHubModalOpen(false)}
/>
</ModalBackdrop>
<AccountSettingsModal
onClose={() => setConnectToGitHubModalOpen(false)}
/>
)}
</>
);

View File

@@ -1,9 +1,8 @@
import React from "react";
import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import { useAuth } from "#/context/auth-context";
import posthog from "posthog-js";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
import { DocsButton } from "#/components/shared/buttons/docs-button";
@@ -21,20 +20,19 @@ import { setCurrentAgentState } from "#/state/agent-slice";
import { AgentState } from "#/types/agent-state";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
import { useLogout } from "#/hooks/mutation/use-logout";
import { useConfig } from "#/hooks/query/use-config";
export function Sidebar() {
const dispatch = useDispatch();
const endSession = useEndSession();
const user = useGitHubUser();
const { data: isAuthed } = useIsAuthed();
const { logout } = useAuth();
const {
data: settings,
isError: settingsIsError,
isSuccess: settingsSuccessfulyFetched,
} = useSettings();
const { data: config } = useConfig();
const { data: settings, isError: settingsError } = useSettings();
const { mutateAsync: logout } = useLogout();
const { isUpToDate: settingsAreUpToDate } = useCurrentSettings();
const { saveUserSettings, isUpToDate: settingsAreUpToDate } =
useCurrentSettings();
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
@@ -56,15 +54,16 @@ export function Sidebar() {
};
const handleAccountSettingsModalClose = () => {
// If the user closes the modal without connecting to GitHub,
// we need to log them out to clear the invalid token from the
// local storage
if (user.isError) logout();
setAccountSettingsModalOpen(false);
};
const showSettingsModal =
isAuthed && (!settingsAreUpToDate || settingsModalIsOpen);
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else await saveUserSettings({ unset_github_token: true });
posthog.reset();
};
const showSettingsModal = !settingsAreUpToDate || settingsModalIsOpen;
return (
<>
@@ -92,7 +91,7 @@ export function Sidebar() {
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined
}
onLogout={logout}
onLogout={handleLogout}
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
/>
)}
@@ -110,13 +109,12 @@ export function Sidebar() {
{accountSettingsModalOpen && (
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
)}
{settingsIsError ||
(showSettingsModal && settingsSuccessfulyFetched && (
<SettingsModal
settings={settings}
onClose={() => setSettingsModalIsOpen(false)}
/>
))}
{(settingsError || showSettingsModal) && (
<SettingsModal
settings={settings}
onClose={() => setSettingsModalIsOpen(false)}
/>
)}
</>
);
}

View File

@@ -10,11 +10,14 @@ import { TOSCheckbox } from "./tos-checkbox";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
interface WaitlistModalProps {
ghToken: string | null;
ghTokenIsSet: boolean;
githubAuthUrl: string | null;
}
export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
export function WaitlistModal({
ghTokenIsSet,
githubAuthUrl,
}: WaitlistModalProps) {
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
const handleGitHubAuth = () => {
@@ -28,11 +31,11 @@ export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
<ModalBackdrop>
<ModalBody>
<AllHandsLogo width={68} height={46} />
<WaitlistMessage content={ghToken ? "waitlist" : "sign-in"} />
<WaitlistMessage content={ghTokenIsSet ? "waitlist" : "sign-in"} />
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
{!ghToken && (
{!ghTokenIsSet && (
<ModalButton
disabled={!isTosAccepted}
text="Connect to GitHub"
@@ -41,7 +44,7 @@ export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
onClick={handleGitHubAuth}
/>
)}
{ghToken && <JoinWaitlistAnchor />}
{ghTokenIsSet && <JoinWaitlistAnchor />}
</ModalBody>
</ModalBackdrop>
);

View File

@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import {
BaseModalDescription,
BaseModalTitle,
@@ -7,13 +8,13 @@ import {
import { ModalBody } from "../modal-body";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { useAuth } from "#/context/auth-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { ModalButton } from "../../buttons/modal-button";
import { CustomInput } from "../../custom-input";
import { FormFieldset } from "../../form-fieldset";
import { useConfig } from "#/hooks/query/use-config";
import { useCurrentSettings } from "#/context/settings-context";
import { PostSettings } from "#/services/settings";
import { GitHubTokenInput } from "./github-token-input";
interface AccountSettingsFormProps {
onClose: () => void;
@@ -28,11 +29,12 @@ export function AccountSettingsForm({
gitHubError,
analyticsConsent,
}: AccountSettingsFormProps) {
const { gitHubToken, setGitHubToken, logout } = useAuth();
const { data: config } = useConfig();
const { saveUserSettings } = useCurrentSettings();
const { saveUserSettings, settings } = useCurrentSettings();
const { t } = useTranslation();
const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
@@ -41,7 +43,9 @@ export function AccountSettingsForm({
const language = formData.get("language")?.toString();
const analytics = formData.get("analytics")?.toString() === "on";
if (ghToken) setGitHubToken(ghToken);
const newSettings: Partial<PostSettings> = {};
if (ghToken) newSettings.github_token = ghToken;
// The form returns the language label, so we need to find the corresponding
// language key to save it in the settings
@@ -50,9 +54,11 @@ export function AccountSettingsForm({
({ label }) => label === language,
)?.value;
if (languageKey) await saveUserSettings({ LANGUAGE: languageKey });
if (languageKey) newSettings.LANGUAGE = languageKey;
}
await saveUserSettings(newSettings);
handleCaptureConsent(analytics);
const ANALYTICS = analytics.toString();
localStorage.setItem("analytics-consent", ANALYTICS);
@@ -60,6 +66,12 @@ export function AccountSettingsForm({
onClose();
};
const onDisconnect = async () => {
await saveUserSettings({ unset_github_token: true });
posthog.reset();
onClose();
};
return (
<ModalBody testID="account-settings-form">
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
@@ -89,23 +101,20 @@ export function AccountSettingsForm({
{config?.APP_MODE !== "saas" && (
<>
<CustomInput
name="ghToken"
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
defaultValue={gitHubToken ?? ""}
/>
<BaseModalDescription>
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
{t(I18nKey.COMMON$HERE)}
</a>
</BaseModalDescription>
<GitHubTokenInput githubTokenIsSet={githubTokenIsSet} />
{!githubTokenIsSet && (
<BaseModalDescription>
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
{t(I18nKey.COMMON$HERE)}
</a>
</BaseModalDescription>
)}
</>
)}
{gitHubError && (
@@ -113,14 +122,12 @@ export function AccountSettingsForm({
{t(I18nKey.GITHUB$TOKEN_INVALID)}
</p>
)}
{gitHubToken && !gitHubError && (
{githubTokenIsSet && !gitHubError && (
<ModalButton
testId="disconnect-github"
variant="text-like"
text={t(I18nKey.BUTTON$DISCONNECT)}
onClick={() => {
logout();
onClose();
}}
onClick={onDisconnect}
className="text-danger self-start"
/>
)}

View File

@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { FaCheckCircle } from "react-icons/fa";
import { I18nKey } from "#/i18n/declaration";
interface GitHubTokenInputProps {
githubTokenIsSet: boolean;
}
export function GitHubTokenInput({ githubTokenIsSet }: GitHubTokenInputProps) {
const { t } = useTranslation();
return (
<label htmlFor="ghToken" className="flex flex-col gap-2">
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3] flex items-center gap-1">
{githubTokenIsSet && (
<FaCheckCircle
data-testid="github-token-set-checkmark"
size={12}
className="text-[#00D1B2]"
/>
)}
{t(I18nKey.GITHUB$TOKEN_LABEL)}
<span className="text-[#A3A3A3]">
{" "}
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
</span>
</span>
{!githubTokenIsSet && (
<input
data-testid="github-token-input"
id="ghToken"
name="ghToken"
type="password"
className="bg-[#27272A] text-xs py-[10px] px-3 rounded"
/>
)}
</label>
);
}

View File

@@ -1,74 +0,0 @@
import { useTranslation } from "react-i18next";
import { ModalBody } from "./modal-body";
import {
BaseModalDescription,
BaseModalTitle,
} from "./confirmation-modals/base-modal";
import { I18nKey } from "#/i18n/declaration";
import { useAuth } from "#/context/auth-context";
import { ModalButton } from "../buttons/modal-button";
import { CustomInput } from "../custom-input";
interface ConnectToGitHubModalProps {
onClose: () => void;
}
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
const { gitHubToken, setGitHubToken } = useAuth();
const { t } = useTranslation();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const ghToken = formData.get("ghToken")?.toString();
if (ghToken) setGitHubToken(ghToken);
onClose();
};
return (
<ModalBody>
<div className="flex flex-col gap-2 self-start">
<BaseModalTitle title="Connect to GitHub" />
<BaseModalDescription
description={
<span>
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
</a>
</span>
}
/>
</div>
<form onSubmit={handleSubmit} className="w-full flex flex-col gap-6">
<CustomInput
label="GitHub Token"
name="ghToken"
required
type="password"
defaultValue={gitHubToken ?? ""}
/>
<div className="flex flex-col gap-2 w-full">
<ModalButton
testId="connect-to-github"
type="submit"
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)}
className="bg-[#791B80] w-full"
/>
<ModalButton
onClick={onClose}
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CLOSE)}
className="bg-[#737373] w-full"
/>
</div>
</form>
</ModalBody>
);
}

View File

@@ -1,110 +1,21 @@
import posthog from "posthog-js";
import React from "react";
import OpenHands from "#/api/open-hands";
import {
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
} from "#/api/open-hands-axios";
import {
setAuthTokenHeader as setGitHubAuthTokenHeader,
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
setupAxiosInterceptors as setupGithubAxiosInterceptors,
} from "#/api/github-axios-instance";
interface AuthContextType {
gitHubToken: string | null;
setUserId: (userId: string) => void;
setGitHubToken: (token: string | null) => void;
clearGitHubToken: () => void;
refreshToken: () => Promise<boolean>;
logout: () => void;
githubTokenIsSet: boolean;
setGitHubTokenIsSet: (value: boolean) => void;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({ children }: React.PropsWithChildren) {
const [gitHubTokenState, setGitHubTokenState] = React.useState<string | null>(
() => localStorage.getItem("ghToken"),
);
const [userIdState, setUserIdState] = React.useState<string>(
() => localStorage.getItem("userId") || "",
);
const clearGitHubToken = () => {
setGitHubTokenState(null);
setUserIdState("");
localStorage.removeItem("ghToken");
localStorage.removeItem("userId");
removeOpenHandsGitHubTokenHeader();
removeGitHubAuthTokenHeader();
};
const setGitHubToken = (token: string | null) => {
setGitHubTokenState(token);
if (token) {
localStorage.setItem("ghToken", token);
setOpenHandsGitHubTokenHeader(token);
setGitHubAuthTokenHeader(token);
} else {
clearGitHubToken();
}
};
const setUserId = (userId: string) => {
setUserIdState(userIdState);
localStorage.setItem("userId", userId);
};
const logout = () => {
clearGitHubToken();
posthog.reset();
};
const refreshToken = async (): Promise<boolean> => {
const config = await OpenHands.getConfig();
if (config.APP_MODE !== "saas" || !gitHubTokenState) {
return false;
}
const newToken = await OpenHands.refreshToken(config.APP_MODE, userIdState);
if (newToken) {
setGitHubToken(newToken);
return true;
}
clearGitHubToken();
return false;
};
React.useEffect(() => {
const storedGitHubToken = localStorage.getItem("ghToken");
const userId = localStorage.getItem("userId") || "";
setGitHubToken(storedGitHubToken);
setUserId(userId);
const setupIntercepter = async () => {
const config = await OpenHands.getConfig();
setupGithubAxiosInterceptors(config.APP_MODE, refreshToken, logout);
};
setupIntercepter();
}, []);
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(false);
const value = React.useMemo(
() => ({
gitHubToken: gitHubTokenState,
setGitHubToken,
setUserId,
clearGitHubToken,
refreshToken,
logout,
githubTokenIsSet,
setGitHubTokenIsSet,
}),
[gitHubTokenState],
[githubTokenIsSet, setGitHubTokenIsSet],
);
return <AuthContext value={value}>{children}</AuthContext>;

View File

@@ -1,6 +1,7 @@
import React from "react";
import {
LATEST_SETTINGS_VERSION,
PostSettings,
Settings,
settingsAreUpToDate,
} from "#/services/settings";
@@ -10,7 +11,7 @@ import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
interface SettingsContextType {
isUpToDate: boolean;
setIsUpToDate: (value: boolean) => void;
saveUserSettings: (newSettings: Partial<Settings>) => Promise<void>;
saveUserSettings: (newSettings: Partial<PostSettings>) => Promise<void>;
settings: Settings | undefined;
}
@@ -28,8 +29,8 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
const saveUserSettings = async (newSettings: Partial<Settings>) => {
const updatedSettings: Partial<Settings> = {
const saveUserSettings = async (newSettings: Partial<PostSettings>) => {
const updatedSettings: Partial<PostSettings> = {
...userSettings,
...newSettings,
};

View File

@@ -5,12 +5,10 @@ import { useDispatch, useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
export const useCreateConversation = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const { gitHubToken } = useAuth();
const queryClient = useQueryClient();
const { selectedRepository, files, importedProjectZip } = useSelector(
@@ -31,7 +29,6 @@ export const useCreateConversation = () => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
gitHubToken || undefined,
selectedRepository || undefined,
variables.q,
files,

View File

@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
export const useLogout = () => {
const { setGitHubTokenIsSet } = useAuth();
const queryClient = useQueryClient();
return useMutation({
mutationFn: OpenHands.logout,
onSuccess: async () => {
setGitHubTokenIsSet(false);
await queryClient.invalidateQueries();
},
});
};

View File

@@ -1,9 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiSettings, DEFAULT_SETTINGS, Settings } from "#/services/settings";
import {
DEFAULT_SETTINGS,
PostApiSettings,
PostSettings,
} from "#/services/settings";
import OpenHands from "#/api/open-hands";
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
const apiSettings: Partial<ApiSettings> = {
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
const apiSettings: Partial<PostApiSettings> = {
llm_model: settings.LLM_MODEL,
llm_base_url: settings.LLM_BASE_URL,
agent: settings.AGENT || DEFAULT_SETTINGS.AGENT,
@@ -11,6 +15,9 @@ const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
confirmation_mode: settings.CONFIRMATION_MODE,
security_analyzer: settings.SECURITY_ANALYZER,
llm_api_key: settings.LLM_API_KEY?.trim() || undefined,
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
github_token: settings.github_token,
unset_github_token: settings.unset_github_token,
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
};

View File

@@ -1,17 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
export const useAppInstallations = () => {
const { data: config } = useConfig();
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
return useQuery({
queryKey: ["installations", gitHubToken, config?.GITHUB_CLIENT_ID],
queryKey: ["installations", githubTokenIsSet, config?.GITHUB_CLIENT_ID],
queryFn: OpenHands.getGitHubUserInstallationIds,
enabled:
!!gitHubToken &&
githubTokenIsSet &&
!!config?.GITHUB_CLIENT_ID &&
config?.APP_MODE === "saas",
});

View File

@@ -1,17 +1,17 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveGitHubAppRepositories } from "#/api/github";
import { useAuth } from "#/context/auth-context";
import { useAppInstallations } from "./use-app-installations";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useAppRepositories = () => {
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const { data: installations } = useAppInstallations();
const repos = useInfiniteQuery({
queryKey: ["repositories", gitHubToken, installations],
queryKey: ["repositories", githubTokenIsSet, installations],
queryFn: async ({
pageParam,
}: {
@@ -46,7 +46,7 @@ export const useAppRepositories = () => {
return null;
},
enabled:
!!gitHubToken &&
githubTokenIsSet &&
Array.isArray(installations) &&
installations.length > 0 &&
config?.APP_MODE === "saas",

View File

@@ -1,24 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useLogout } from "../mutation/use-logout";
import { useCurrentSettings } from "#/context/settings-context";
export const useGitHubUser = () => {
const { gitHubToken, setUserId, logout } = useAuth();
const { githubTokenIsSet } = useAuth();
const { setGitHubTokenIsSet } = useAuth();
const { mutateAsync: logout } = useLogout();
const { saveUserSettings } = useCurrentSettings();
const { data: config } = useConfig();
const user = useQuery({
queryKey: ["user", gitHubToken],
queryKey: ["user", githubTokenIsSet],
queryFn: OpenHands.getGitHubUser,
enabled: !!gitHubToken && !!config?.APP_MODE,
enabled: githubTokenIsSet && !!config?.APP_MODE,
retry: false,
});
React.useEffect(() => {
if (user.data) {
setUserId(user.data.id.toString());
posthog.identify(user.data.login, {
company: user.data.company,
name: user.data.name,
@@ -29,9 +33,18 @@ export const useGitHubUser = () => {
}
}, [user.data]);
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else {
await saveUserSettings({ unset_github_token: true });
setGitHubTokenIsSet(false);
}
posthog.reset();
};
React.useEffect(() => {
if (user.isError) {
logout();
handleLogout();
}
}, [user.isError]);

View File

@@ -5,13 +5,13 @@ import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useIsAuthed = () => {
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
return useQuery({
queryKey: ["user", "authenticated", gitHubToken, appMode],
queryKey: ["user", "authenticated", githubTokenIsSet, appMode],
queryFn: () => OpenHands.authenticate(appMode!),
enabled: !!appMode,
staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -4,6 +4,7 @@ import posthog from "posthog-js";
import { AxiosError } from "axios";
import { DEFAULT_SETTINGS, getLocalStorageSettings } from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
const getSettingsQueryFn = async () => {
try {
@@ -20,6 +21,7 @@ const getSettingsQueryFn = async () => {
LLM_API_KEY: apiSettings.llm_api_key,
REMOTE_RUNTIME_RESOURCE_FACTOR:
apiSettings.remote_runtime_resource_factor,
GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set,
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
};
}
@@ -37,9 +39,14 @@ const getSettingsQueryFn = async () => {
};
export const useSettings = () => {
const { setGitHubTokenIsSet } = useAuth();
const query = useQuery({
queryKey: ["settings"],
queryFn: getSettingsQueryFn,
initialData: DEFAULT_SETTINGS,
staleTime: 0,
retry: false,
});
React.useEffect(() => {
@@ -48,5 +55,9 @@ export const useSettings = () => {
}
}, [query.data?.LLM_API_KEY]);
React.useEffect(() => {
setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET);
}, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]);
return query;
};

View File

@@ -1,20 +1,20 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveGitHubUserRepositories } from "#/api/github";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useUserRepositories = () => {
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const repos = useInfiniteQuery({
queryKey: ["repositories", gitHubToken],
queryKey: ["repositories", githubTokenIsSet],
queryFn: async ({ pageParam }) =>
retrieveGitHubUserRepositories(pageParam, 100),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled: !!gitHubToken && config?.APP_MODE === "oss",
enabled: githubTokenIsSet && config?.APP_MODE === "oss",
});
// TODO: Once we create our custom dropdown component, we should fetch data onEndReached

View File

@@ -1,20 +1,23 @@
import React from "react";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types";
import { useAuth } from "#/context/auth-context";
interface UseGitHubAuthUrlConfig {
gitHubToken: string | null;
appMode: GetConfigResponse["APP_MODE"] | null;
gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null;
}
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) =>
React.useMemo(() => {
if (config.appMode === "saas" && !config.gitHubToken)
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => {
const { githubTokenIsSet } = useAuth();
return React.useMemo(() => {
if (config.appMode === "saas" && !githubTokenIsSet)
return generateGitHubAuthUrl(
config.gitHubClientId || "",
new URL(window.location.href),
);
return null;
}, [config.gitHubToken, config.appMode, config.gitHubClientId]);
}, [githubTokenIsSet, config.appMode, config.gitHubClientId]);
};

View File

@@ -4,18 +4,30 @@ import {
Conversation,
ResultSet,
} from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import {
ApiSettings,
DEFAULT_SETTINGS,
PostApiSettings,
} from "#/services/settings";
export const MOCK_USER_PREFERENCES = {
settings: {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
agent: DEFAULT_SETTINGS.AGENT,
language: DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
},
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
agent: DEFAULT_SETTINGS.AGENT,
language: DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
github_token_is_set: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET,
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
};
const MOCK_USER_PREFERENCES: {
settings: ApiSettings | PostApiSettings;
} = {
settings: MOCK_DEFAULT_USER_SETTINGS,
};
const conversations: Conversation[] = [
@@ -168,17 +180,32 @@ export const handlers = [
return HttpResponse.json(config);
}),
http.get("/api/settings", async () =>
HttpResponse.json(MOCK_USER_PREFERENCES.settings),
),
http.get("/api/settings", async () => {
const settings: ApiSettings = {
...MOCK_USER_PREFERENCES.settings,
};
// @ts-expect-error - mock types
if (settings.github_token) settings.github_token_is_set = true;
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
const body = await request.json();
if (body) {
let newSettings: Partial<PostApiSettings> = {};
if (typeof body === "object") {
newSettings = { ...body };
if (newSettings.unset_github_token) {
newSettings.github_token = undefined;
newSettings.github_token_is_set = false;
delete newSettings.unset_github_token;
}
}
MOCK_USER_PREFERENCES.settings = {
...MOCK_USER_PREFERENCES.settings,
// @ts-expect-error - We know this is a settings object
...body,
...newSettings,
};
return HttpResponse.json(null, { status: 200 });

View File

@@ -8,7 +8,6 @@ import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useConfig } from "#/hooks/query/use-config";
import { useAuth } from "#/context/auth-context";
import { ImportProjectSuggestionBox } from "../../components/features/suggestions/import-project-suggestion-box";
import { GitHubRepositoriesSuggestionBox } from "#/components/features/github/github-repositories-suggestion-box";
import { HeroHeading } from "#/components/shared/hero-heading";
@@ -16,7 +15,6 @@ import { TaskForm } from "#/components/shared/task-form";
function Home() {
const { t } = useTranslation();
const { gitHubToken } = useAuth();
const dispatch = useDispatch();
const formRef = React.useRef<HTMLFormElement>(null);
@@ -24,7 +22,6 @@ function Home() {
const { data: user } = useGitHubUser();
const gitHubAuthUrl = useGitHubAuthUrl({
gitHubToken,
appMode: config?.APP_MODE || null,
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
});

View File

@@ -22,7 +22,6 @@ import { FilesProvider } from "#/context/files";
import { ChatInterface } from "../../components/features/chat/chat-interface";
import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "./event-handler";
import { useAuth } from "#/context/auth-context";
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
import { Container } from "#/components/layout/container";
import {
@@ -42,7 +41,6 @@ import { RootState } from "#/store";
function AppContent() {
useConversationConfig();
const { t } = useTranslation();
const { gitHubToken } = useAuth();
const { data: settings } = useSettings();
const { conversationId } = useConversation();
const { data: conversation, isFetched } = useUserConversation(
@@ -57,8 +55,9 @@ function AppContent() {
const [width, setWidth] = React.useState(window.innerWidth);
const secrets = React.useMemo(
() => [gitHubToken].filter((secret) => secret !== null),
[gitHubToken],
// secrets to filter go here
() => [].filter((secret) => secret !== null),
[],
);
const Terminal = React.useMemo(

View File

@@ -3,13 +3,13 @@ import { useRouteError, isRouteErrorResponse, Outlet } from "react-router";
import i18n from "#/i18n";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "#/hooks/query/use-config";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import { useSettings } from "#/hooks/query/use-settings";
import { useMaybeMigrateSettings } from "#/hooks/use-maybe-migrate-settings";
import { useAuth } from "#/context/auth-context";
export function ErrorBoundary() {
const error = useRouteError();
@@ -46,7 +46,7 @@ export function ErrorBoundary() {
export default function MainApp() {
useMaybeMigrateSettings();
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: settings } = useSettings();
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
@@ -54,10 +54,13 @@ export default function MainApp() {
);
const config = useConfig();
const { data: isAuthed, isFetching: isFetchingAuth } = useIsAuthed();
const {
data: isAuthed,
isFetching: isFetchingAuth,
isError: authError,
} = useIsAuthed();
const gitHubAuthUrl = useGitHubAuthUrl({
gitHubToken,
appMode: config.data?.APP_MODE || null,
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
});
@@ -68,8 +71,9 @@ export default function MainApp() {
}
}, [settings?.LANGUAGE]);
const isInWaitlist =
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
const userIsAuthed = !!isAuthed && !authError;
const renderWaitlistModal =
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
return (
<div
@@ -85,8 +89,11 @@ export default function MainApp() {
<Outlet />
</div>
{isInWaitlist && (
<WaitlistModal ghToken={gitHubToken} githubAuthUrl={gitHubAuthUrl} />
{renderWaitlistModal && (
<WaitlistModal
ghTokenIsSet={githubTokenIsSet}
githubAuthUrl={gitHubAuthUrl}
/>
)}
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (

View File

@@ -2,16 +2,13 @@ import { useNavigate, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
function OAuthGitHubCallback() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setGitHubToken } = useAuth();
const code = searchParams.get("code");
const { data, isSuccess, error } = useQuery({
const { isSuccess, error } = useQuery({
queryKey: ["access_token", code],
queryFn: () => OpenHands.getGitHubAccessToken(code!),
enabled: !!code,
@@ -19,7 +16,6 @@ function OAuthGitHubCallback() {
React.useEffect(() => {
if (isSuccess) {
setGitHubToken(data.access_token);
navigate("/");
}
}, [isSuccess]);

View File

@@ -9,6 +9,7 @@ export type Settings = {
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
GITHUB_TOKEN_IS_SET: boolean;
ENABLE_DEFAULT_CONDENSER: boolean;
};
@@ -21,9 +22,20 @@ export type ApiSettings = {
confirmation_mode: boolean;
security_analyzer: string;
remote_runtime_resource_factor: number;
github_token_is_set: boolean;
enable_default_condenser: boolean;
};
export type PostSettings = Settings & {
github_token: string;
unset_github_token: boolean;
};
export type PostApiSettings = ApiSettings & {
github_token: string;
unset_github_token: boolean;
};
export const DEFAULT_SETTINGS: Settings = {
LLM_MODEL: "anthropic/claude-3-5-sonnet-20241022",
LLM_BASE_URL: "",
@@ -33,6 +45,7 @@ export const DEFAULT_SETTINGS: Settings = {
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
GITHUB_TOKEN_IS_SET: false,
ENABLE_DEFAULT_CONDENSER: false,
};
@@ -76,6 +89,7 @@ export const getLocalStorageSettings = (): Settings => {
SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
REMOTE_RUNTIME_RESOURCE_FACTOR:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
GITHUB_TOKEN_IS_SET: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET,
ENABLE_DEFAULT_CONDENSER:
enableDefaultCondenser || DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
};

View File

@@ -57,6 +57,7 @@ class AsyncEventStreamWrapper:
class EventStream:
sid: str
file_store: FileStore
secrets: dict[str, str]
# For each subscriber ID, there is a map of callback functions - useful
# when there are multiple listeners
_subscribers: dict[str, dict[str, Callable]]
@@ -82,6 +83,7 @@ class EventStream:
self._subscribers = {}
self._lock = threading.Lock()
self._cur_id = 0
self.secrets = {}
# load the stream
self.__post_init__()
@@ -267,10 +269,24 @@ class EventStream:
event._timestamp = datetime.now().isoformat()
event._source = source # type: ignore [attr-defined]
data = event_to_dict(event)
data = self._replace_secrets(data)
event = event_from_dict(data)
if event.id is not None:
self.file_store.write(self._get_filename_for_id(event.id), json.dumps(data))
self._queue.put(event)
def set_secrets(self, secrets: dict[str, str]):
self.secrets = secrets.copy()
def _replace_secrets(self, data: dict) -> dict:
for key in data:
if isinstance(data[key], dict):
data[key] = self._replace_secrets(data[key])
elif isinstance(data[key], str):
for secret in self.secrets.values():
data[key] = data[key].replace(secret, '<secret_hidden>')
return data
def _run_queue_loop(self):
self._queue_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._queue_loop)

View File

@@ -1,5 +1,9 @@
from fastapi import Request
def get_github_token(request: Request) -> str | None:
return getattr(request.state, 'github_token', None)
def get_user_id(request: Request) -> str | None:
return getattr(request.state, 'github_user_id', None)

View File

@@ -6,11 +6,13 @@ from openhands.core.logger import openhands_logger as logger
from openhands.server.middleware import (
AttachConversationMiddleware,
CacheControlMiddleware,
GitHubTokenMiddleware,
InMemoryRateLimiter,
LocalhostCORSMiddleware,
RateLimitMiddleware,
)
from openhands.server.types import AppMode, OpenhandsConfigInterface
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
@@ -48,6 +50,8 @@ class OpenhandsConfig(OpenhandsConfigInterface):
return config
def attach_middleware(self, api: FastAPI) -> None:
SettingsStoreImpl = get_impl(SettingsStore, self.settings_store_class) # type: ignore
api.add_middleware(
LocalhostCORSMiddleware,
allow_credentials=True,
@@ -61,6 +65,7 @@ class OpenhandsConfig(OpenhandsConfigInterface):
rate_limiter=InMemoryRateLimiter(requests=10, seconds=1),
)
api.middleware('http')(AttachConversationMiddleware(api))
api.middleware('http')(GitHubTokenMiddleware(api, SettingsStoreImpl)) # type: ignore
def load_openhands_config():

View File

@@ -12,7 +12,9 @@ from starlette.requests import Request as StarletteRequest
from starlette.types import ASGIApp
from openhands.server import shared
from openhands.server.auth import get_user_id
from openhands.server.types import SessionMiddlewareInterface
from openhands.storage.settings.settings_store import SettingsStore
class LocalhostCORSMiddleware(CORSMiddleware):
@@ -180,3 +182,22 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
await self._detach_session(request)
return response
class GitHubTokenMiddleware(SessionMiddlewareInterface):
def __init__(self, app, settings_store: SettingsStore):
self.app = app
self.settings_store_impl = settings_store
async def __call__(self, request: Request, call_next: Callable):
settings_store = await self.settings_store_impl.get_instance(
shared.config, get_user_id(request)
)
settings = await settings_store.load()
if settings and settings.github_token:
request.state.github_token = settings.github_token
else:
request.state.github_token = None
return await call_next(request)

View File

@@ -1,8 +1,9 @@
import httpx
import requests
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from openhands.server.auth import get_github_token
from openhands.server.shared import openhands_config
from openhands.utils.async_utils import call_sync_from_async
@@ -10,12 +11,13 @@ app = APIRouter(prefix='/api/github')
def require_github_token(request: Request):
github_token = request.headers.get('X-GitHub-Token')
github_token = get_github_token(request)
if not github_token:
raise HTTPException(
status_code=400,
detail='Missing X-GitHub-Token header',
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Missing GitHub token',
)
return github_token

View File

@@ -10,7 +10,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.events.action.message import MessageAction
from openhands.events.stream import EventStreamSubscriber
from openhands.runtime import get_runtime_cls
from openhands.server.auth import get_user_id
from openhands.server.auth import get_github_token, get_user_id
from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.shared import config, conversation_manager
@@ -32,7 +32,6 @@ UPDATED_AT_CALLBACK_ID = 'updated_at_callback_id'
class InitSessionRequest(BaseModel):
github_token: str | None = None
selected_repository: str | None = None
initial_user_msg: str | None = None
image_urls: list[str] | None = None
@@ -127,7 +126,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
"""
logger.info('Initializing new conversation')
user_id = get_user_id(request)
github_token = getattr(request.state, 'github_token', '') or data.github_token
github_token = get_github_token(request)
selected_repository = data.selected_repository
initial_user_msg = data.initial_user_msg
image_urls = data.image_urls or []

View File

@@ -3,10 +3,12 @@ from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.server.auth import get_user_id
from openhands.server.settings import Settings
from openhands.server.services.github_service import GitHubService
from openhands.server.settings import Settings, SettingsWithTokenMeta
from openhands.server.shared import config, openhands_config
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.import_utils import get_impl
app = APIRouter(prefix='/api')
@@ -19,7 +21,7 @@ ConversationStoreImpl = get_impl(
@app.get('/settings')
async def load_settings(request: Request) -> Settings | None:
async def load_settings(request: Request) -> SettingsWithTokenMeta | None:
try:
settings_store = await SettingsStoreImpl.get_instance(
config, get_user_id(request)
@@ -30,7 +32,16 @@ async def load_settings(request: Request) -> Settings | None:
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Settings not found'},
)
return settings
github_token = request.state.github_token
settings_with_token_data = SettingsWithTokenMeta(
**settings.model_dump(),
github_token_is_set=bool(github_token),
)
settings_with_token_data.llm_api_key = settings.llm_api_key
del settings_with_token_data.github_token
return settings_with_token_data
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(
@@ -42,8 +53,22 @@ async def load_settings(request: Request) -> Settings | None:
@app.post('/settings')
async def store_settings(
request: Request,
settings: Settings,
settings: SettingsWithTokenMeta,
) -> JSONResponse:
# Check if token is valid
if settings.github_token:
try:
# We check if the token is valid by getting the user
# If the token is invalid, this will raise an exception
github = GitHubService(settings.github_token)
await call_sync_from_async(github.get_user)
except Exception as e:
logger.warning(f'Invalid GitHub token: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid GitHub token'},
)
try:
settings_store = await SettingsStoreImpl.get_instance(
config, get_user_id(request)
@@ -55,21 +80,46 @@ async def store_settings(
if settings.llm_api_key is None:
settings.llm_api_key = existing_settings.llm_api_key
if settings.github_token is None:
settings.github_token = existing_settings.github_token
response = JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
if settings.unset_github_token:
settings.github_token = None
# Update sandbox config with new settings
if settings.remote_runtime_resource_factor is not None:
config.sandbox.remote_runtime_resource_factor = (
settings.remote_runtime_resource_factor
)
await settings_store.store(settings)
settings = convert_to_settings(settings)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
await settings_store.store(settings)
return response
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
def convert_to_settings(settings_with_token_data: SettingsWithTokenMeta) -> Settings:
settings_data = settings_with_token_data.model_dump()
# Filter out additional fields from `SettingsWithTokenData`
filtered_settings_data = {
key: value
for key, value in settings_data.items()
if key in Settings.model_fields # Ensures only `Settings` fields are included
}
# Convert the `llm_api_key` to a `SecretStr` instance
filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key
return Settings(**filtered_settings_data)

View File

@@ -0,0 +1,16 @@
import requests
class GitHubService:
def __init__(self, token: str):
self.token = token
self.headers = {
'Authorization': f'Bearer {token}',
'Accept': 'application/vnd.github.v3+json',
}
def get_user(self):
response = requests.get('https://api.github.com/user', headers=self.headers)
response.raise_for_status()
return response.json()

View File

@@ -110,6 +110,12 @@ class AgentSession:
agent_to_llm_config=agent_to_llm_config,
agent_configs=agent_configs,
)
if github_token:
self.event_stream.set_secrets(
{
'github_token': github_token,
}
)
if initial_message:
self.event_stream.add_event(initial_message, EventSource.USER)
self.event_stream.add_event(

View File

@@ -21,6 +21,7 @@ class Settings(BaseModel):
llm_api_key: SecretStr | None = None
llm_base_url: str | None = None
remote_runtime_resource_factor: int | None = None
github_token: str | None = None
enable_default_condenser: bool = False
@field_serializer('llm_api_key')
@@ -53,5 +54,15 @@ class Settings(BaseModel):
llm_api_key=llm_config.api_key,
llm_base_url=llm_config.base_url,
remote_runtime_resource_factor=app_config.sandbox.remote_runtime_resource_factor,
github_token=None,
)
return settings
class SettingsWithTokenMeta(Settings):
"""
Settings with additional token data for the frontend
"""
github_token_is_set: bool | None = None
unset_github_token: bool | None = None

View File

@@ -38,6 +38,12 @@ def mock_settings_store():
yield store_instance
@pytest.fixture
def mock_github_service():
with patch('openhands.server.routes.settings.GitHubService') as mock:
yield mock
@pytest.mark.asyncio
async def test_settings_api_runtime_factor(test_client, mock_settings_store):
# Mock the settings store to return None initially (no existing settings)
@@ -117,3 +123,79 @@ async def test_settings_llm_api_key(test_client, mock_settings_store):
# We should never expose the API key in the response
assert 'test-key' not in response.json()
@pytest.mark.skip(
reason='Mock middleware does not seem to properly set the github_token'
)
@pytest.mark.asyncio
async def test_settings_api_set_github_token(
mock_github_service, test_client, mock_settings_store
):
# Test data with github_token set
settings_data = {
'language': 'en',
'agent': 'test-agent',
'max_iterations': 100,
'security_analyzer': 'default',
'confirmation_mode': True,
'llm_model': 'test-model',
'llm_api_key': 'test-key',
'llm_base_url': 'https://test.com',
'github_token': 'test-token',
}
# Make the POST request to store settings
response = test_client.post('/api/settings', json=settings_data)
assert response.status_code == 200
# Verify the settings were stored with the github_token
stored_settings = mock_settings_store.store.call_args[0][0]
assert stored_settings.github_token == 'test-token'
# Mock settings store to return our settings for the GET request
mock_settings_store.load.return_value = Settings(**settings_data)
# Make a GET request to retrieve settings
response = test_client.get('/api/settings')
data = response.json()
assert response.status_code == 200
assert data.get('github_token') is None
assert data['github_token_is_set'] is True
@pytest.mark.asyncio
async def test_settings_unset_github_token(
mock_github_service, test_client, mock_settings_store
):
# Test data with unset_github_token set to True
settings_data = {
'language': 'en',
'agent': 'test-agent',
'max_iterations': 100,
'security_analyzer': 'default',
'confirmation_mode': True,
'llm_model': 'test-model',
'llm_api_key': 'test-key',
'llm_base_url': 'https://test.com',
'github_token': 'test-token',
}
# Mock settings store to return our settings for the GET request
mock_settings_store.load.return_value = Settings(**settings_data)
settings_data['unset_github_token'] = True
# Make the POST request to store settings
response = test_client.post('/api/settings', json=settings_data)
assert response.status_code == 200
# Verify the settings were stored with the github_token unset
stored_settings = mock_settings_store.store.call_args[0][0]
assert stored_settings.github_token is None
# Make a GET request to retrieve settings
response = test_client.get('/api/settings')
assert response.status_code == 200
assert response.json()['github_token_is_set'] is False