diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index dbba5c079f..e3114e004a 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -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: () => , + }, +]); +const renderSidebar = () => renderWithProviders(); -}; 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(); + + 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(); + }); + }); }); diff --git a/frontend/__tests__/components/features/waitlist-modal.test.tsx b/frontend/__tests__/components/features/waitlist-modal.test.tsx index d52ac6e7c9..c35ccf8435 100644 --- a/frontend/__tests__/components/features/waitlist-modal.test.tsx +++ b/frontend/__tests__/components/features/waitlist-modal.test.tsx @@ -14,7 +14,7 @@ describe("WaitlistModal", () => { }); it("should render a tos checkbox that is unchecked by default", () => { - render(); + render(); 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(); + render(); 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(); + render(); const checkbox = screen.getByRole("checkbox"); await user.click(checkbox); diff --git a/frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx b/frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx new file mode 100644 index 0000000000..ac6395c823 --- /dev/null +++ b/frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx @@ -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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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: "", + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 31c737c597..275eab952f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" diff --git a/frontend/src/api/github-axios-instance.ts b/frontend/src/api/github-axios-instance.ts deleted file mode 100644 index dace5e3461..0000000000 --- a/frontend/src/api/github-axios-instance.ts +++ /dev/null @@ -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 = >( - 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, - 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, -}; diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 84d8648546..d7c5de7023 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -242,13 +242,11 @@ class OpenHands { } static async createConversation( - githubToken?: string, selectedRepository?: string, initialUserMsg?: string, imageUrls?: string[], ): Promise { 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 { + await openHands.post("/api/logout"); + } } export default OpenHands; diff --git a/frontend/src/components/features/chat/action-suggestions.tsx b/frontend/src/components/features/chat/action-suggestions.tsx index 70cb176238..ec0917e49e 100644 --- a/frontend/src/components/features/chat/action-suggestions.tsx +++ b/frontend/src/components/features/chat/action-suggestions.tsx @@ -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 ? (
{!hasPullRequest ? ( <> diff --git a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx index 35a31e18a1..45eb527806 100644 --- a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx +++ b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx @@ -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 && ( - setConnectToGitHubModalOpen(false)}> - setConnectToGitHubModalOpen(false)} - /> - + setConnectToGitHubModalOpen(false)} + /> )} ); diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index cee990f9e9..044aa4846b 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -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 && ( )} - {settingsIsError || - (showSettingsModal && settingsSuccessfulyFetched && ( - setSettingsModalIsOpen(false)} - /> - ))} + {(settingsError || showSettingsModal) && ( + setSettingsModalIsOpen(false)} + /> + )} ); } diff --git a/frontend/src/components/features/waitlist/waitlist-modal.tsx b/frontend/src/components/features/waitlist/waitlist-modal.tsx index 486bf1855e..b8f2f71f4c 100644 --- a/frontend/src/components/features/waitlist/waitlist-modal.tsx +++ b/frontend/src/components/features/waitlist/waitlist-modal.tsx @@ -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) { - + setIsTosAccepted((prev) => !prev)} /> - {!ghToken && ( + {!ghTokenIsSet && ( )} - {ghToken && } + {ghTokenIsSet && } ); diff --git a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx b/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx index 6f805829bd..17d44e491c 100644 --- a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx +++ b/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx @@ -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) => { 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 = {}; + + 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 (
@@ -89,23 +101,20 @@ export function AccountSettingsForm({ {config?.APP_MODE !== "saas" && ( <> - - - {t(I18nKey.GITHUB$GET_TOKEN)}{" "} - - {t(I18nKey.COMMON$HERE)} - - + + {!githubTokenIsSet && ( + + {t(I18nKey.GITHUB$GET_TOKEN)}{" "} + + {t(I18nKey.COMMON$HERE)} + + + )} )} {gitHubError && ( @@ -113,14 +122,12 @@ export function AccountSettingsForm({ {t(I18nKey.GITHUB$TOKEN_INVALID)}

)} - {gitHubToken && !gitHubError && ( + {githubTokenIsSet && !gitHubError && ( { - logout(); - onClose(); - }} + onClick={onDisconnect} className="text-danger self-start" /> )} diff --git a/frontend/src/components/shared/modals/account-settings/github-token-input.tsx b/frontend/src/components/shared/modals/account-settings/github-token-input.tsx new file mode 100644 index 0000000000..f5f4de4b22 --- /dev/null +++ b/frontend/src/components/shared/modals/account-settings/github-token-input.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/shared/modals/connect-to-github-modal.tsx b/frontend/src/components/shared/modals/connect-to-github-modal.tsx deleted file mode 100644 index 130ca4e199..0000000000 --- a/frontend/src/components/shared/modals/connect-to-github-modal.tsx +++ /dev/null @@ -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) => { - event.preventDefault(); - const formData = new FormData(event.currentTarget); - const ghToken = formData.get("ghToken")?.toString(); - - if (ghToken) setGitHubToken(ghToken); - onClose(); - }; - - return ( - -
- - - {t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "} - - {t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)} - - - } - /> -
- - - -
- - -
- -
- ); -} diff --git a/frontend/src/context/auth-context.tsx b/frontend/src/context/auth-context.tsx index f04f4c79a2..e7aed7b0d4 100644 --- a/frontend/src/context/auth-context.tsx +++ b/frontend/src/context/auth-context.tsx @@ -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; - logout: () => void; + githubTokenIsSet: boolean; + setGitHubTokenIsSet: (value: boolean) => void; } const AuthContext = React.createContext(undefined); function AuthProvider({ children }: React.PropsWithChildren) { - const [gitHubTokenState, setGitHubTokenState] = React.useState( - () => localStorage.getItem("ghToken"), - ); - - const [userIdState, setUserIdState] = React.useState( - () => 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 => { - 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 {children}; diff --git a/frontend/src/context/settings-context.tsx b/frontend/src/context/settings-context.tsx index 4ecc105f9b..ef6972de4d 100644 --- a/frontend/src/context/settings-context.tsx +++ b/frontend/src/context/settings-context.tsx @@ -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) => Promise; + saveUserSettings: (newSettings: Partial) => Promise; settings: Settings | undefined; } @@ -28,8 +29,8 @@ export function SettingsProvider({ children }: SettingsProviderProps) { const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate()); - const saveUserSettings = async (newSettings: Partial) => { - const updatedSettings: Partial = { + const saveUserSettings = async (newSettings: Partial) => { + const updatedSettings: Partial = { ...userSettings, ...newSettings, }; diff --git a/frontend/src/hooks/mutation/use-create-conversation.ts b/frontend/src/hooks/mutation/use-create-conversation.ts index 27c976c945..c3d4ee4536 100644 --- a/frontend/src/hooks/mutation/use-create-conversation.ts +++ b/frontend/src/hooks/mutation/use-create-conversation.ts @@ -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, diff --git a/frontend/src/hooks/mutation/use-logout.ts b/frontend/src/hooks/mutation/use-logout.ts new file mode 100644 index 0000000000..4207c1dd19 --- /dev/null +++ b/frontend/src/hooks/mutation/use-logout.ts @@ -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(); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index 50865f6d64..077d389086 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -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) => { - const apiSettings: Partial = { +const saveSettingsMutationFn = async (settings: Partial) => { + const apiSettings: Partial = { 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) => { 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, }; diff --git a/frontend/src/hooks/query/use-app-installations.ts b/frontend/src/hooks/query/use-app-installations.ts index e22bd7805e..691d48f2d6 100644 --- a/frontend/src/hooks/query/use-app-installations.ts +++ b/frontend/src/hooks/query/use-app-installations.ts @@ -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", }); diff --git a/frontend/src/hooks/query/use-app-repositories.ts b/frontend/src/hooks/query/use-app-repositories.ts index b06150a3ac..6d07a6292f 100644 --- a/frontend/src/hooks/query/use-app-repositories.ts +++ b/frontend/src/hooks/query/use-app-repositories.ts @@ -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", diff --git a/frontend/src/hooks/query/use-github-user.ts b/frontend/src/hooks/query/use-github-user.ts index 7d24e12abc..db3f4db2f0 100644 --- a/frontend/src/hooks/query/use-github-user.ts +++ b/frontend/src/hooks/query/use-github-user.ts @@ -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]); diff --git a/frontend/src/hooks/query/use-is-authed.ts b/frontend/src/hooks/query/use-is-authed.ts index 45c51b70bf..35987f7d9b 100644 --- a/frontend/src/hooks/query/use-is-authed.ts +++ b/frontend/src/hooks/query/use-is-authed.ts @@ -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 diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index 1f56c86b9d..c5576dc930 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -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; }; diff --git a/frontend/src/hooks/query/use-user-repositories.ts b/frontend/src/hooks/query/use-user-repositories.ts index 222b390575..0dfc24e6b5 100644 --- a/frontend/src/hooks/query/use-user-repositories.ts +++ b/frontend/src/hooks/query/use-user-repositories.ts @@ -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 diff --git a/frontend/src/hooks/use-github-auth-url.ts b/frontend/src/hooks/use-github-auth-url.ts index e9d493764c..b058913b61 100644 --- a/frontend/src/hooks/use-github-auth-url.ts +++ b/frontend/src/hooks/use-github-auth-url.ts @@ -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]); +}; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index d9c40ff146..dba9c12cea 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -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 = {}; + 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 }); diff --git a/frontend/src/routes/_oh._index/route.tsx b/frontend/src/routes/_oh._index/route.tsx index 926635f5ae..23e4ccd08b 100644 --- a/frontend/src/routes/_oh._index/route.tsx +++ b/frontend/src/routes/_oh._index/route.tsx @@ -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(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, }); diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx index e5c0b0b3a9..935f7a122e 100644 --- a/frontend/src/routes/_oh.app/route.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -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( diff --git a/frontend/src/routes/_oh/route.tsx b/frontend/src/routes/_oh/route.tsx index ca5868d155..ac18b716c5 100644 --- a/frontend/src/routes/_oh/route.tsx +++ b/frontend/src/routes/_oh/route.tsx @@ -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 (
- {isInWaitlist && ( - + {renderWaitlistModal && ( + )} {config.data?.APP_MODE === "oss" && consentFormIsOpen && ( diff --git a/frontend/src/routes/oauth.github.callback.tsx b/frontend/src/routes/oauth.github.callback.tsx index 62d76ad27f..f10fcf23e6 100644 --- a/frontend/src/routes/oauth.github.callback.tsx +++ b/frontend/src/routes/oauth.github.callback.tsx @@ -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]); diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts index 1a4cd286f8..97737de3e3 100644 --- a/frontend/src/services/settings.ts +++ b/frontend/src/services/settings.ts @@ -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, }; diff --git a/openhands/events/stream.py b/openhands/events/stream.py index 177a921e4e..8a6cdfb35a 100644 --- a/openhands/events/stream.py +++ b/openhands/events/stream.py @@ -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, '') + return data + def _run_queue_loop(self): self._queue_loop = asyncio.new_event_loop() asyncio.set_event_loop(self._queue_loop) diff --git a/openhands/server/auth.py b/openhands/server/auth.py index a880cb58ae..d54577a665 100644 --- a/openhands/server/auth.py +++ b/openhands/server/auth.py @@ -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) diff --git a/openhands/server/config/openhands_config.py b/openhands/server/config/openhands_config.py index 12c4f7b9de..2d61152ce5 100644 --- a/openhands/server/config/openhands_config.py +++ b/openhands/server/config/openhands_config.py @@ -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(): diff --git a/openhands/server/middleware.py b/openhands/server/middleware.py index 6b71721ae2..399271fa48 100644 --- a/openhands/server/middleware.py +++ b/openhands/server/middleware.py @@ -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) diff --git a/openhands/server/routes/github.py b/openhands/server/routes/github.py index ca9dfc5f65..67612235aa 100644 --- a/openhands/server/routes/github.py +++ b/openhands/server/routes/github.py @@ -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 diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 3fdbbd7d66..0e0cd7743b 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -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 [] diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 9abad14f85..0c39e0243e 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -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) diff --git a/openhands/server/services/github_service.py b/openhands/server/services/github_service.py new file mode 100644 index 0000000000..2fae3134a0 --- /dev/null +++ b/openhands/server/services/github_service.py @@ -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() diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index f23ac69090..59afdf141c 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -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( diff --git a/openhands/server/settings.py b/openhands/server/settings.py index c63e115546..b6594372eb 100644 --- a/openhands/server/settings.py +++ b/openhands/server/settings.py @@ -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 diff --git a/tests/unit/test_settings_api.py b/tests/unit/test_settings_api.py index ed7c02eb14..315e67e7bd 100644 --- a/tests/unit/test_settings_api.py +++ b/tests/unit/test_settings_api.py @@ -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