From cd2d0ee9a547cbf9f04d46ea14a9559890007a97 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:38:54 +0400 Subject: [PATCH] feat(frontend): Organizational support (#9496) Co-authored-by: openhands Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com> Co-authored-by: Abhay Mishra Co-authored-by: Hyun Han <62870362+smosco@users.noreply.github.com> Co-authored-by: Nhan Nguyen Co-authored-by: Bharath A V Co-authored-by: hieptl Co-authored-by: Chloe Co-authored-by: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> --- .gitignore | 2 + frontend/.gitignore | 1 + .../chat/expandable-message.test.tsx | 2 +- .../account-settings-context-menu.test.tsx | 214 ---- .../org/confirm-remove-member-modal.test.tsx | 91 ++ .../org/confirm-update-role-modal.test.tsx | 102 ++ .../invite-organization-member-modal.test.tsx | 141 +++ .../features/org/org-selector.test.tsx | 203 ++++ .../settings/settings-navigation.test.tsx | 109 ++ .../features/user/user-context-menu.test.tsx | 633 ++++++++++ .../components/landing-translations.test.tsx | 219 ---- .../__tests__/components/ui/dropdown.test.tsx | 429 +++++++ .../components/user-actions.test.tsx | 336 ++++-- .../__tests__/components/user-avatar.test.tsx | 38 +- frontend/__tests__/helpers/mock-config.ts | 32 - .../use-invite-members-batch.test.tsx | 45 + .../hooks/mutation/use-remove-member.test.tsx | 45 + .../hooks/query/use-organizations.test.tsx | 174 +++ .../hooks/use-org-type-and-access.test.tsx | 134 +++ .../__tests__/hooks/use-permission.test.tsx | 98 ++ .../hooks/use-settings-nav-items.test.tsx | 223 +++- frontend/__tests__/i18n/translations.test.tsx | 79 -- frontend/__tests__/routes/api-keys.test.tsx | 10 + .../__tests__/routes/app-settings.test.tsx | 10 +- frontend/__tests__/routes/billing.test.tsx | 367 ++++++ .../__tests__/routes/git-settings.test.tsx | 10 +- .../__tests__/routes/home-screen.test.tsx | 12 +- .../__tests__/routes/llm-settings.test.tsx | 719 ++++++++++- frontend/__tests__/routes/manage-org.test.tsx | 954 +++++++++++++++ .../manage-organization-members.test.tsx | 1062 +++++++++++++++++ .../__tests__/routes/mcp-settings.test.tsx | 10 + .../routes/secrets-settings.test.tsx | 74 +- .../routes/settings-with-payment.test.tsx | 135 ++- frontend/__tests__/routes/settings.test.tsx | 449 ++++++- .../selected-organization-store.test.ts | 51 + .../utils/billing-visibility.test.ts | 50 + .../__tests__/utils/input-validation.test.ts | 172 +++ .../__tests__/utils/permission-checks.test.ts | 79 ++ .../__tests__/utils/permission-guard.test.ts | 175 +++ frontend/playwright.config.ts | 2 +- .../organization-service.api.ts | 159 +++ .../account-settings-context-menu.tsx | 116 -- .../features/org/change-org-name-modal.tsx | 45 + .../org/confirm-remove-member-modal.tsx | 42 + .../org/confirm-update-role-modal.tsx | 47 + .../org/delete-org-confirmation-modal.tsx | 52 + .../org/invite-organization-member-modal.tsx | 68 ++ .../components/features/org/org-selector.tsx | 61 + .../org/organization-member-list-item.tsx | 85 ++ .../organization-member-role-context-menu.tsx | 123 ++ .../features/payment/payment-form.tsx | 5 +- .../features/payment/setup-payment-modal.tsx | 2 +- .../features/settings/brand-button.tsx | 2 +- .../settings/settings-dropdown-input.tsx | 4 +- .../features/settings/settings-navigation.tsx | 24 +- .../components/features/sidebar/sidebar.tsx | 3 - .../features/sidebar/user-actions.tsx | 102 +- .../features/sidebar/user-avatar.tsx | 4 +- .../features/user/user-context-menu.tsx | 168 +++ .../components/shared/inputs/badge-input.tsx | 7 +- .../src/components/shared/loading-spinner.tsx | 20 +- .../modals/confirmation-modals/base-modal.tsx | 2 +- .../shared/modals/modal-backdrop.tsx | 14 +- .../shared/modals/modal-button-group.tsx | 70 ++ .../components/shared/modals/org-modal.tsx | 90 ++ .../src/components/v1/chat/event-message.tsx | 1 - frontend/src/constants/settings-nav.tsx | 11 + .../src/context/use-selected-organization.ts | 28 + .../hooks/mutation/use-delete-organization.ts | 36 + .../mutation/use-invite-members-batch.ts | 38 + .../src/hooks/mutation/use-remove-member.ts | 38 + .../hooks/mutation/use-switch-organization.ts | 36 + .../use-unified-start-conversation.ts | 14 + .../hooks/mutation/use-update-member-role.ts | 46 + .../hooks/mutation/use-update-organization.ts | 28 + .../hooks/organizations/use-permissions.ts | 17 + frontend/src/hooks/query/use-me.ts | 18 + .../query/use-organization-members-count.ts | 24 + .../hooks/query/use-organization-members.ts | 30 + .../query/use-organization-payment-info.tsx | 16 + frontend/src/hooks/query/use-organization.ts | 14 + frontend/src/hooks/query/use-organizations.ts | 32 + .../src/hooks/use-auto-select-organization.ts | 33 + frontend/src/hooks/use-org-type-and-access.ts | 22 + frontend/src/hooks/use-settings-nav-items.ts | 56 +- .../src/hooks/use-should-hide-org-selector.ts | 16 + frontend/src/i18n/declaration.ts | 57 + frontend/src/i18n/translation.json | 912 ++++++++++++++ frontend/src/icons/admin.svg | 3 + frontend/src/icons/loading-outer.svg | 2 +- frontend/src/mocks/handlers.ts | 2 + frontend/src/mocks/org-handlers.ts | 556 +++++++++ frontend/src/mocks/settings-handlers.ts | 35 +- frontend/src/routes.ts | 2 + frontend/src/routes/api-keys.tsx | 3 + frontend/src/routes/app-settings.tsx | 5 + frontend/src/routes/billing.tsx | 46 +- frontend/src/routes/git-settings.tsx | 3 + frontend/src/routes/llm-settings.tsx | 50 +- frontend/src/routes/manage-org.tsx | 219 ++++ .../routes/manage-organization-members.tsx | 269 +++++ frontend/src/routes/mcp-settings.tsx | 3 + frontend/src/routes/root-layout.tsx | 9 +- frontend/src/routes/secrets-settings.tsx | 3 + frontend/src/routes/settings.tsx | 158 +-- .../src/stores/selected-organization-store.ts | 30 + frontend/src/tailwind.css | 8 + frontend/src/types/org.ts | 58 + frontend/src/ui/context-menu-icon-text.tsx | 30 + frontend/src/ui/credits-chip.tsx | 30 + frontend/src/ui/dropdown/clear-button.tsx | 19 + frontend/src/ui/dropdown/dropdown-input.tsx | 27 + frontend/src/ui/dropdown/dropdown-menu.tsx | 63 + frontend/src/ui/dropdown/dropdown.tsx | 128 ++ frontend/src/ui/dropdown/loading-spinner.tsx | 8 + frontend/src/ui/dropdown/toggle-button.tsx | 31 + frontend/src/ui/dropdown/types.ts | 4 + frontend/src/ui/interactive-chip.tsx | 33 + frontend/src/ui/pagination.tsx | 129 ++ .../src/utils/get-component-prop-types.ts | 2 + frontend/src/utils/input-validation.ts | 35 + frontend/src/utils/org/billing-visibility.ts | 19 + frontend/src/utils/org/permission-checks.ts | 50 + frontend/src/utils/org/permission-guard.ts | 84 ++ frontend/src/utils/org/permissions.ts | 69 ++ frontend/src/utils/query-client-getters.ts | 5 + frontend/src/utils/settings-utils.ts | 57 + frontend/tailwind.config.js | 20 + frontend/test-utils.tsx | 63 +- frontend/tests/avatar-menu.spec.ts | 36 +- frontend/vitest.setup.ts | 2 + 131 files changed, 11876 insertions(+), 1061 deletions(-) delete mode 100644 frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx create mode 100644 frontend/__tests__/components/features/org/confirm-remove-member-modal.test.tsx create mode 100644 frontend/__tests__/components/features/org/confirm-update-role-modal.test.tsx create mode 100644 frontend/__tests__/components/features/org/invite-organization-member-modal.test.tsx create mode 100644 frontend/__tests__/components/features/org/org-selector.test.tsx create mode 100644 frontend/__tests__/components/features/settings/settings-navigation.test.tsx create mode 100644 frontend/__tests__/components/features/user/user-context-menu.test.tsx delete mode 100644 frontend/__tests__/components/landing-translations.test.tsx create mode 100644 frontend/__tests__/components/ui/dropdown.test.tsx delete mode 100644 frontend/__tests__/helpers/mock-config.ts create mode 100644 frontend/__tests__/hooks/mutation/use-invite-members-batch.test.tsx create mode 100644 frontend/__tests__/hooks/mutation/use-remove-member.test.tsx create mode 100644 frontend/__tests__/hooks/query/use-organizations.test.tsx create mode 100644 frontend/__tests__/hooks/use-org-type-and-access.test.tsx create mode 100644 frontend/__tests__/hooks/use-permission.test.tsx delete mode 100644 frontend/__tests__/i18n/translations.test.tsx create mode 100644 frontend/__tests__/routes/api-keys.test.tsx create mode 100644 frontend/__tests__/routes/billing.test.tsx create mode 100644 frontend/__tests__/routes/manage-org.test.tsx create mode 100644 frontend/__tests__/routes/manage-organization-members.test.tsx create mode 100644 frontend/__tests__/routes/mcp-settings.test.tsx create mode 100644 frontend/__tests__/stores/selected-organization-store.test.ts create mode 100644 frontend/__tests__/utils/billing-visibility.test.ts create mode 100644 frontend/__tests__/utils/input-validation.test.ts create mode 100644 frontend/__tests__/utils/permission-checks.test.ts create mode 100644 frontend/__tests__/utils/permission-guard.test.ts create mode 100644 frontend/src/api/organization-service/organization-service.api.ts delete mode 100644 frontend/src/components/features/context-menu/account-settings-context-menu.tsx create mode 100644 frontend/src/components/features/org/change-org-name-modal.tsx create mode 100644 frontend/src/components/features/org/confirm-remove-member-modal.tsx create mode 100644 frontend/src/components/features/org/confirm-update-role-modal.tsx create mode 100644 frontend/src/components/features/org/delete-org-confirmation-modal.tsx create mode 100644 frontend/src/components/features/org/invite-organization-member-modal.tsx create mode 100644 frontend/src/components/features/org/org-selector.tsx create mode 100644 frontend/src/components/features/org/organization-member-list-item.tsx create mode 100644 frontend/src/components/features/org/organization-member-role-context-menu.tsx create mode 100644 frontend/src/components/features/user/user-context-menu.tsx create mode 100644 frontend/src/components/shared/modals/modal-button-group.tsx create mode 100644 frontend/src/components/shared/modals/org-modal.tsx create mode 100644 frontend/src/context/use-selected-organization.ts create mode 100644 frontend/src/hooks/mutation/use-delete-organization.ts create mode 100644 frontend/src/hooks/mutation/use-invite-members-batch.ts create mode 100644 frontend/src/hooks/mutation/use-remove-member.ts create mode 100644 frontend/src/hooks/mutation/use-switch-organization.ts create mode 100644 frontend/src/hooks/mutation/use-update-member-role.ts create mode 100644 frontend/src/hooks/mutation/use-update-organization.ts create mode 100644 frontend/src/hooks/organizations/use-permissions.ts create mode 100644 frontend/src/hooks/query/use-me.ts create mode 100644 frontend/src/hooks/query/use-organization-members-count.ts create mode 100644 frontend/src/hooks/query/use-organization-members.ts create mode 100644 frontend/src/hooks/query/use-organization-payment-info.tsx create mode 100644 frontend/src/hooks/query/use-organization.ts create mode 100644 frontend/src/hooks/query/use-organizations.ts create mode 100644 frontend/src/hooks/use-auto-select-organization.ts create mode 100644 frontend/src/hooks/use-org-type-and-access.ts create mode 100644 frontend/src/hooks/use-should-hide-org-selector.ts create mode 100644 frontend/src/icons/admin.svg create mode 100644 frontend/src/mocks/org-handlers.ts create mode 100644 frontend/src/routes/manage-org.tsx create mode 100644 frontend/src/routes/manage-organization-members.tsx create mode 100644 frontend/src/stores/selected-organization-store.ts create mode 100644 frontend/src/types/org.ts create mode 100644 frontend/src/ui/context-menu-icon-text.tsx create mode 100644 frontend/src/ui/credits-chip.tsx create mode 100644 frontend/src/ui/dropdown/clear-button.tsx create mode 100644 frontend/src/ui/dropdown/dropdown-input.tsx create mode 100644 frontend/src/ui/dropdown/dropdown-menu.tsx create mode 100644 frontend/src/ui/dropdown/dropdown.tsx create mode 100644 frontend/src/ui/dropdown/loading-spinner.tsx create mode 100644 frontend/src/ui/dropdown/toggle-button.tsx create mode 100644 frontend/src/ui/dropdown/types.ts create mode 100644 frontend/src/ui/interactive-chip.tsx create mode 100644 frontend/src/ui/pagination.tsx create mode 100644 frontend/src/utils/get-component-prop-types.ts create mode 100644 frontend/src/utils/input-validation.ts create mode 100644 frontend/src/utils/org/billing-visibility.ts create mode 100644 frontend/src/utils/org/permission-checks.ts create mode 100644 frontend/src/utils/org/permission-guard.ts create mode 100644 frontend/src/utils/org/permissions.ts create mode 100644 frontend/src/utils/query-client-getters.ts diff --git a/.gitignore b/.gitignore index 6fc0934a02..47512f9d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -234,6 +234,8 @@ yarn-error.log* logs +ralph/ + # agent .envrc /workspace diff --git a/frontend/.gitignore b/frontend/.gitignore index 13f00df210..9fa77e5182 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -8,3 +8,4 @@ node_modules/ /blob-report/ /playwright/.cache/ .react-router/ +ralph/ diff --git a/frontend/__tests__/components/chat/expandable-message.test.tsx b/frontend/__tests__/components/chat/expandable-message.test.tsx index d55e926450..96f5f8ee4a 100644 --- a/frontend/__tests__/components/chat/expandable-message.test.tsx +++ b/frontend/__tests__/components/chat/expandable-message.test.tsx @@ -113,7 +113,7 @@ describe("ExpandableMessage", () => { it("should render the out of credits message when the user is out of credits", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - We only care about the app_mode and feature_flags fields + // @ts-expect-error - partial mock for testing getConfigSpy.mockResolvedValue({ app_mode: "saas", feature_flags: { diff --git a/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx b/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx deleted file mode 100644 index cc009e894d..0000000000 --- a/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; -import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu"; -import { MemoryRouter } from "react-router"; -import { renderWithProviders } from "../../../test-utils"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createMockWebClientConfig } from "../../helpers/mock-config"; - -const mockTrackAddTeamMembersButtonClick = vi.fn(); - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackAddTeamMembersButtonClick: mockTrackAddTeamMembersButtonClick, - }), -})); - -// Mock posthog feature flag -vi.mock("posthog-js/react", () => ({ - useFeatureFlagEnabled: vi.fn(), -})); - -// Import the mocked module to get access to the mock -import * as posthog from "posthog-js/react"; - -describe("AccountSettingsContextMenu", () => { - const user = userEvent.setup(); - const onClickAccountSettingsMock = vi.fn(); - const onLogoutMock = vi.fn(); - const onCloseMock = vi.fn(); - - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - // Set default feature flag to false - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false); - }); - - // Create a wrapper with MemoryRouter and renderWithProviders - const renderWithRouter = (ui: React.ReactElement) => { - return renderWithProviders({ui}); - }; - - const renderWithSaasConfig = (ui: React.ReactElement, options?: { analyticsConsent?: boolean }) => { - queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "saas" })); - queryClient.setQueryData(["settings"], { user_consents_to_analytics: options?.analyticsConsent ?? true }); - return render( - - {ui} - - ); - }; - - const renderWithOssConfig = (ui: React.ReactElement) => { - queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "oss" })); - return render( - - {ui} - - ); - }; - - afterEach(() => { - onClickAccountSettingsMock.mockClear(); - onLogoutMock.mockClear(); - onCloseMock.mockClear(); - mockTrackAddTeamMembersButtonClick.mockClear(); - vi.mocked(posthog.useFeatureFlagEnabled).mockClear(); - }); - - it("should always render the right options", () => { - renderWithRouter( - , - ); - - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); - expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument(); - expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument(); - }); - - it("should render Documentation link with correct attributes", () => { - renderWithRouter( - , - ); - - const documentationLink = screen.getByText("SIDEBAR$DOCS").closest("a"); - expect(documentationLink).toHaveAttribute("href", "https://docs.openhands.dev"); - expect(documentationLink).toHaveAttribute("target", "_blank"); - expect(documentationLink).toHaveAttribute("rel", "noopener noreferrer"); - }); - - it("should call onLogout when the logout option is clicked", async () => { - renderWithRouter( - , - ); - - const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); - await user.click(logoutOption); - - expect(onLogoutMock).toHaveBeenCalledOnce(); - }); - - test("logout button is always enabled", async () => { - renderWithRouter( - , - ); - - const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); - await user.click(logoutOption); - - expect(onLogoutMock).toHaveBeenCalledOnce(); - }); - - it("should call onClose when clicking outside of the element", async () => { - renderWithRouter( - , - ); - - const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); - await user.click(accountSettingsButton); - await user.click(document.body); - - expect(onCloseMock).toHaveBeenCalledOnce(); - }); - - it("should show Add Team Members button in SaaS mode when feature flag is enabled", () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true); - renderWithSaasConfig( - , - ); - - expect(screen.getByTestId("add-team-members-button")).toBeInTheDocument(); - expect(screen.getByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).toBeInTheDocument(); - }); - - it("should not show Add Team Members button in SaaS mode when feature flag is disabled", () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false); - renderWithSaasConfig( - , - ); - - expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument(); - expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument(); - }); - - it("should not show Add Team Members button in OSS mode even when feature flag is enabled", () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true); - renderWithOssConfig( - , - ); - - expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument(); - expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument(); - }); - - it("should not show Add Team Members button when analytics consent is disabled", () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true); - renderWithSaasConfig( - , - { analyticsConsent: false }, - ); - - expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument(); - expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument(); - }); - - it("should call tracking function and onClose when Add Team Members button is clicked", async () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true); - renderWithSaasConfig( - , - ); - - const addTeamMembersButton = screen.getByTestId("add-team-members-button"); - await user.click(addTeamMembersButton); - - expect(mockTrackAddTeamMembersButtonClick).toHaveBeenCalledOnce(); - expect(onCloseMock).toHaveBeenCalledOnce(); - }); -}); diff --git a/frontend/__tests__/components/features/org/confirm-remove-member-modal.test.tsx b/frontend/__tests__/components/features/org/confirm-remove-member-modal.test.tsx new file mode 100644 index 0000000000..922e8a19ab --- /dev/null +++ b/frontend/__tests__/components/features/org/confirm-remove-member-modal.test.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import { ConfirmRemoveMemberModal } from "#/components/features/org/confirm-remove-member-modal"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + Trans: ({ + values, + components, + }: { + values: { email: string }; + components: { email: React.ReactElement }; + }) => React.cloneElement(components.email, {}, values.email), +})); + +describe("ConfirmRemoveMemberModal", () => { + it("should display the member email in the confirmation message", () => { + // Arrange + const memberEmail = "test@example.com"; + + // Act + renderWithProviders( + , + ); + + // Assert + expect(screen.getByText(memberEmail)).toBeInTheDocument(); + }); + + it("should call onConfirm when the confirm button is clicked", async () => { + // Arrange + const user = userEvent.setup(); + const onConfirmMock = vi.fn(); + renderWithProviders( + , + ); + + // Act + await user.click(screen.getByTestId("confirm-button")); + + // Assert + expect(onConfirmMock).toHaveBeenCalledOnce(); + }); + + it("should call onCancel when the cancel button is clicked", async () => { + // Arrange + const user = userEvent.setup(); + const onCancelMock = vi.fn(); + renderWithProviders( + , + ); + + // Act + await user.click(screen.getByTestId("cancel-button")); + + // Assert + expect(onCancelMock).toHaveBeenCalledOnce(); + }); + + it("should disable buttons and show loading spinner when isLoading is true", () => { + // Arrange & Act + renderWithProviders( + , + ); + + // Assert + expect(screen.getByTestId("confirm-button")).toBeDisabled(); + expect(screen.getByTestId("cancel-button")).toBeDisabled(); + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/features/org/confirm-update-role-modal.test.tsx b/frontend/__tests__/components/features/org/confirm-update-role-modal.test.tsx new file mode 100644 index 0000000000..d9409565a9 --- /dev/null +++ b/frontend/__tests__/components/features/org/confirm-update-role-modal.test.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import { ConfirmUpdateRoleModal } from "#/components/features/org/confirm-update-role-modal"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + Trans: ({ + values, + components, + }: { + values: { email: string; role: string }; + components: { email: React.ReactElement; role: React.ReactElement }; + }) => ( + <> + {React.cloneElement(components.email, {}, values.email)} + {React.cloneElement(components.role, {}, values.role)} + + ), +})); + +describe("ConfirmUpdateRoleModal", () => { + it("should display the member email and new role in the confirmation message", () => { + // Arrange + const memberEmail = "test@example.com"; + const newRole = "admin"; + + // Act + renderWithProviders( + , + ); + + // Assert + expect(screen.getByText(memberEmail)).toBeInTheDocument(); + expect(screen.getByText(newRole)).toBeInTheDocument(); + }); + + it("should call onConfirm when the confirm button is clicked", async () => { + // Arrange + const user = userEvent.setup(); + const onConfirmMock = vi.fn(); + renderWithProviders( + , + ); + + // Act + await user.click(screen.getByTestId("confirm-button")); + + // Assert + expect(onConfirmMock).toHaveBeenCalledOnce(); + }); + + it("should call onCancel when the cancel button is clicked", async () => { + // Arrange + const user = userEvent.setup(); + const onCancelMock = vi.fn(); + renderWithProviders( + , + ); + + // Act + await user.click(screen.getByTestId("cancel-button")); + + // Assert + expect(onCancelMock).toHaveBeenCalledOnce(); + }); + + it("should disable buttons and show loading spinner when isLoading is true", () => { + // Arrange & Act + renderWithProviders( + , + ); + + // Assert + expect(screen.getByTestId("confirm-button")).toBeDisabled(); + expect(screen.getByTestId("cancel-button")).toBeDisabled(); + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/features/org/invite-organization-member-modal.test.tsx b/frontend/__tests__/components/features/org/invite-organization-member-modal.test.tsx new file mode 100644 index 0000000000..42e470420d --- /dev/null +++ b/frontend/__tests__/components/features/org/invite-organization-member-modal.test.tsx @@ -0,0 +1,141 @@ +import { within, screen, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { InviteOrganizationMemberModal } from "#/components/features/org/invite-organization-member-modal"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import * as ToastHandlers from "#/utils/custom-toast-handlers"; + +vi.mock("react-router", () => ({ + useRevalidator: vi.fn(() => ({ revalidate: vi.fn() })), +})); + +const renderInviteOrganizationMemberModal = (config?: { + onClose: () => void; +}) => + render( + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + +describe("InviteOrganizationMemberModal", () => { + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "1" }); + }); + + afterEach(() => { + vi.clearAllMocks(); + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should call onClose the modal when the close button is clicked", async () => { + const onCloseMock = vi.fn(); + renderInviteOrganizationMemberModal({ onClose: onCloseMock }); + + const modal = screen.getByTestId("invite-modal"); + const closeButton = within(modal).getByRole("button", { + name: /close/i, + }); + await userEvent.click(closeButton); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + it("should call the batch API to invite a single team member when the form is submitted", async () => { + const inviteMembersBatchSpy = vi.spyOn( + organizationService, + "inviteMembers", + ); + const onCloseMock = vi.fn(); + + renderInviteOrganizationMemberModal({ onClose: onCloseMock }); + + const modal = screen.getByTestId("invite-modal"); + + const badgeInput = within(modal).getByTestId("emails-badge-input"); + await userEvent.type(badgeInput, "someone@acme.org "); + + // Verify badge is displayed + expect(screen.getByText("someone@acme.org")).toBeInTheDocument(); + + const submitButton = within(modal).getByRole("button", { + name: /add/i, + }); + await userEvent.click(submitButton); + + expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({ + orgId: "1", + emails: ["someone@acme.org"], + }); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + it("should allow adding multiple emails using badge input and make a batch POST request", async () => { + const inviteMembersBatchSpy = vi.spyOn( + organizationService, + "inviteMembers", + ); + const onCloseMock = vi.fn(); + + renderInviteOrganizationMemberModal({ onClose: onCloseMock }); + + const modal = screen.getByTestId("invite-modal"); + + // Should have badge input instead of regular input + const badgeInput = within(modal).getByTestId("emails-badge-input"); + expect(badgeInput).toBeInTheDocument(); + + // Add first email by typing and pressing space + await userEvent.type(badgeInput, "user1@acme.org "); + + // Add second email by typing and pressing space + await userEvent.type(badgeInput, "user2@acme.org "); + + // Add third email by typing and pressing space + await userEvent.type(badgeInput, "user3@acme.org "); + + // Verify badges are displayed + expect(screen.getByText("user1@acme.org")).toBeInTheDocument(); + expect(screen.getByText("user2@acme.org")).toBeInTheDocument(); + expect(screen.getByText("user3@acme.org")).toBeInTheDocument(); + + const submitButton = within(modal).getByRole("button", { + name: /add/i, + }); + await userEvent.click(submitButton); + + // Should call batch invite API with all emails + expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({ + orgId: "1", + emails: ["user1@acme.org", "user2@acme.org", "user3@acme.org"], + }); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + it("should display an error toast when clicking add button with no emails added", async () => { + // Arrange + const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); + const inviteMembersSpy = vi.spyOn(organizationService, "inviteMembers"); + renderInviteOrganizationMemberModal(); + + // Act + const modal = screen.getByTestId("invite-modal"); + const submitButton = within(modal).getByRole("button", { name: /add/i }); + await userEvent.click(submitButton); + + // Assert + expect(displayErrorToastSpy).toHaveBeenCalledWith( + "ORG$NO_EMAILS_ADDED_HINT", + ); + expect(inviteMembersSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/__tests__/components/features/org/org-selector.test.tsx b/frontend/__tests__/components/features/org/org-selector.test.tsx new file mode 100644 index 0000000000..66f7f95233 --- /dev/null +++ b/frontend/__tests__/components/features/org/org-selector.test.tsx @@ -0,0 +1,203 @@ +import { screen, render, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { OrgSelector } from "#/components/features/org/org-selector"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, + createMockOrganization, +} from "#/mocks/org-handlers"; + +vi.mock("react-router", () => ({ + useRevalidator: () => ({ revalidate: vi.fn() }), + useNavigate: () => vi.fn(), + useLocation: () => ({ pathname: "/" }), + useMatch: () => null, +})); + +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature) +vi.mock("#/hooks/query/use-config", () => ({ + useConfig: () => ({ data: { app_mode: "saas" } }), +})); + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization", + "ORG$PERSONAL_WORKSPACE": "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +const renderOrgSelector = () => + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + +describe("OrgSelector", () => { + it("should not render when user only has a personal workspace", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + + const { container } = renderOrgSelector(); + + await waitFor(() => { + expect(container).toBeEmptyDOMElement(); + }); + }); + + it("should render when user only has a team organization", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const { container } = renderOrgSelector(); + + await waitFor(() => { + expect(container).not.toBeEmptyDOMElement(); + }); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should show a loading indicator when fetching organizations", () => { + vi.spyOn(organizationService, "getOrganizations").mockImplementation( + () => new Promise(() => {}), // never resolves + ); + + renderOrgSelector(); + + // The dropdown trigger should be disabled while loading + const trigger = screen.getByTestId("dropdown-trigger"); + expect(trigger).toBeDisabled(); + }); + + it("should select the first organization after orgs are loaded", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + + renderOrgSelector(); + + // The combobox input should show the first org name + await waitFor(() => { + const input = screen.getByRole("combobox"); + expect(input).toHaveValue("Personal Workspace"); + }); + }); + + it("should show all options when dropdown is opened", async () => { + const user = userEvent.setup(); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [ + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, + createMockOrganization("3", "Test Organization", 500), + ], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + + renderOrgSelector(); + + // Wait for the selector to be populated with the first organization + await waitFor(() => { + const input = screen.getByRole("combobox"); + expect(input).toHaveValue("Personal Workspace"); + }); + + // Click the trigger to open dropdown + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + // Verify all 3 options are visible + const listbox = await screen.findByRole("listbox"); + const options = within(listbox).getAllByRole("option"); + + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent("Personal Workspace"); + expect(options[1]).toHaveTextContent("Acme Corp"); + expect(options[2]).toHaveTextContent("Test Organization"); + }); + + it("should call switchOrganization API when selecting a different organization", async () => { + // Arrange + const user = userEvent.setup(); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + const switchOrgSpy = vi + .spyOn(organizationService, "switchOrganization") + .mockResolvedValue(MOCK_TEAM_ORG_ACME); + + renderOrgSelector(); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace"); + }); + + // Act + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + const listbox = await screen.findByRole("listbox"); + const acmeOption = within(listbox).getByText("Acme Corp"); + await user.click(acmeOption); + + // Assert + expect(switchOrgSpy).toHaveBeenCalledWith({ orgId: MOCK_TEAM_ORG_ACME.id }); + }); + + it("should show loading state while switching organizations", async () => { + // Arrange + const user = userEvent.setup(); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "switchOrganization").mockImplementation( + () => new Promise(() => {}), // never resolves to keep loading state + ); + + renderOrgSelector(); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace"); + }); + + // Act + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + const listbox = await screen.findByRole("listbox"); + const acmeOption = within(listbox).getByText("Acme Corp"); + await user.click(acmeOption); + + // Assert + await waitFor(() => { + expect(screen.getByTestId("dropdown-trigger")).toBeDisabled(); + }); + }); +}); diff --git a/frontend/__tests__/components/features/settings/settings-navigation.test.tsx b/frontend/__tests__/components/features/settings/settings-navigation.test.tsx new file mode 100644 index 0000000000..41c6b25f91 --- /dev/null +++ b/frontend/__tests__/components/features/settings/settings-navigation.test.tsx @@ -0,0 +1,109 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MemoryRouter } from "react-router"; +import { SettingsNavigation } from "#/components/features/settings/settings-navigation"; +import OptionService from "#/api/option-service/option-service.api"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import { SAAS_NAV_ITEMS, SettingsNavItem } from "#/constants/settings-nav"; + +vi.mock("react-router", async () => ({ + ...(await vi.importActual("react-router")), + useRevalidator: () => ({ revalidate: vi.fn() }), +})); + +const mockConfig = () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue({ + app_mode: "saas", + } as Awaited>); +}; + +const ITEMS_WITHOUT_ORG = SAAS_NAV_ITEMS.filter( + (item) => + item.to !== "/settings/org" && item.to !== "/settings/org-members", +); + +const renderSettingsNavigation = ( + items: SettingsNavItem[] = SAAS_NAV_ITEMS, +) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render( + + + + + , + ); +}; + +describe("SettingsNavigation", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockConfig(); + useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + }); + + describe("renders navigation items passed via props", () => { + it("should render org routes when included in navigation items", async () => { + renderSettingsNavigation(SAAS_NAV_ITEMS); + + await screen.findByTestId("settings-navbar"); + + const orgMembersLink = await screen.findByText("Organization Members"); + const orgLink = await screen.findByText("Organization"); + + expect(orgMembersLink).toBeInTheDocument(); + expect(orgLink).toBeInTheDocument(); + }); + + it("should not render org routes when excluded from navigation items", async () => { + renderSettingsNavigation(ITEMS_WITHOUT_ORG); + + await screen.findByTestId("settings-navbar"); + + const orgMembersLink = screen.queryByText("Organization Members"); + const orgLink = screen.queryByText("Organization"); + + expect(orgMembersLink).not.toBeInTheDocument(); + expect(orgLink).not.toBeInTheDocument(); + }); + + it("should render all non-org SAAS items regardless of which items are passed", async () => { + renderSettingsNavigation(SAAS_NAV_ITEMS); + + await screen.findByTestId("settings-navbar"); + + // Verify non-org items are rendered (using their i18n keys as text since + // react-i18next returns the key when no translation is loaded) + const secretsLink = await screen.findByText("SETTINGS$NAV_SECRETS"); + const apiKeysLink = await screen.findByText("SETTINGS$NAV_API_KEYS"); + + expect(secretsLink).toBeInTheDocument(); + expect(apiKeysLink).toBeInTheDocument(); + }); + + it("should render empty nav when given an empty items list", async () => { + renderSettingsNavigation([]); + + await screen.findByTestId("settings-navbar"); + + // No nav links should be rendered + const orgMembersLink = screen.queryByText("Organization Members"); + const orgLink = screen.queryByText("Organization"); + + expect(orgMembersLink).not.toBeInTheDocument(); + expect(orgLink).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/components/features/user/user-context-menu.test.tsx b/frontend/__tests__/components/features/user/user-context-menu.test.tsx new file mode 100644 index 0000000000..f69de4c0d3 --- /dev/null +++ b/frontend/__tests__/components/features/user/user-context-menu.test.tsx @@ -0,0 +1,633 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router"; +import { UserContextMenu } from "#/components/features/user/user-context-menu"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { GetComponentPropTypes } from "#/utils/get-component-prop-types"; +import { + INITIAL_MOCK_ORGS, + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, +} from "#/mocks/org-handlers"; +import AuthService from "#/api/auth-service/auth-service.api"; +import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; +import OptionService from "#/api/option-service/option-service.api"; +import { OrganizationMember } from "#/types/org"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; + +type UserContextMenuProps = GetComponentPropTypes; + +function UserContextMenuWithRootOutlet({ + type, + onClose, + onOpenInviteModal, +}: UserContextMenuProps) { + return ( +
+
+ +
+ ); +} + +const renderUserContextMenu = ({ + type, + onClose, + onOpenInviteModal, +}: UserContextMenuProps) => + render( + , + { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + +const { navigateMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), +})); + +vi.mock("react-router", async (importActual) => ({ + ...(await importActual()), + useNavigate: () => navigateMock, + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + +// Mock useIsAuthed to return authenticated state +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +const createMockUser = ( + overrides: Partial = {}, +): OrganizationMember => ({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + ...overrides, +}); + +const seedActiveUser = (user: Partial) => { + useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser(user), + ); +}; + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization", + ORG$PERSONAL_WORKSPACE: "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +describe("UserContextMenu", () => { + beforeEach(() => { + // Ensure clean state at the start of each test + vi.restoreAllMocks(); + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + navigateMock.mockClear(); + // Reset Zustand store to ensure clean state between tests + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should render the default context items for a user", () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + screen.getByTestId("org-selector"); + screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); + + expect( + screen.queryByText("ORG$INVITE_ORG_MEMBERS"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("ORG$ORGANIZATION_MEMBERS"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("COMMON$ORGANIZATION"), + ).not.toBeInTheDocument(); + }); + + it("should render navigation items from SAAS_NAV_ITEMS (except organization-members/org)", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out) + const expectedItems = SAAS_NAV_ITEMS.filter( + (item) => + item.to !== "/settings/org-members" && + item.to !== "/settings/org" && + item.to !== "/settings/billing", + ); + + await waitFor(() => { + expectedItems.forEach((item) => { + expect(screen.getByText(item.text)).toBeInTheDocument(); + }); + }); + }); + + it("should render navigation items from SAAS_NAV_ITEMS when user role is admin (except organization-members/org)", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + seedActiveUser({ role: "admin" }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out) + const expectedItems = SAAS_NAV_ITEMS.filter( + (item) => + item.to !== "/settings/org-members" && item.to !== "/settings/org", + ); + + await waitFor(() => { + expectedItems.forEach((item) => { + expect(screen.getByText(item.text)).toBeInTheDocument(); + }); + }); + }); + + it("should not display Organization Members menu item for regular users (filtered out)", () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Organization Members is filtered out from nav items for all users + expect(screen.queryByText("Organization Members")).not.toBeInTheDocument(); + }); + + it("should render a documentation link", () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + const docsLink = screen.getByText("SIDEBAR$DOCS").closest("a"); + expect(docsLink).toHaveAttribute("href", "https://docs.openhands.dev"); + expect(docsLink).toHaveAttribute("target", "_blank"); + }); + + describe("OSS mode", () => { + beforeEach(() => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "oss", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + }); + + it("should render OSS_NAV_ITEMS when in OSS mode", async () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for the config to load and OSS nav items to appear + await waitFor(() => { + OSS_NAV_ITEMS.forEach((item) => { + expect(screen.getByText(item.text)).toBeInTheDocument(); + }); + }); + + // Verify SAAS-only items are NOT rendered (e.g., Billing) + expect( + screen.queryByText("SETTINGS$NAV_BILLING"), + ).not.toBeInTheDocument(); + }); + + it("should not display Organization Members menu item in OSS mode", async () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for the config to load + await waitFor(() => { + expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument(); + }); + + // Verify Organization Members is NOT rendered in OSS mode + expect( + screen.queryByText("Organization Members"), + ).not.toBeInTheDocument(); + }); + }); + + describe("HIDE_LLM_SETTINGS feature flag", () => { + it("should hide LLM settings link when HIDE_LLM_SETTINGS is true", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: true, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + await waitFor(() => { + // Other nav items should still be visible + expect(screen.getByText("SETTINGS$NAV_USER")).toBeInTheDocument(); + // LLM settings (to: "/settings") should NOT be visible + expect( + screen.queryByText("COMMON$LANGUAGE_MODEL_LLM"), + ).not.toBeInTheDocument(); + }); + }); + + it("should show LLM settings link when HIDE_LLM_SETTINGS is false", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + await waitFor(() => { + expect( + screen.getByText("COMMON$LANGUAGE_MODEL_LLM"), + ).toBeInTheDocument(); + }); + }); + }); + + it("should render additional context items when user is an admin", () => { + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + screen.getByTestId("org-selector"); + screen.getByText("ORG$INVITE_ORG_MEMBERS"); + screen.getByText("ORG$ORGANIZATION_MEMBERS"); + screen.getByText("COMMON$ORGANIZATION"); + }); + + it("should render additional context items when user is an owner", () => { + renderUserContextMenu({ type: "owner", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + screen.getByTestId("org-selector"); + screen.getByText("ORG$INVITE_ORG_MEMBERS"); + screen.getByText("ORG$ORGANIZATION_MEMBERS"); + screen.getByText("COMMON$ORGANIZATION"); + }); + + it("should call the logout handler when Logout is clicked", async () => { + const logoutSpy = vi.spyOn(AuthService, "logout"); + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); + await userEvent.click(logoutButton); + + expect(logoutSpy).toHaveBeenCalledOnce(); + }); + + it("should have correct navigation links for nav items", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, // Enable billing so billing link is shown + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + seedActiveUser({ role: "admin" }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load and test a few representative nav items have the correct href + await waitFor(() => { + const userLink = screen.getByText("SETTINGS$NAV_USER").closest("a"); + expect(userLink).toHaveAttribute("href", "/settings/user"); + }); + + await waitFor(() => { + const billingLink = screen.getByText("SETTINGS$NAV_BILLING").closest("a"); + expect(billingLink).toHaveAttribute("href", "/settings/billing"); + }); + + await waitFor(() => { + const integrationsLink = screen + .getByText("SETTINGS$NAV_INTEGRATIONS") + .closest("a"); + expect(integrationsLink).toHaveAttribute( + "href", + "/settings/integrations", + ); + }); + }); + + it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => { + // Mock a team org so org management buttons are visible (not personal org) + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for orgs to load so org management buttons are visible + const manageOrganizationMembersButton = await screen.findByText( + "ORG$ORGANIZATION_MEMBERS", + ); + await userEvent.click(manageOrganizationMembersButton); + + expect(navigateMock).toHaveBeenCalledExactlyOnceWith( + "/settings/org-members", + ); + }); + + it("should navigate to /settings/org when Manage Account is clicked", async () => { + // Mock a team org so org management buttons are visible (not personal org) + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for orgs to load so org management buttons are visible + const manageAccountButton = await screen.findByText( + "COMMON$ORGANIZATION", + ); + await userEvent.click(manageAccountButton); + + expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/settings/org"); + }); + + it("should call the onClose handler when clicking outside the context menu", async () => { + const onCloseMock = vi.fn(); + renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn }); + + const contextMenu = screen.getByTestId("user-context-menu"); + await userEvent.click(contextMenu); + + expect(onCloseMock).not.toHaveBeenCalled(); + + // Simulate clicking outside the context menu + await userEvent.click(document.body); + + expect(onCloseMock).toHaveBeenCalled(); + }); + + it("should call the onClose handler after each action", async () => { + // Mock a team org so org management buttons are visible + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const onCloseMock = vi.fn(); + renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn }); + + const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); + await userEvent.click(logoutButton); + expect(onCloseMock).toHaveBeenCalledTimes(1); + + // Wait for orgs to load so org management buttons are visible + const manageOrganizationMembersButton = await screen.findByText( + "ORG$ORGANIZATION_MEMBERS", + ); + await userEvent.click(manageOrganizationMembersButton); + expect(onCloseMock).toHaveBeenCalledTimes(2); + + const manageAccountButton = screen.getByText("COMMON$ORGANIZATION"); + await userEvent.click(manageAccountButton); + expect(onCloseMock).toHaveBeenCalledTimes(3); + }); + + describe("Personal org vs team org visibility", () => { + it("should not show Organization and Organization Members settings items when personal org is selected", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + // Pre-select the personal org in the Zustand store + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for org selector to load and org management buttons to disappear + // (they disappear when personal org is selected) + await waitFor(() => { + expect( + screen.queryByText("ORG$ORGANIZATION_MEMBERS"), + ).not.toBeInTheDocument(); + }); + + expect( + screen.queryByText("COMMON$ORGANIZATION"), + ).not.toBeInTheDocument(); + }); + + it("should not show Billing settings item when team org is selected", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for org selector to load and billing to disappear + // (billing disappears when team org is selected) + await waitFor(() => { + expect( + screen.queryByText("SETTINGS$NAV_BILLING"), + ).not.toBeInTheDocument(); + }); + }); + }); + + it("should call onOpenInviteModal and onClose when Invite Organization Member is clicked", async () => { + // Mock a team org so org management buttons are visible (not personal org) + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const onCloseMock = vi.fn(); + const onOpenInviteModalMock = vi.fn(); + renderUserContextMenu({ + type: "admin", + onClose: onCloseMock, + onOpenInviteModal: onOpenInviteModalMock, + }); + + // Wait for orgs to load so org management buttons are visible + const inviteButton = await screen.findByText("ORG$INVITE_ORG_MEMBERS"); + await userEvent.click(inviteButton); + + expect(onOpenInviteModalMock).toHaveBeenCalledOnce(); + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + test("the user can change orgs", async () => { + // Mock SaaS mode and organizations for this test + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: INITIAL_MOCK_ORGS, + currentOrgId: INITIAL_MOCK_ORGS[0].id, + }); + + const user = userEvent.setup(); + const onCloseMock = vi.fn(); + renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn }); + + // Wait for org selector to appear (it may take a moment for config to load) + const orgSelector = await screen.findByTestId("org-selector"); + expect(orgSelector).toBeInTheDocument(); + + // Wait for organizations to load (indicated by org name appearing in the dropdown) + // INITIAL_MOCK_ORGS[0] is a personal org, so it displays "Personal Workspace" + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace"); + }); + + // Open the dropdown by clicking the trigger + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + // Select a different organization + const orgOption = screen.getByRole("option", { + name: INITIAL_MOCK_ORGS[1].name, + }); + await user.click(orgOption); + + expect(onCloseMock).not.toHaveBeenCalled(); + + // Verify that the dropdown shows the selected organization + expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name); + }); +}); diff --git a/frontend/__tests__/components/landing-translations.test.tsx b/frontend/__tests__/components/landing-translations.test.tsx deleted file mode 100644 index feb6f250f6..0000000000 --- a/frontend/__tests__/components/landing-translations.test.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { test, expect, describe, vi } from "vitest"; -import { useTranslation } from "react-i18next"; -import translations from "../../src/i18n/translation.json"; -import { UserAvatar } from "../../src/components/features/sidebar/user-avatar"; - -vi.mock("@heroui/react", () => ({ - Tooltip: ({ - content, - children, - }: { - content: string; - children: React.ReactNode; - }) => ( -
- {children} -
{content}
-
- ), -})); - -const supportedLanguages = [ - "en", - "ja", - "zh-CN", - "zh-TW", - "ko-KR", - "de", - "no", - "it", - "pt", - "es", - "ar", - "fr", - "tr", -]; - -// Helper function to check if a translation exists for all supported languages -function checkTranslationExists(key: string) { - const missingTranslations: string[] = []; - - const translationEntry = ( - translations as Record> - )[key]; - if (!translationEntry) { - throw new Error( - `Translation key "${key}" does not exist in translation.json`, - ); - } - - for (const lang of supportedLanguages) { - if (!translationEntry[lang]) { - missingTranslations.push(lang); - } - } - - return missingTranslations; -} - -// Helper function to find duplicate translation keys -function findDuplicateKeys(obj: Record) { - const seen = new Set(); - const duplicates = new Set(); - - // Only check top-level keys as these are our translation keys - for (const key in obj) { - if (seen.has(key)) { - duplicates.add(key); - } else { - seen.add(key); - } - } - - return Array.from(duplicates); -} - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translationEntry = ( - translations as Record> - )[key]; - return translationEntry?.ja || key; - }, - }), -})); - -describe("Landing page translations", () => { - test("should render Japanese translations correctly", () => { - // Mock a simple component that uses the translations - const TestComponent = () => { - const { t } = useTranslation(); - return ( -
- {}} /> -
-

{t("LANDING$TITLE")}

- - - - - -
-
- {t("WORKSPACE$TERMINAL_TAB_LABEL")} - {t("WORKSPACE$BROWSER_TAB_LABEL")} - {t("WORKSPACE$JUPYTER_TAB_LABEL")} - {t("WORKSPACE$CODE_EDITOR_TAB_LABEL")} -
-
{t("WORKSPACE$TITLE")}
- -
- {t("TERMINAL$WAITING_FOR_CLIENT")} - {t("STATUS$CONNECTED")} - {t("STATUS$CONNECTED_TO_SERVER")} -
-
- {`5 ${t("TIME$MINUTES_AGO")}`} - {`2 ${t("TIME$HOURS_AGO")}`} - {`3 ${t("TIME$DAYS_AGO")}`} -
-
- ); - }; - - render(); - - // Check main content translations - expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument(); - expect(screen.getByText("VS Codeで開く")).toBeInTheDocument(); - expect( - screen.getByText("テストカバレッジを向上させる"), - ).toBeInTheDocument(); - expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument(); - expect(screen.getByText("READMEを改善")).toBeInTheDocument(); - expect(screen.getByText("依存関係を整理")).toBeInTheDocument(); - - // Check tab labels - const tabs = screen.getByTestId("tabs"); - expect(tabs).toHaveTextContent("ターミナル"); - expect(tabs).toHaveTextContent("ブラウザ"); - expect(tabs).toHaveTextContent("Jupyter"); - expect(tabs).toHaveTextContent("コードエディタ"); - - // Check workspace label and new project button - expect(screen.getByTestId("workspace-label")).toHaveTextContent( - "ワークスペース", - ); - expect(screen.getByTestId("new-project")).toHaveTextContent( - "新規プロジェクト", - ); - - // Check status messages - const status = screen.getByTestId("status"); - expect(status).toHaveTextContent("クライアントの準備を待機中"); - expect(status).toHaveTextContent("接続済み"); - expect(status).toHaveTextContent("サーバーに接続済み"); - - // Check time-related translations - const time = screen.getByTestId("time"); - expect(time).toHaveTextContent("5 分前"); - expect(time).toHaveTextContent("2 時間前"); - expect(time).toHaveTextContent("3 日前"); - }); - - test("all translation keys should have translations for all supported languages", () => { - // Test all translation keys used in the component - const translationKeys = [ - "LANDING$TITLE", - "VSCODE$OPEN", - "SUGGESTIONS$INCREASE_TEST_COVERAGE", - "SUGGESTIONS$AUTO_MERGE_PRS", - "SUGGESTIONS$FIX_README", - "SUGGESTIONS$CLEAN_DEPENDENCIES", - "WORKSPACE$TERMINAL_TAB_LABEL", - "WORKSPACE$BROWSER_TAB_LABEL", - "WORKSPACE$JUPYTER_TAB_LABEL", - "WORKSPACE$CODE_EDITOR_TAB_LABEL", - "WORKSPACE$TITLE", - "PROJECT$NEW_PROJECT", - "TERMINAL$WAITING_FOR_CLIENT", - "STATUS$CONNECTED", - "STATUS$CONNECTED_TO_SERVER", - "TIME$MINUTES_AGO", - "TIME$HOURS_AGO", - "TIME$DAYS_AGO", - ]; - - // Check all keys and collect missing translations - const missingTranslationsMap = new Map(); - translationKeys.forEach((key) => { - const missing = checkTranslationExists(key); - if (missing.length > 0) { - missingTranslationsMap.set(key, missing); - } - }); - - // If any translations are missing, throw an error with all missing translations - if (missingTranslationsMap.size > 0) { - const errorMessage = Array.from(missingTranslationsMap.entries()) - .map( - ([key, langs]) => - `\n- "${key}" is missing translations for: ${langs.join(", ")}`, - ) - .join(""); - throw new Error(`Missing translations:${errorMessage}`); - } - }); - - test("translation file should not have duplicate keys", () => { - const duplicates = findDuplicateKeys(translations); - - if (duplicates.length > 0) { - throw new Error( - `Found duplicate translation keys: ${duplicates.join(", ")}`, - ); - } - }); -}); diff --git a/frontend/__tests__/components/ui/dropdown.test.tsx b/frontend/__tests__/components/ui/dropdown.test.tsx new file mode 100644 index 0000000000..55a1fa767a --- /dev/null +++ b/frontend/__tests__/components/ui/dropdown.test.tsx @@ -0,0 +1,429 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { Dropdown } from "#/ui/dropdown/dropdown"; + +const mockOptions = [ + { value: "1", label: "Option 1" }, + { value: "2", label: "Option 2" }, + { value: "3", label: "Option 3" }, +]; + +describe("Dropdown", () => { + describe("Trigger", () => { + it("should render a custom trigger button", () => { + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + + expect(trigger).toBeInTheDocument(); + }); + + it("should open dropdown on trigger click", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText("Option 1")).not.toBeInTheDocument(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const listbox = screen.getByRole("listbox"); + expect(listbox).toBeInTheDocument(); + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByText("Option 3")).toBeInTheDocument(); + }); + }); + + describe("Type-ahead / Search", () => { + it("should filter options based on input text", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "Option 1"); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Option 3")).not.toBeInTheDocument(); + }); + + it("should be case-insensitive by default", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "option 1"); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); + }); + + it("should show all options when search is cleared", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "Option 1"); + await user.clear(input); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByText("Option 3")).toBeInTheDocument(); + }); + }); + + describe("Empty state", () => { + it("should display empty state when no options provided", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect(screen.getByText("No options")).toBeInTheDocument(); + }); + + it("should render custom empty state message", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect(screen.getByText("Nothing found")).toBeInTheDocument(); + }); + }); + + describe("Single selection", () => { + it("should select an option on click", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const option = screen.getByText("Option 1"); + await user.click(option); + + expect(screen.getByRole("combobox")).toHaveValue("Option 1"); + }); + + it("should close dropdown after selection", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByText("Option 1")); + + expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); + }); + + it("should display selected option in input", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByText("Option 1")); + + expect(screen.getByRole("combobox")).toHaveValue("Option 1"); + }); + + it("should highlight currently selected option in list", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + await user.click(trigger); + + const selectedOption = screen.getByRole("option", { name: "Option 1" }); + expect(selectedOption).toHaveAttribute("aria-selected", "true"); + }); + + it("should preserve selected value in input and show all options when reopening dropdown", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + // Reopen the dropdown + await user.click(trigger); + + const input = screen.getByRole("combobox"); + expect(input).toHaveValue("Option 1"); + expect( + screen.getByRole("option", { name: "Option 1" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option 2" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option 3" }), + ).toBeInTheDocument(); + }); + }); + + describe("Clear button", () => { + it("should not render clear button by default", () => { + render(); + + expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument(); + }); + + it("should render clear button when clearable prop is true and has value", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + expect(screen.getByTestId("dropdown-clear")).toBeInTheDocument(); + }); + + it("should clear selection and search input when clear button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + const clearButton = screen.getByTestId("dropdown-clear"); + await user.click(clearButton); + + expect(screen.getByRole("combobox")).toHaveValue(""); + }); + + it("should not render clear button when there is no selection", () => { + render(); + + expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument(); + }); + + it("should show placeholder after clearing selection", async () => { + const user = userEvent.setup(); + render( + , + ); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + const clearButton = screen.getByTestId("dropdown-clear"); + await user.click(clearButton); + + const input = screen.getByRole("combobox"); + expect(input).toHaveValue(""); + }); + }); + + describe("Loading state", () => { + it("should not display loading indicator by default", () => { + render(); + + expect(screen.queryByTestId("dropdown-loading")).not.toBeInTheDocument(); + }); + + it("should display loading indicator when loading prop is true", () => { + render(); + + expect(screen.getByTestId("dropdown-loading")).toBeInTheDocument(); + }); + + it("should disable interaction while loading", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + }); + + describe("Disabled state", () => { + it("should not open dropdown when disabled", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + it("should have disabled attribute on trigger", () => { + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + expect(trigger).toBeDisabled(); + }); + }); + + describe("Placeholder", () => { + it("should display placeholder text when no value selected", () => { + render(); + + const input = screen.getByRole("combobox"); + expect(input).toHaveAttribute("placeholder", "Select an option"); + }); + }); + + describe("Default value", () => { + it("should display defaultValue in input on mount", () => { + render(); + + const input = screen.getByRole("combobox"); + expect(input).toHaveValue("Option 1"); + }); + + it("should show all options when opened with defaultValue", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect( + screen.getByRole("option", { name: "Option 1" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option 2" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option 3" }), + ).toBeInTheDocument(); + }); + + it("should restore input value when closed with Escape", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "test"); + await user.keyboard("{Escape}"); + + expect(input).toHaveValue("Option 1"); + }); + }); + + describe("onChange", () => { + it("should call onChange with selected item when option is clicked", async () => { + const user = userEvent.setup(); + const onChangeMock = vi.fn(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + expect(onChangeMock).toHaveBeenCalledWith(mockOptions[0]); + }); + + it("should call onChange with null when selection is cleared", async () => { + const user = userEvent.setup(); + const onChangeMock = vi.fn(); + render( + , + ); + + const clearButton = screen.getByTestId("dropdown-clear"); + await user.click(clearButton); + + expect(onChangeMock).toHaveBeenCalledWith(null); + }); + }); + + describe("Controlled mode", () => { + it.todo("should reflect external value changes"); + it.todo("should call onChange when selection changes"); + it.todo("should not update internal state when controlled"); + }); + + describe("Uncontrolled mode", () => { + it.todo("should manage selection state internally"); + it.todo("should call onChange when selection changes"); + it.todo("should support defaultValue prop"); + }); + + describe("testId prop", () => { + it("should apply custom testId to the root container", () => { + render(); + + expect(screen.getByTestId("org-dropdown")).toBeInTheDocument(); + }); + }); + + describe("Cursor position preservation", () => { + it("should keep menu open when clicking the input while dropdown is open", async () => { + // Without a stateReducer, Downshift's default InputClick behavior + // toggles the menu (closes it if already open). The stateReducer + // should override this to keep the menu open so users can click + // to reposition their cursor without losing the dropdown. + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + // Menu should be open + expect(screen.getByText("Option 1")).toBeInTheDocument(); + + // Click the input itself (simulates clicking to reposition cursor) + const input = screen.getByRole("combobox"); + await user.click(input); + + // Menu should still be open — not toggled closed + expect(screen.getByText("Option 1")).toBeInTheDocument(); + }); + + it("should still filter options correctly after typing with cursor fix", async () => { + // Verifies that the direct onChange handler (which bypasses Downshift's + // default onInputValueChange for cursor preservation) still updates + // the search/filter state correctly. + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "Option 1"); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Option 3")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx index e32931f053..936586168d 100644 --- a/frontend/__tests__/components/user-actions.test.tsx +++ b/frontend/__tests__/components/user-actions.test.tsx @@ -1,11 +1,64 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, afterEach, beforeEach, test } from "vitest"; import userEvent from "@testing-library/user-event"; -import { UserActions } from "#/components/features/sidebar/user-actions"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; import { MemoryRouter } from "react-router"; import { ReactElement } from "react"; +import { UserActions } from "#/components/features/sidebar/user-actions"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; import { renderWithProviders } from "../../test-utils"; +vi.mock("react-router", async (importActual) => ({ + ...(await importActual()), + useNavigate: () => vi.fn(), + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization", + ORG$PERSONAL_WORKSPACE: "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +const renderUserActions = (props = { hasAvatar: true }) => { + render( + , + { + wrapper: ({ children }) => ( + + + {children} + + + ), + }, + ); +}; + // Create mocks for all the hooks we need const useIsAuthedMock = vi .fn() @@ -38,9 +91,8 @@ describe("UserActions", () => { const onLogoutMock = vi.fn(); // Create a wrapper with MemoryRouter and renderWithProviders - const renderWithRouter = (ui: ReactElement) => { - return renderWithProviders({ui}); - }; + const renderWithRouter = (ui: ReactElement) => + renderWithProviders({ui}); beforeEach(() => { // Reset all mocks to default values before each test @@ -61,29 +113,11 @@ describe("UserActions", () => { }); it("should render", () => { - renderWithRouter(); - + renderUserActions(); expect(screen.getByTestId("user-actions")).toBeInTheDocument(); expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); }); - it("should call onLogout and close the menu when the logout option is clicked", async () => { - renderWithRouter( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - - const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); - await user.click(logoutOption); - - expect(onLogoutMock).toHaveBeenCalledOnce(); - }); - it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => { // Set isAuthed to false for this test useIsAuthedMock.mockReturnValue({ data: false, isLoading: false }); @@ -96,29 +130,31 @@ describe("UserActions", () => { providers: [{ id: "github", name: "GitHub" }], }); - renderWithRouter(); + renderUserActions(); const userAvatar = screen.getByTestId("user-avatar"); await user.click(userAvatar); // Context menu should NOT appear because user is not authenticated - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); + }); + + it("should NOT show context menu when user is undefined and avatar is hovered", async () => { + renderUserActions({ hasAvatar: false }); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + + // Context menu should NOT appear because user is undefined + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); }); it("should show context menu even when user has no avatar_url", async () => { - renderWithRouter( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); // Context menu SHOULD appear because user object exists (even with empty avatar_url) - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); }); it("should NOT be able to access logout when user is not authenticated", async () => { @@ -133,15 +169,13 @@ describe("UserActions", () => { providers: [{ id: "github", name: "GitHub" }], }); - renderWithRouter(); + renderWithRouter(); const userAvatar = screen.getByTestId("user-avatar"); await user.click(userAvatar); // Context menu should NOT appear because user is not authenticated - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); // Logout option should NOT be accessible when user is not authenticated expect( @@ -161,16 +195,12 @@ describe("UserActions", () => { providers: [{ id: "github", name: "GitHub" }], }); - const { unmount } = renderWithRouter( - , - ); + const { unmount } = renderWithRouter(); // Initially no user and not authenticated - menu should not appear - let userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); // Unmount the first component unmount(); @@ -188,10 +218,7 @@ describe("UserActions", () => { // Render a new component with user prop and authentication renderWithRouter( - , + , ); // Component should render correctly @@ -199,12 +226,10 @@ describe("UserActions", () => { expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); // Menu should now work with user defined and authenticated - userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); + const userActionsEl = screen.getByTestId("user-actions"); + await user.hover(userActionsEl); - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); }); it("should handle user prop changing from defined to undefined", async () => { @@ -219,18 +244,13 @@ describe("UserActions", () => { }); const { rerender } = renderWithRouter( - , + , ); - // Click to open menu - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); + // Hover to open menu + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); // Set authentication to false for the rerender useIsAuthedMock.mockReturnValue({ data: false, isLoading: false }); @@ -246,14 +266,12 @@ describe("UserActions", () => { // Remove user prop - menu should disappear because user is no longer authenticated rerender( - + , ); // Context menu should NOT be visible when user becomes unauthenticated - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); // Logout option should not be accessible expect( @@ -272,20 +290,168 @@ describe("UserActions", () => { providers: [{ id: "github", name: "GitHub" }], }); - renderWithRouter( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); // Context menu should still appear even when loading + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); + }); + + test("context menu should default to user role", async () => { + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + + // Verify logout is present + expect(screen.getByTestId("user-context-menu")).toHaveTextContent( + "ACCOUNT_SETTINGS$LOGOUT", + ); + // Verify nav items are present (e.g., settings nav items) + expect(screen.getByTestId("user-context-menu")).toHaveTextContent( + "SETTINGS$NAV_USER", + ); + // Verify admin-only items are NOT present for user role expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); + screen.queryByText("ORG$MANAGE_ORGANIZATION_MEMBERS"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("ORG$MANAGE_ORGANIZATION"), + ).not.toBeInTheDocument(); + }); + + test("should NOT show Team and Organization nav items when personal workspace is selected", async () => { + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + + // Team and Organization nav links should NOT be visible when no org is selected (personal workspace) + expect(screen.queryByText("Team")).not.toBeInTheDocument(); + expect(screen.queryByText("Organization")).not.toBeInTheDocument(); + }); + + it("should show context menu on hover", async () => { + renderUserActions(); + + const userActions = screen.getByTestId("user-actions"); + const contextMenu = screen.getByTestId("user-context-menu"); + + // Menu is in DOM but hidden via CSS (opacity-0, pointer-events-none) + expect(contextMenu.parentElement).toHaveClass("opacity-0"); + expect(contextMenu.parentElement).toHaveClass("pointer-events-none"); + + // Hover over the user actions area + await user.hover(userActions); + + // Menu should be visible on hover (CSS classes change via group-hover) + expect(contextMenu).toBeVisible(); + }); + + it("should have pointer-events-none on hover bridge pseudo-element to allow menu item clicks", async () => { + renderUserActions(); + + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + + const contextMenu = screen.getByTestId("user-context-menu"); + const hoverBridgeContainer = contextMenu.parentElement; + + // The hover bridge uses a ::before pseudo-element for diagonal mouse movement + // This pseudo-element MUST have pointer-events-none to allow clicks through to menu items + // The class should include "before:pointer-events-none" to prevent the hover bridge from blocking clicks + expect(hoverBridgeContainer?.className).toContain( + "before:pointer-events-none", + ); + }); + + describe("Org selector dropdown state reset when context menu hides", () => { + // These tests verify that the org selector dropdown resets its internal + // state (search text, open/closed) when the context menu hides and + // reappears. Without this, stale state persists because the context + // menu is hidden via CSS (opacity/pointer-events) rather than unmounted. + + beforeEach(() => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should reset org selector search text when context menu hides and reappears", async () => { + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + + // Hover to show context menu + await user.hover(userActions); + + // Wait for orgs to load and auto-select + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue( + MOCK_PERSONAL_ORG.name, + ); + }); + + // Open dropdown and type search text + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + const input = screen.getByRole("combobox"); + await user.clear(input); + await user.type(input, "search text"); + expect(input).toHaveValue("search text"); + + // Unhover to hide context menu, then hover again + await user.unhover(userActions); + await user.hover(userActions); + + // Org selector should be reset — showing selected org name, not search text + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue( + MOCK_PERSONAL_ORG.name, + ); + }); + }); + + it("should reset dropdown to collapsed state when context menu hides and reappears", async () => { + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + + // Hover to show context menu + await user.hover(userActions); + + // Wait for orgs to load + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue( + MOCK_PERSONAL_ORG.name, + ); + }); + + // Open dropdown and type to change its state + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + const input = screen.getByRole("combobox"); + await user.clear(input); + await user.type(input, "Acme"); + expect(input).toHaveValue("Acme"); + + // Unhover to hide context menu, then hover again + await user.unhover(userActions); + await user.hover(userActions); + + // Wait for fresh component with org data + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue( + MOCK_PERSONAL_ORG.name, + ); + }); + + // Dropdown should be collapsed (closed) after reset + expect(screen.getByTestId("dropdown-trigger")).toHaveAttribute( + "aria-expanded", + "false", + ); + // No option elements should be rendered + expect(screen.queryAllByRole("option")).toHaveLength(0); + }); }); }); diff --git a/frontend/__tests__/components/user-avatar.test.tsx b/frontend/__tests__/components/user-avatar.test.tsx index 5e46a6643e..534456761b 100644 --- a/frontend/__tests__/components/user-avatar.test.tsx +++ b/frontend/__tests__/components/user-avatar.test.tsx @@ -1,40 +1,18 @@ import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { UserAvatar } from "#/components/features/sidebar/user-avatar"; describe("UserAvatar", () => { - const onClickMock = vi.fn(); - - afterEach(() => { - onClickMock.mockClear(); - }); - it("(default) should render the placeholder avatar when the user is logged out", () => { - render(); + render(); expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); expect( screen.getByLabelText("USER$AVATAR_PLACEHOLDER"), ).toBeInTheDocument(); }); - it("should call onClick when clicked", async () => { - const user = userEvent.setup(); - render(); - - const userAvatarContainer = screen.getByTestId("user-avatar"); - await user.click(userAvatarContainer); - - expect(onClickMock).toHaveBeenCalledOnce(); - }); - it("should display the user's avatar when available", () => { - render( - , - ); + render(); expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument(); expect( @@ -43,24 +21,20 @@ describe("UserAvatar", () => { }); it("should display a loading spinner instead of an avatar when isLoading is true", () => { - const { rerender } = render(); + const { rerender } = render(); expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); expect( screen.getByLabelText("USER$AVATAR_PLACEHOLDER"), ).toBeInTheDocument(); - rerender(); + rerender(); expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); expect( screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"), ).not.toBeInTheDocument(); rerender( - , + , ); expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument(); diff --git a/frontend/__tests__/helpers/mock-config.ts b/frontend/__tests__/helpers/mock-config.ts deleted file mode 100644 index 36141a4773..0000000000 --- a/frontend/__tests__/helpers/mock-config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { WebClientConfig } from "#/api/option-service/option.types"; - -/** - * Creates a mock WebClientConfig with all required fields. - * Use this helper to create test config objects with sensible defaults. - */ -export const createMockWebClientConfig = ( - overrides: Partial = {}, -): WebClientConfig => ({ - app_mode: "oss", - posthog_client_key: "test-posthog-key", - feature_flags: { - enable_billing: false, - hide_llm_settings: false, - enable_jira: false, - enable_jira_dc: false, - enable_linear: false, - hide_users_page: false, - hide_billing_page: false, - hide_integrations_page: false, - ...overrides.feature_flags, - }, - providers_configured: [], - maintenance_start_time: null, - auth_url: null, - recaptcha_site_key: null, - faulty_models: [], - error_message: null, - updated_at: new Date().toISOString(), - github_app_slug: null, - ...overrides, -}); diff --git a/frontend/__tests__/hooks/mutation/use-invite-members-batch.test.tsx b/frontend/__tests__/hooks/mutation/use-invite-members-batch.test.tsx new file mode 100644 index 0000000000..b30d9104e1 --- /dev/null +++ b/frontend/__tests__/hooks/mutation/use-invite-members-batch.test.tsx @@ -0,0 +1,45 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useInviteMembersBatch } from "#/hooks/mutation/use-invite-members-batch"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +// Mock the useRevalidator hook from react-router +vi.mock("react-router", () => ({ + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + +describe("useInviteMembersBatch", () => { + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should throw an error when organizationId is null", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + }, + }); + + const { result } = renderHook(() => useInviteMembersBatch(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + // Attempt to mutate without organizationId + result.current.mutate({ emails: ["test@example.com"] }); + + // Should fail with an error about missing organizationId + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe("Organization ID is required"); + }); +}); diff --git a/frontend/__tests__/hooks/mutation/use-remove-member.test.tsx b/frontend/__tests__/hooks/mutation/use-remove-member.test.tsx new file mode 100644 index 0000000000..3eebd139f5 --- /dev/null +++ b/frontend/__tests__/hooks/mutation/use-remove-member.test.tsx @@ -0,0 +1,45 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useRemoveMember } from "#/hooks/mutation/use-remove-member"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +// Mock the useRevalidator hook from react-router +vi.mock("react-router", () => ({ + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + +describe("useRemoveMember", () => { + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should throw an error when organizationId is null", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + }, + }); + + const { result } = renderHook(() => useRemoveMember(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + // Attempt to mutate without organizationId + result.current.mutate({ userId: "user-123" }); + + // Should fail with an error about missing organizationId + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe("Organization ID is required"); + }); +}); diff --git a/frontend/__tests__/hooks/query/use-organizations.test.tsx b/frontend/__tests__/hooks/query/use-organizations.test.tsx new file mode 100644 index 0000000000..a42a5b3e77 --- /dev/null +++ b/frontend/__tests__/hooks/query/use-organizations.test.tsx @@ -0,0 +1,174 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useOrganizations } from "#/hooks/query/use-organizations"; +import type { Organization } from "#/types/org"; + +vi.mock("#/api/organization-service/organization-service.api", () => ({ + organizationService: { + getOrganizations: vi.fn(), + }, +})); + +// Mock useIsAuthed to return authenticated +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature) +vi.mock("#/hooks/query/use-config", () => ({ + useConfig: () => ({ data: { app_mode: "saas" } }), +})); + +const mockGetOrganizations = vi.mocked(organizationService.getOrganizations); + +function createMinimalOrg( + id: string, + name: string, + is_personal?: boolean, +): Organization { + return { + id, + name, + is_personal, + contact_name: "", + contact_email: "", + conversation_expiration: 0, + agent: "", + default_max_iterations: 0, + security_analyzer: "", + confirmation_mode: false, + default_llm_model: "", + default_llm_api_key_for_byor: "", + default_llm_base_url: "", + remote_runtime_resource_factor: 0, + enable_default_condenser: false, + billing_margin: 0, + enable_proactive_conversation_starters: false, + sandbox_base_container_image: "", + sandbox_runtime_container_image: "", + org_version: 0, + mcp_config: { tools: [], settings: {} }, + search_api_key: null, + sandbox_api_key: null, + max_budget_per_task: 0, + enable_solvability_analysis: false, + v1_enabled: false, + credits: 0, + }; +} + +describe("useOrganizations", () => { + let queryClient: QueryClient; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + vi.clearAllMocks(); + }); + + it("sorts personal workspace first, then non-personal alphabetically by name", async () => { + // API returns unsorted: Beta, Personal, Acme, All Hands + mockGetOrganizations.mockResolvedValue({ + items: [ + createMinimalOrg("3", "Beta LLC", false), + createMinimalOrg("1", "Personal Workspace", true), + createMinimalOrg("2", "Acme Corp", false), + createMinimalOrg("4", "All Hands AI", false), + ], + currentOrgId: "1", + }); + + const { result } = renderHook(() => useOrganizations(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const { organizations } = result.current.data!; + expect(organizations).toHaveLength(4); + expect(organizations[0].id).toBe("1"); + expect(organizations[0].is_personal).toBe(true); + expect(organizations[0].name).toBe("Personal Workspace"); + expect(organizations[1].name).toBe("Acme Corp"); + expect(organizations[2].name).toBe("All Hands AI"); + expect(organizations[3].name).toBe("Beta LLC"); + }); + + it("treats missing is_personal as false and sorts by name", async () => { + mockGetOrganizations.mockResolvedValue({ + items: [ + createMinimalOrg("1", "Zebra Org"), // no is_personal + createMinimalOrg("2", "Alpha Org", true), // personal first + createMinimalOrg("3", "Mango Org"), // no is_personal + ], + currentOrgId: "2", + }); + + const { result } = renderHook(() => useOrganizations(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const { organizations } = result.current.data!; + expect(organizations[0].id).toBe("2"); + expect(organizations[0].is_personal).toBe(true); + expect(organizations[1].name).toBe("Mango Org"); + expect(organizations[2].name).toBe("Zebra Org"); + }); + + it("handles missing name by treating as empty string for sort", async () => { + const orgWithName = createMinimalOrg("2", "Beta", false); + const orgNoName = { ...createMinimalOrg("1", "Alpha", false) }; + delete (orgNoName as Record).name; + mockGetOrganizations.mockResolvedValue({ + items: [orgWithName, orgNoName] as Organization[], + currentOrgId: "1", + }); + + const { result } = renderHook(() => useOrganizations(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const { organizations } = result.current.data!; + // undefined name is coerced to ""; "" sorts before "Beta" + expect(organizations[0].id).toBe("1"); + expect(organizations[1].id).toBe("2"); + expect(organizations[1].name).toBe("Beta"); + }); + + it("does not mutate the original array from the API", async () => { + const apiOrgs = [ + createMinimalOrg("2", "Acme", false), + createMinimalOrg("1", "Personal", true), + ]; + mockGetOrganizations.mockResolvedValue({ + items: apiOrgs, + currentOrgId: "1", + }); + + const { result } = renderHook(() => useOrganizations(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Hook sorts a copy ([...data]), so API order unchanged + expect(apiOrgs[0].id).toBe("2"); + expect(apiOrgs[1].id).toBe("1"); + // Returned data is sorted + expect(result.current.data!.organizations[0].id).toBe("1"); + expect(result.current.data!.organizations[1].id).toBe("2"); + }); +}); diff --git a/frontend/__tests__/hooks/use-org-type-and-access.test.tsx b/frontend/__tests__/hooks/use-org-type-and-access.test.tsx new file mode 100644 index 0000000000..4d3d4c9ecc --- /dev/null +++ b/frontend/__tests__/hooks/use-org-type-and-access.test.tsx @@ -0,0 +1,134 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access"; + +// Mock the dependencies +vi.mock("#/context/use-selected-organization", () => ({ + useSelectedOrganizationId: vi.fn(), +})); + +vi.mock("#/hooks/query/use-organizations", () => ({ + useOrganizations: vi.fn(), +})); + +// Import mocked modules +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { useOrganizations } from "#/hooks/query/use-organizations"; + +const mockUseSelectedOrganizationId = vi.mocked(useSelectedOrganizationId); +const mockUseOrganizations = vi.mocked(useOrganizations); + +const queryClient = new QueryClient(); +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useOrgTypeAndAccess", () => { + beforeEach(() => { + queryClient.clear(); + vi.clearAllMocks(); + }); + + it("should return false for all booleans when no organization is selected", async () => { + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: null, + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [], currentOrgId: null }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.selectedOrg).toBeUndefined(); + expect(result.current.isPersonalOrg).toBe(false); + expect(result.current.isTeamOrg).toBe(false); + expect(result.current.canViewOrgRoutes).toBe(false); + expect(result.current.organizationId).toBeNull(); + }); + }); + + it("should return isPersonalOrg=true and isTeamOrg=false for personal org", async () => { + const personalOrg = { id: "org-1", is_personal: true, name: "Personal" }; + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: "org-1", + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [personalOrg], currentOrgId: "org-1" }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.selectedOrg).toEqual(personalOrg); + expect(result.current.isPersonalOrg).toBe(true); + expect(result.current.isTeamOrg).toBe(false); + expect(result.current.canViewOrgRoutes).toBe(false); + expect(result.current.organizationId).toBe("org-1"); + }); + }); + + it("should return isPersonalOrg=false and isTeamOrg=true for team org", async () => { + const teamOrg = { id: "org-2", is_personal: false, name: "Team" }; + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: "org-2", + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [teamOrg], currentOrgId: "org-2" }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.selectedOrg).toEqual(teamOrg); + expect(result.current.isPersonalOrg).toBe(false); + expect(result.current.isTeamOrg).toBe(true); + expect(result.current.canViewOrgRoutes).toBe(true); + expect(result.current.organizationId).toBe("org-2"); + }); + }); + + it("should return canViewOrgRoutes=true only when isTeamOrg AND organizationId is truthy", async () => { + const teamOrg = { id: "org-3", is_personal: false, name: "Team" }; + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: "org-3", + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [teamOrg], currentOrgId: "org-3" }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.isTeamOrg).toBe(true); + expect(result.current.organizationId).toBe("org-3"); + expect(result.current.canViewOrgRoutes).toBe(true); + }); + }); + + it("should treat undefined is_personal field as team org", async () => { + // Organization without is_personal field (undefined) + const orgWithoutPersonalField = { id: "org-4", name: "Unknown Type" }; + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: "org-4", + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [orgWithoutPersonalField], currentOrgId: "org-4" }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.selectedOrg).toEqual(orgWithoutPersonalField); + expect(result.current.isPersonalOrg).toBe(false); + expect(result.current.isTeamOrg).toBe(true); + expect(result.current.canViewOrgRoutes).toBe(true); + }); + }); +}); diff --git a/frontend/__tests__/hooks/use-permission.test.tsx b/frontend/__tests__/hooks/use-permission.test.tsx new file mode 100644 index 0000000000..421e7e0475 --- /dev/null +++ b/frontend/__tests__/hooks/use-permission.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { usePermission } from "#/hooks/organizations/use-permissions"; +import { rolePermissions } from "#/utils/org/permissions"; +import { OrganizationUserRole } from "#/types/org"; + +describe("usePermission", () => { + const setup = (role: OrganizationUserRole) => + renderHook(() => usePermission(role)).result.current; + + describe("hasPermission", () => { + it("returns true when the role has the permission", () => { + const { hasPermission } = setup("admin"); + + expect(hasPermission("invite_user_to_organization")).toBe(true); + }); + + it("returns false when the role does not have the permission", () => { + const { hasPermission } = setup("member"); + + expect(hasPermission("invite_user_to_organization")).toBe(false); + }); + }); + + describe("rolePermissions integration", () => { + it("matches the permissions defined for the role", () => { + const { hasPermission } = setup("member"); + + rolePermissions.member.forEach((permission) => { + expect(hasPermission(permission)).toBe(true); + }); + }); + }); + + describe("change_user_role permission behavior", () => { + const run = ( + activeUserRole: OrganizationUserRole, + targetUserId: string, + targetRole: OrganizationUserRole, + activeUserId = "123", + ) => { + const { hasPermission } = renderHook(() => + usePermission(activeUserRole), + ).result.current; + + // users can't change their own roles + if (activeUserId === targetUserId) return false; + + return hasPermission(`change_user_role:${targetRole}`); + }; + + describe("member role", () => { + it("cannot change any roles", () => { + expect(run("member", "u2", "member")).toBe(false); + expect(run("member", "u2", "admin")).toBe(false); + expect(run("member", "u2", "owner")).toBe(false); + }); + }); + + describe("admin role", () => { + it("cannot change owner role", () => { + expect(run("admin", "u2", "owner")).toBe(false); + }); + + it("can change member or admin roles", () => { + expect(run("admin", "u2", "member")).toBe( + rolePermissions.admin.includes("change_user_role:member") + ); + expect(run("admin", "u2", "admin")).toBe( + rolePermissions.admin.includes("change_user_role:admin") + ); + }); + }); + + describe("owner role", () => { + it("can change owner, admin, and member roles", () => { + expect(run("owner", "u2", "admin")).toBe( + rolePermissions.owner.includes("change_user_role:admin"), + ); + + expect(run("owner", "u2", "member")).toBe( + rolePermissions.owner.includes("change_user_role:member"), + ); + + expect(run("owner", "u2", "owner")).toBe( + rolePermissions.owner.includes("change_user_role:owner"), + ); + }); + }); + + describe("self role change", () => { + it("is always disallowed", () => { + expect(run("owner", "u2", "member", "u2")).toBe(false); + expect(run("admin", "u2", "member", "u2")).toBe(false); + }); + }); + }); +}); diff --git a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx index 43205ff9d5..cc5193464b 100644 --- a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx +++ b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx @@ -6,18 +6,54 @@ import OptionService from "#/api/option-service/option-service.api"; import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; import { WebClientFeatureFlags } from "#/api/option-service/option.types"; +// Mock useOrgTypeAndAccess +const mockOrgTypeAndAccess = vi.hoisted(() => ({ + isPersonalOrg: false, + isTeamOrg: false, + organizationId: null as string | null, + selectedOrg: null, + canViewOrgRoutes: false, +})); + +vi.mock("#/hooks/use-org-type-and-access", () => ({ + useOrgTypeAndAccess: () => mockOrgTypeAndAccess, +})); + +// Mock useMe +const mockMe = vi.hoisted(() => ({ + data: null as { role: string } | null | undefined, +})); + +vi.mock("#/hooks/query/use-me", () => ({ + useMe: () => mockMe, +})); + const queryClient = new QueryClient(); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); -const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => { +const mockConfig = ( + appMode: "saas" | "oss", + hideLlmSettings = false, + enableBilling = true, +) => { vi.spyOn(OptionService, "getConfig").mockResolvedValue({ app_mode: appMode, - feature_flags: { hide_llm_settings: hideLlmSettings }, + feature_flags: { + hide_llm_settings: hideLlmSettings, + enable_billing: enableBilling, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, } as Awaited>); }; +vi.mock("react-router", () => ({ + useRevalidator: () => ({ revalidate: vi.fn() }), +})); + const mockConfigWithFeatureFlags = ( appMode: "saas" | "oss", featureFlags: Partial, @@ -25,7 +61,7 @@ const mockConfigWithFeatureFlags = ( vi.spyOn(OptionService, "getConfig").mockResolvedValue({ app_mode: appMode, feature_flags: { - enable_billing: false, + enable_billing: true, // Enable billing by default so it's not hidden hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, @@ -41,19 +77,38 @@ const mockConfigWithFeatureFlags = ( describe("useSettingsNavItems", () => { beforeEach(() => { queryClient.clear(); + vi.restoreAllMocks(); + // Reset mock state + mockOrgTypeAndAccess.isPersonalOrg = false; + mockOrgTypeAndAccess.isTeamOrg = false; + mockOrgTypeAndAccess.organizationId = null; + mockOrgTypeAndAccess.selectedOrg = null; + mockOrgTypeAndAccess.canViewOrgRoutes = false; + mockMe.data = null; }); - it("should return SAAS_NAV_ITEMS when app_mode is 'saas'", async () => { + it("should return SAAS_NAV_ITEMS minus billing/org/org-members when userRole is 'member'", async () => { mockConfig("saas"); + mockMe.data = { role: "member" }; + mockOrgTypeAndAccess.organizationId = "org-1"; + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); await waitFor(() => { - expect(result.current).toEqual(SAAS_NAV_ITEMS); + expect(result.current).toEqual( + SAAS_NAV_ITEMS.filter( + (item) => + item.to !== "/settings/billing" && + item.to !== "/settings/org" && + item.to !== "/settings/org-members", + ), + ); }); }); it("should return OSS_NAV_ITEMS when app_mode is 'oss'", async () => { mockConfig("oss"); + mockMe.data = { role: "admin" }; const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); await waitFor(() => { @@ -63,6 +118,8 @@ describe("useSettingsNavItems", () => { it("should filter out '/settings' item when hide_llm_settings feature flag is enabled", async () => { mockConfig("saas", true); + mockMe.data = { role: "admin" }; + mockOrgTypeAndAccess.organizationId = "org-1"; const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); await waitFor(() => { @@ -72,7 +129,163 @@ describe("useSettingsNavItems", () => { }); }); + describe("org-type and role-based filtering", () => { + it("should include org routes by default for team org admin", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isTeamOrg = true; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load (check that any SAAS item is present) + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Org routes should be included for team org admin + expect( + result.current.find((item) => item.to === "/settings/org"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/org-members"), + ).toBeDefined(); + }); + + it("should hide org routes when isPersonalOrg is true", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isPersonalOrg = true; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load (check that any SAAS item is present) + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Org routes should be filtered out for personal orgs + expect( + result.current.find((item) => item.to === "/settings/org"), + ).toBeUndefined(); + expect( + result.current.find((item) => item.to === "/settings/org-members"), + ).toBeUndefined(); + }); + + it("should hide org routes when user role is member", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isTeamOrg = true; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "member" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Org routes should be hidden for members + expect( + result.current.find((item) => item.to === "/settings/org"), + ).toBeUndefined(); + expect( + result.current.find((item) => item.to === "/settings/org-members"), + ).toBeUndefined(); + }); + + it("should hide org routes when no organization is selected", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isTeamOrg = false; + mockOrgTypeAndAccess.isPersonalOrg = false; + mockOrgTypeAndAccess.organizationId = null; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Org routes should be hidden when no org is selected + expect( + result.current.find((item) => item.to === "/settings/org"), + ).toBeUndefined(); + expect( + result.current.find((item) => item.to === "/settings/org-members"), + ).toBeUndefined(); + }); + + it("should hide billing route when isTeamOrg is true", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isTeamOrg = true; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Billing should be hidden for team orgs + expect( + result.current.find((item) => item.to === "/settings/billing"), + ).toBeUndefined(); + }); + + it("should show billing route for personal org", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isPersonalOrg = true; + mockOrgTypeAndAccess.isTeamOrg = false; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Billing should be visible for personal orgs + expect( + result.current.find((item) => item.to === "/settings/billing"), + ).toBeDefined(); + }); + }); + describe("hide page feature flags", () => { + beforeEach(() => { + // Set up user as admin with org context so billing is accessible + mockMe.data = { role: "admin" }; + mockOrgTypeAndAccess.isPersonalOrg = true; // Personal org shows billing + mockOrgTypeAndAccess.isTeamOrg = false; + mockOrgTypeAndAccess.organizationId = "org-1"; + }); + it("should filter out '/settings/user' when hide_users_page is true", async () => { mockConfigWithFeatureFlags("saas", { hide_users_page: true }); const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); diff --git a/frontend/__tests__/i18n/translations.test.tsx b/frontend/__tests__/i18n/translations.test.tsx deleted file mode 100644 index 3b8ff9b4a5..0000000000 --- a/frontend/__tests__/i18n/translations.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import i18n from "../../src/i18n"; -import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu"; -import { renderWithProviders } from "../../test-utils"; -import { MemoryRouter } from "react-router"; - -describe("Translations", () => { - it("should render translated text", () => { - i18n.changeLanguage("en"); - renderWithProviders( - - {}} onClose={() => {}} /> - , - ); - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); - }); - - it("should not attempt to load unsupported language codes", async () => { - // Test that the configuration prevents 404 errors by not attempting to load - // unsupported language codes like 'en-US@posix' - const originalLanguage = i18n.language; - - try { - // With nonExplicitSupportedLngs: false, i18next will not attempt to load - // unsupported language codes, preventing 404 errors - - // Test with a language code that includes region but is not in supportedLngs - await i18n.changeLanguage("en-US@posix"); - - // Since "en-US@posix" is not in supportedLngs and nonExplicitSupportedLngs is false, - // i18next should fall back to the fallbackLng ("en") - expect(i18n.language).toBe("en"); - - // Test another unsupported region code - await i18n.changeLanguage("ja-JP"); - - // Even with nonExplicitSupportedLngs: false, i18next still falls back to base language - // if it exists in supportedLngs, but importantly, it won't make a 404 request first - expect(i18n.language).toBe("ja"); - - // Test that supported languages still work - await i18n.changeLanguage("ja"); - expect(i18n.language).toBe("ja"); - - await i18n.changeLanguage("zh-CN"); - expect(i18n.language).toBe("zh-CN"); - - } finally { - // Restore the original language - await i18n.changeLanguage(originalLanguage); - } - }); - - it("should have proper i18n configuration", () => { - // Test that the i18n instance has the expected configuration - expect(i18n.options.supportedLngs).toBeDefined(); - - // nonExplicitSupportedLngs should be false to prevent 404 errors - expect(i18n.options.nonExplicitSupportedLngs).toBe(false); - - // fallbackLng can be a string or array, check if it includes "en" - const fallbackLng = i18n.options.fallbackLng; - if (Array.isArray(fallbackLng)) { - expect(fallbackLng).toContain("en"); - } else { - expect(fallbackLng).toBe("en"); - } - - // Test that supported languages include both base and region-specific codes - const supportedLngs = i18n.options.supportedLngs as string[]; - expect(supportedLngs).toContain("en"); - expect(supportedLngs).toContain("zh-CN"); - expect(supportedLngs).toContain("zh-TW"); - expect(supportedLngs).toContain("ko-KR"); - }); -}); diff --git a/frontend/__tests__/routes/api-keys.test.tsx b/frontend/__tests__/routes/api-keys.test.tsx new file mode 100644 index 0000000000..f243654845 --- /dev/null +++ b/frontend/__tests__/routes/api-keys.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { clientLoader } from "#/routes/api-keys"; + +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", () => { + // This test verifies the clientLoader is exported (for consistency with other routes) + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); diff --git a/frontend/__tests__/routes/app-settings.test.tsx b/frontend/__tests__/routes/app-settings.test.tsx index 038cb94c52..7b42844246 100644 --- a/frontend/__tests__/routes/app-settings.test.tsx +++ b/frontend/__tests__/routes/app-settings.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; -import AppSettingsScreen from "#/routes/app-settings"; +import AppSettingsScreen, { clientLoader } from "#/routes/app-settings"; import SettingsService from "#/api/settings-service/settings-service.api"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { AvailableLanguages } from "#/i18n"; @@ -18,6 +18,14 @@ const renderAppSettingsScreen = () => ), }); +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", () => { + // This test verifies the clientLoader is exported (for consistency with other routes) + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); + describe("Content", () => { it("should render the screen", () => { renderAppSettingsScreen(); diff --git a/frontend/__tests__/routes/billing.test.tsx b/frontend/__tests__/routes/billing.test.tsx new file mode 100644 index 0000000000..f4a943c2b9 --- /dev/null +++ b/frontend/__tests__/routes/billing.test.tsx @@ -0,0 +1,367 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { QueryClientProvider } from "@tanstack/react-query"; +import BillingSettingsScreen, { clientLoader } from "#/routes/billing"; +import { PaymentForm } from "#/components/features/payment/payment-form"; +import OptionService from "#/api/option-service/option-service.api"; +import { OrganizationMember } from "#/types/org"; +import * as orgStore from "#/stores/selected-organization-store"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; + +// Mock the i18next hook +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +// Mock useTracking hook +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackCreditsPurchased: vi.fn(), + }), +})); + +// Mock useBalance hook +const mockUseBalance = vi.fn(); +vi.mock("#/hooks/query/use-balance", () => ({ + useBalance: () => mockUseBalance(), +})); + +// Mock useCreateStripeCheckoutSession hook +vi.mock( + "#/hooks/mutation/stripe/use-create-stripe-checkout-session", + () => ({ + useCreateStripeCheckoutSession: () => ({ + mutate: vi.fn(), + isPending: false, + }), + }), +); + +describe("Billing Route", () => { + const { mockQueryClient } = vi.hoisted(() => ({ + mockQueryClient: (() => { + const { QueryClient } = require("@tanstack/react-query"); + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + })(), + })); + + // Mock queryClient to use our test instance + vi.mock("#/query-client-config", () => ({ + queryClient: mockQueryClient, + })); + + const createMockUser = ( + overrides: Partial = {}, + ): OrganizationMember => ({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + ...overrides, + }); + + const seedActiveUser = (user: Partial) => { + orgStore.useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser(user), + ); + }; + + const setupSaasMode = (featureFlags = {}) => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + ...featureFlags, + }, + }), + ); + }; + + beforeEach(() => { + mockQueryClient.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("clientLoader cache key", () => { + it("should use the 'web-client-config' query key to read cached config", async () => { + // Arrange: pre-populate the cache under the canonical key + seedActiveUser({ role: "admin" }); + const cachedConfig = { + app_mode: "saas" as const, + posthog_client_key: "test", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, + }; + mockQueryClient.setQueryData(["web-client-config"], cachedConfig); + + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + + // Act: invoke the clientLoader directly + const result = await clientLoader(); + + // Assert: the loader should have found the cached config and NOT called getConfig + expect(getConfigSpy).not.toHaveBeenCalled(); + expect(result).toBeNull(); // admin with billing enabled = no redirect + }); + }); + + describe("clientLoader permission checks", () => { + it("should redirect members to /settings/user when accessing billing directly", async () => { + // Arrange + setupSaasMode(); + seedActiveUser({ role: "member" }); + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should be redirected to user settings + await waitFor(() => { + expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument(); + }); + }); + + it("should allow admins to access billing route", async () => { + // Arrange + setupSaasMode(); + seedActiveUser({ role: "admin" }); + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should stay on billing page (component renders PaymentForm) + await waitFor(() => { + expect( + screen.queryByTestId("user-settings-screen"), + ).not.toBeInTheDocument(); + }); + }); + + it("should allow owners to access billing route", async () => { + // Arrange + setupSaasMode(); + seedActiveUser({ role: "owner" }); + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should stay on billing page + await waitFor(() => { + expect( + screen.queryByTestId("user-settings-screen"), + ).not.toBeInTheDocument(); + }); + }); + + it("should redirect when user is undefined (no org selected)", async () => { + // Arrange: no org selected, so getActiveOrganizationUser returns undefined + setupSaasMode(); + // Explicitly clear org store so getActiveOrganizationUser returns undefined + orgStore.useSelectedOrganizationStore.setState({ organizationId: null }); + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should be redirected to user settings + await waitFor(() => { + expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument(); + }); + }); + + it("should redirect all users when enable_billing is false", async () => { + // Arrange: enable_billing=false means billing is hidden for everyone + setupSaasMode({ enable_billing: false }); + seedActiveUser({ role: "owner" }); // Even owners should be redirected + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should be redirected to user settings + await waitFor(() => { + expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument(); + }); + }); + }); + + describe("PaymentForm permission behavior", () => { + beforeEach(() => { + mockUseBalance.mockReturnValue({ + data: "150.00", + isLoading: false, + }); + }); + + it("should disable input and button when isDisabled is true, but show balance", async () => { + // Arrange & Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - balance is visible + const balance = screen.getByTestId("user-balance"); + expect(balance).toBeInTheDocument(); + expect(balance).toHaveTextContent("$150.00"); + + // Assert - input is disabled + const topUpInput = screen.getByTestId("top-up-input"); + expect(topUpInput).toBeDisabled(); + + // Assert - button is disabled + const submitButton = screen.getByRole("button"); + expect(submitButton).toBeDisabled(); + }); + + it("should enable input and button when isDisabled is false", async () => { + // Arrange & Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - input is enabled + const topUpInput = screen.getByTestId("top-up-input"); + expect(topUpInput).not.toBeDisabled(); + + // Assert - button starts disabled (no amount entered) but is NOT + // permanently disabled by the isDisabled prop + const submitButton = screen.getByRole("button"); + // The button is disabled because no valid amount is entered, not because of isDisabled + expect(submitButton).toBeDisabled(); + }); + }); +}); diff --git a/frontend/__tests__/routes/git-settings.test.tsx b/frontend/__tests__/routes/git-settings.test.tsx index 0766790579..860bdaf1d0 100644 --- a/frontend/__tests__/routes/git-settings.test.tsx +++ b/frontend/__tests__/routes/git-settings.test.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import i18next from "i18next"; import { I18nextProvider } from "react-i18next"; -import GitSettingsScreen from "#/routes/git-settings"; +import GitSettingsScreen, { clientLoader } from "#/routes/git-settings"; import SettingsService from "#/api/settings-service/settings-service.api"; import OptionService from "#/api/option-service/option-service.api"; import AuthService from "#/api/auth-service/auth-service.api"; @@ -13,7 +13,6 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { WebClientConfig } from "#/api/option-service/option.types"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; import { SecretsService } from "#/api/secrets-service"; -import { integrationService } from "#/api/integration-service/integration-service.api"; const VALID_OSS_CONFIG: WebClientConfig = { app_mode: "oss", @@ -657,3 +656,10 @@ describe("GitLab Webhook Manager Integration", () => { }); }); }); + +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", () => { + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); diff --git a/frontend/__tests__/routes/home-screen.test.tsx b/frontend/__tests__/routes/home-screen.test.tsx index df672c6343..27d6825820 100644 --- a/frontend/__tests__/routes/home-screen.test.tsx +++ b/frontend/__tests__/routes/home-screen.test.tsx @@ -541,7 +541,7 @@ describe("Settings 404", () => { }); }); -describe("Setup Payment modal", () => { +describe("New user welcome toast", () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); @@ -593,7 +593,7 @@ describe("Setup Payment modal", () => { vi.unstubAllGlobals(); }); - it("should only render if SaaS mode and is new user", async () => { + it("should not show the setup payment modal (removed) in SaaS mode for new users", async () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, is_new_user: true, @@ -603,9 +603,9 @@ describe("Setup Payment modal", () => { await screen.findByTestId("root-layout"); - const setupPaymentModal = await screen.findByTestId( - "proceed-to-stripe-button", - ); - expect(setupPaymentModal).toBeInTheDocument(); + // SetupPaymentModal was removed; verify it no longer renders + expect( + screen.queryByTestId("proceed-to-stripe-button"), + ).not.toBeInTheDocument(); }); }); diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index 82d2085fe8..2dabd4da79 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -11,12 +11,23 @@ import { import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; import OptionService from "#/api/option-service/option-service.api"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import type { OrganizationMember } from "#/types/org"; // Mock react-router hooks const mockUseSearchParams = vi.fn(); -vi.mock("react-router", () => ({ - useSearchParams: () => mockUseSearchParams(), -})); +vi.mock("react-router", async () => { + const actual = + await vi.importActual("react-router"); + return { + ...actual, + useSearchParams: () => mockUseSearchParams(), + useRevalidator: () => ({ + revalidate: vi.fn(), + }), + }; +}); // Mock useIsAuthed hook const mockUseIsAuthed = vi.fn(); @@ -24,14 +35,63 @@ vi.mock("#/hooks/query/use-is-authed", () => ({ useIsAuthed: () => mockUseIsAuthed(), })); -const renderLlmSettingsScreen = () => - render(, { +// Mock useConfig hook +const mockUseConfig = vi.fn(); +vi.mock("#/hooks/query/use-config", () => ({ + useConfig: () => mockUseConfig(), +})); + +const renderLlmSettingsScreen = ( + orgId: string | null = null, + meData?: { + org_id: string; + user_id: string; + email: string; + role: string; + status: string; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + }, +) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + // Default to orgId "1" if not provided (for backward compatibility) + const finalOrgId = orgId ?? "1"; + useSelectedOrganizationStore.setState({ organizationId: finalOrgId }); + + // Pre-populate React Query cache with me data + // If meData is provided, use it; otherwise use default owner data + const defaultMeData = { + org_id: finalOrgId, + user_id: "99", + email: "owner@example.com", + role: "owner", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }; + queryClient.setQueryData( + ["organizations", finalOrgId, "me"], + meData || defaultMeData, + ); + + return render(, { wrapper: ({ children }) => ( - - {children} - + {children} ), }); +}; beforeEach(() => { vi.resetAllMocks(); @@ -47,22 +107,58 @@ beforeEach(() => { // Default mock for useIsAuthed - returns authenticated by default mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false }); + + // Default mock for useConfig - returns SaaS mode by default + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, + }); + + // Default mock for organizationService.getMe - returns owner role by default (full access) + const defaultMeData: OrganizationMember = { + org_id: "1", + user_id: "99", + email: "owner@example.com", + role: "owner", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }; + vi.spyOn(organizationService, "getMe").mockResolvedValue(defaultMeData); + + // Reset organization store + useSelectedOrganizationStore.setState({ organizationId: "1" }); }); describe("Content", () => { describe("Basic form", () => { it("should render the basic form by default", async () => { + // Use OSS mode so API key input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + renderLlmSettingsScreen(); await screen.findByTestId("llm-settings-screen"); - const basicFom = screen.getByTestId("llm-settings-form-basic"); - within(basicFom).getByTestId("llm-provider-input"); - within(basicFom).getByTestId("llm-model-input"); - within(basicFom).getByTestId("llm-api-key-input"); - within(basicFom).getByTestId("llm-api-key-help-anchor"); + const basicForm = screen.getByTestId("llm-settings-form-basic"); + within(basicForm).getByTestId("llm-provider-input"); + within(basicForm).getByTestId("llm-model-input"); + within(basicForm).getByTestId("llm-api-key-input"); + within(basicForm).getByTestId("llm-api-key-help-anchor"); }); it("should render the default values if non exist", async () => { + // Use OSS mode so API key input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + renderLlmSettingsScreen(); await screen.findByTestId("llm-settings-screen"); @@ -142,6 +238,12 @@ describe("Content", () => { }); it("should render the advanced form if the switch is toggled", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + renderLlmSettingsScreen(); await screen.findByTestId("llm-settings-screen"); @@ -176,6 +278,12 @@ describe("Content", () => { }); it("should render the default advanced settings", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + renderLlmSettingsScreen(); await screen.findByTestId("llm-settings-screen"); @@ -215,6 +323,12 @@ describe("Content", () => { }); it("should render existing advanced settings correctly", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, @@ -336,10 +450,10 @@ describe("Content", () => { describe("API key visibility in Basic Settings", () => { it("should hide API key input when SaaS mode is enabled and OpenHands provider is selected", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "saas", + // SaaS mode is already the default from beforeEach, but let's be explicit + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -363,10 +477,10 @@ describe("Content", () => { }); it("should show API key input when SaaS mode is enabled and non-OpenHands provider is selected", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "saas", + // SaaS mode is already the default from beforeEach, but let's be explicit + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -394,10 +508,9 @@ describe("Content", () => { }); it("should show API key input when OSS mode is enabled and OpenHands provider is selected", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "oss", + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -421,10 +534,9 @@ describe("Content", () => { }); it("should show API key input when OSS mode is enabled and non-OpenHands provider is selected", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "oss", + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -452,10 +564,10 @@ describe("Content", () => { }); it("should hide API key input when switching from non-OpenHands to OpenHands provider in SaaS mode", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "saas", + // SaaS mode is already the default from beforeEach, but let's be explicit + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -497,10 +609,10 @@ describe("Content", () => { }); it("should show API key input when switching from OpenHands to non-OpenHands provider in SaaS mode", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "saas", + // SaaS mode is already the default from beforeEach, but let's be explicit + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -548,15 +660,17 @@ describe("Form submission", () => { const provider = screen.getByTestId("llm-provider-input"); const model = screen.getByTestId("llm-model-input"); - const apiKey = screen.getByTestId("llm-api-key-input"); - // select provider + // select provider (switch to OpenAI so API key input becomes visible) await userEvent.click(provider); const providerOption = screen.getByText("OpenAI"); await userEvent.click(providerOption); - expect(provider).toHaveValue("OpenAI"); + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); - // enter api key + // enter api key (now visible after switching provider) + const apiKey = await screen.findByTestId("llm-api-key-input"); await userEvent.type(apiKey, "test-api-key"); // select model @@ -577,6 +691,12 @@ describe("Form submission", () => { }); it("should submit the advanced form with the correct values", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); renderLlmSettingsScreen(); @@ -685,6 +805,12 @@ describe("Form submission", () => { }); it("should disable the button if there are no changes in the advanced form", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, @@ -818,8 +944,17 @@ describe("Form submission", () => { expect(submitButton).toBeDisabled(); + // Switch to a non-OpenHands provider first so API key input is visible + const provider = screen.getByTestId("llm-provider-input"); + await userEvent.click(provider); + const providerOption = screen.getByText("OpenAI"); + await userEvent.click(providerOption); + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + // dirty the basic form - const apiKey = screen.getByTestId("llm-api-key-input"); + const apiKey = await screen.findByTestId("llm-api-key-input"); await userEvent.type(apiKey, "test-api-key"); expect(submitButton).not.toBeDisabled(); @@ -1009,21 +1144,9 @@ describe("View persistence after saving advanced settings", () => { it("should remain on Advanced view after saving when search API key is set", async () => { // Arrange: Start with default settings (non-SaaS mode to show search API key field) - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - partial mock for testing - getConfigSpy.mockResolvedValue({ - app_mode: "oss", - posthog_client_key: "fake-posthog-client-key", - feature_flags: { - enable_billing: false, - hide_llm_settings: false, - enable_jira: false, - enable_jira_dc: false, - enable_linear: false, - hide_users_page: false, - hide_billing_page: false, - hide_integrations_page: false, - }, + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, }); const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); @@ -1080,12 +1203,37 @@ describe("Status toasts", () => { ); renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); - // Toggle setting to change + // Switch to a non-OpenHands provider so API key input is visible + const provider = screen.getByTestId("llm-provider-input"); + await userEvent.click(provider); + const providerOption = screen.getByText("OpenAI"); + await userEvent.click(providerOption); + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + + // Wait for API key input to appear const apiKeyInput = await screen.findByTestId("llm-api-key-input"); + + // Also change the model to ensure form is dirty + const model = screen.getByTestId("llm-model-input"); + await userEvent.click(model); + const modelOption = screen.getByText("gpt-4o"); + await userEvent.click(modelOption); + await waitFor(() => { + expect(model).toHaveValue("gpt-4o"); + }); + + // Enter API key await userEvent.type(apiKeyInput, "test-api-key"); + // Wait for submit button to be enabled const submit = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submit).not.toBeDisabled(); + }); await userEvent.click(submit); expect(saveSettingsSpy).toHaveBeenCalled(); @@ -1100,12 +1248,37 @@ describe("Status toasts", () => { saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings")); renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); - // Toggle setting to change + // Switch to a non-OpenHands provider so API key input is visible + const provider = screen.getByTestId("llm-provider-input"); + await userEvent.click(provider); + const providerOption = screen.getByText("OpenAI"); + await userEvent.click(providerOption); + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + + // Wait for API key input to appear const apiKeyInput = await screen.findByTestId("llm-api-key-input"); + + // Also change the model to ensure form is dirty + const model = screen.getByTestId("llm-model-input"); + await userEvent.click(model); + const modelOption = screen.getByText("gpt-4o"); + await userEvent.click(modelOption); + await waitFor(() => { + expect(model).toHaveValue("gpt-4o"); + }); + + // Enter API key await userEvent.type(apiKeyInput, "test-api-key"); + // Wait for submit button to be enabled const submit = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submit).not.toBeDisabled(); + }); await userEvent.click(submit); expect(saveSettingsSpy).toHaveBeenCalled(); @@ -1115,6 +1288,12 @@ describe("Status toasts", () => { describe("Advanced form", () => { it("should call displaySuccessToast when the settings are saved", async () => { + // Use OSS mode to ensure API key input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); const displaySuccessToastSpy = vi.spyOn( @@ -1133,7 +1312,11 @@ describe("Status toasts", () => { const apiKeyInput = await screen.findByTestId("llm-api-key-input"); await userEvent.type(apiKeyInput, "test-api-key"); + // Wait for submit button to be enabled const submit = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submit).not.toBeDisabled(); + }); await userEvent.click(submit); expect(saveSettingsSpy).toHaveBeenCalled(); @@ -1141,6 +1324,12 @@ describe("Status toasts", () => { }); it("should call displayErrorToast when the settings fail to save", async () => { + // Use OSS mode to ensure API key input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); @@ -1158,7 +1347,11 @@ describe("Status toasts", () => { const apiKeyInput = await screen.findByTestId("llm-api-key-input"); await userEvent.type(apiKeyInput, "test-api-key"); + // Wait for submit button to be enabled const submit = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submit).not.toBeDisabled(); + }); await userEvent.click(submit); expect(saveSettingsSpy).toHaveBeenCalled(); @@ -1166,3 +1359,411 @@ describe("Status toasts", () => { }); }); }); + +describe("Role-based permissions", () => { + const getMeSpy = vi.spyOn(organizationService, "getMe"); + + beforeEach(() => { + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, + }); + }); + + describe("User role (read-only)", () => { + const memberData: OrganizationMember = { + org_id: "2", + user_id: "99", + email: "user@example.com", + role: "member", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }; + + beforeEach(() => { + // Mock user role + getMeSpy.mockResolvedValue(memberData); + }); + + it("should disable all input fields in basic view", async () => { + // Arrange + renderLlmSettingsScreen("2", memberData); // orgId "2" returns user role + + // Act + await screen.findByTestId("llm-settings-screen"); + const basicForm = screen.getByTestId("llm-settings-form-basic"); + + // Assert + const providerInput = within(basicForm).getByTestId("llm-provider-input"); + const modelInput = within(basicForm).getByTestId("llm-model-input"); + + await waitFor(() => { + expect(providerInput).toBeDisabled(); + expect(modelInput).toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be disabled + const apiKeyInput = within(basicForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).toBeDisabled(); + } + }); + + // Note: No "should disable all input fields in advanced view" test for members + // because members cannot access the advanced view (the toggle is disabled). + + it("should not render submit button", async () => { + // Arrange + renderLlmSettingsScreen("2", memberData); + + // Act + await screen.findByTestId("llm-settings-screen"); + const submitButton = screen.queryByTestId("submit-button"); + + // Assert + expect(submitButton).not.toBeInTheDocument(); + }); + + it("should disable the advanced/basic toggle for read-only users", async () => { + // Arrange + renderLlmSettingsScreen("2", memberData); + + // Act + await screen.findByTestId("llm-settings-screen"); + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + + // Assert - toggle should be disabled for members who lack edit_llm_settings + await waitFor(() => { + expect(advancedSwitch).toBeDisabled(); + }); + + // Basic form should remain visible (members can't switch to advanced) + expect( + screen.getByTestId("llm-settings-form-basic"), + ).toBeInTheDocument(); + }); + + }); + + describe("Owner role (full access)", () => { + beforeEach(() => { + // Mock owner role + getMeSpy.mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "owner@example.com", + role: "owner", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }); + }); + + it("should enable all input fields in basic view", async () => { + // Arrange + renderLlmSettingsScreen("1"); // orgId "1" returns owner role + + // Act + await screen.findByTestId("llm-settings-screen"); + const basicForm = screen.getByTestId("llm-settings-form-basic"); + + // Assert + const providerInput = within(basicForm).getByTestId("llm-provider-input"); + const modelInput = within(basicForm).getByTestId("llm-model-input"); + + await waitFor(() => { + expect(providerInput).not.toBeDisabled(); + expect(modelInput).not.toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be enabled + const apiKeyInput = within(basicForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).not.toBeDisabled(); + } + }); + + it("should enable all input fields in advanced view", async () => { + // Arrange + renderLlmSettingsScreen("1"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + + // Assert - owners can toggle between views + expect(advancedSwitch).not.toBeDisabled(); + + await userEvent.click(advancedSwitch); + const advancedForm = await screen.findByTestId( + "llm-settings-form-advanced", + ); + + // Assert + const modelInput = within(advancedForm).getByTestId( + "llm-custom-model-input", + ); + const baseUrlInput = within(advancedForm).getByTestId("base-url-input"); + const condenserSwitch = within(advancedForm).getByTestId( + "enable-memory-condenser-switch", + ); + const confirmationSwitch = within(advancedForm).getByTestId( + "enable-confirmation-mode-switch", + ); + + await waitFor(() => { + expect(modelInput).not.toBeDisabled(); + expect(baseUrlInput).not.toBeDisabled(); + expect(condenserSwitch).not.toBeDisabled(); + expect(confirmationSwitch).not.toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be enabled + const apiKeyInput = + within(advancedForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).not.toBeDisabled(); + } + }); + + it("should enable submit button when form is dirty", async () => { + // Arrange + renderLlmSettingsScreen("1"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const submitButton = screen.getByTestId("submit-button"); + const providerInput = screen.getByTestId("llm-provider-input"); + + // Assert - initially disabled (no changes) + expect(submitButton).toBeDisabled(); + + // Act - make a change by selecting a different provider + await userEvent.click(providerInput); + const openAIOption = await screen.findByText("OpenAI"); + await userEvent.click(openAIOption); + + // Assert - button should be enabled + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + }); + + it("should allow submitting form changes", async () => { + // Arrange + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); + renderLlmSettingsScreen("1"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const providerInput = screen.getByTestId("llm-provider-input"); + const modelInput = screen.getByTestId("llm-model-input"); + + // Select a different provider to make form dirty + await userEvent.click(providerInput); + const openAIOption = await screen.findByText("OpenAI"); + await userEvent.click(openAIOption); + await waitFor(() => { + expect(providerInput).toHaveValue("OpenAI"); + }); + + // Select a different model to ensure form is dirty + await userEvent.click(modelInput); + const modelOption = await screen.findByText("gpt-4o"); + await userEvent.click(modelOption); + await waitFor(() => { + expect(modelInput).toHaveValue("gpt-4o"); + }); + + // Wait for form to be marked as dirty + const submitButton = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + + await userEvent.click(submitButton); + + // Assert + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalled(); + }); + }); + + // Note: The former "should disable security analyzer dropdown when confirmation mode + // is enabled" test was removed. It was in the member block and only passed because + // members have isReadOnly=true (all fields disabled), not because confirmation mode + // disables the analyzer. For owners/admins, the security analyzer is enabled + // regardless of confirmation mode. + }); + + describe("Admin role (full access)", () => { + beforeEach(() => { + // Mock admin role + getMeSpy.mockResolvedValue({ + org_id: "3", + user_id: "99", + email: "admin@example.com", + role: "admin", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }); + }); + + it("should enable all input fields in basic view", async () => { + // Arrange + renderLlmSettingsScreen("3"); // orgId "3" returns admin role + + // Act + await screen.findByTestId("llm-settings-screen"); + const basicForm = screen.getByTestId("llm-settings-form-basic"); + + // Assert + const providerInput = within(basicForm).getByTestId("llm-provider-input"); + const modelInput = within(basicForm).getByTestId("llm-model-input"); + + await waitFor(() => { + expect(providerInput).not.toBeDisabled(); + expect(modelInput).not.toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be enabled + const apiKeyInput = within(basicForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).not.toBeDisabled(); + } + }); + + it("should enable all input fields in advanced view", async () => { + // Arrange + renderLlmSettingsScreen("3"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + + // Assert - admins can toggle between views + expect(advancedSwitch).not.toBeDisabled(); + + await userEvent.click(advancedSwitch); + const advancedForm = await screen.findByTestId( + "llm-settings-form-advanced", + ); + + // Assert + const modelInput = within(advancedForm).getByTestId( + "llm-custom-model-input", + ); + const baseUrlInput = within(advancedForm).getByTestId("base-url-input"); + const condenserSwitch = within(advancedForm).getByTestId( + "enable-memory-condenser-switch", + ); + const confirmationSwitch = within(advancedForm).getByTestId( + "enable-confirmation-mode-switch", + ); + + await waitFor(() => { + expect(modelInput).not.toBeDisabled(); + expect(baseUrlInput).not.toBeDisabled(); + expect(condenserSwitch).not.toBeDisabled(); + expect(confirmationSwitch).not.toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be enabled + const apiKeyInput = + within(advancedForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).not.toBeDisabled(); + } + }); + + it("should enable submit button when form is dirty", async () => { + // Arrange + renderLlmSettingsScreen("3"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const submitButton = screen.getByTestId("submit-button"); + const providerInput = screen.getByTestId("llm-provider-input"); + + // Assert - initially disabled (no changes) + expect(submitButton).toBeDisabled(); + + // Act - make a change by selecting a different provider + await userEvent.click(providerInput); + const openAIOption = await screen.findByText("OpenAI"); + await userEvent.click(openAIOption); + + // Assert - button should be enabled + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + }); + + it("should allow submitting form changes", async () => { + // Arrange + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); + renderLlmSettingsScreen("3"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const providerInput = screen.getByTestId("llm-provider-input"); + const modelInput = screen.getByTestId("llm-model-input"); + + // Select a different provider to make form dirty + await userEvent.click(providerInput); + const openAIOption = await screen.findByText("OpenAI"); + await userEvent.click(openAIOption); + await waitFor(() => { + expect(providerInput).toHaveValue("OpenAI"); + }); + + // Select a different model to ensure form is dirty + await userEvent.click(modelInput); + const modelOption = await screen.findByText("gpt-4o"); + await userEvent.click(modelOption); + await waitFor(() => { + expect(modelInput).toHaveValue("gpt-4o"); + }); + + // Wait for form to be marked as dirty + const submitButton = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + + await userEvent.click(submitButton); + + // Assert + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalled(); + }); + }); + }); +}); + +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", async () => { + // This test verifies the clientLoader is exported for consistency with other routes + // Note: All roles have view_llm_settings permission, so this guard ensures + // the route is protected and can be restricted in the future if needed + const { clientLoader } = await import("#/routes/llm-settings"); + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); diff --git a/frontend/__tests__/routes/manage-org.test.tsx b/frontend/__tests__/routes/manage-org.test.tsx new file mode 100644 index 0000000000..390b10fc43 --- /dev/null +++ b/frontend/__tests__/routes/manage-org.test.tsx @@ -0,0 +1,954 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub } from "react-router"; +import { selectOrganization } from "test-utils"; +import ManageOrg from "#/routes/manage-org"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import SettingsScreen, { clientLoader } from "#/routes/settings"; +import { + resetOrgMockData, + MOCK_TEAM_ORG_ACME, + INITIAL_MOCK_ORGS, +} from "#/mocks/org-handlers"; +import OptionService from "#/api/option-service/option-service.api"; +import BillingService from "#/api/billing-service/billing-service.api"; +import { OrganizationMember } from "#/types/org"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; + +const mockQueryClient = vi.hoisted(() => { + const { QueryClient } = require("@tanstack/react-query"); + return new QueryClient(); +}); + +vi.mock("#/query-client-config", () => ({ + queryClient: mockQueryClient, +})); + +function ManageOrgWithPortalRoot() { + return ( +
+ +
+
+ ); +} + +const RouteStub = createRoutesStub([ + { + Component: () =>
, + path: "/", + }, + { + // @ts-expect-error - type mismatch + loader: clientLoader, + Component: SettingsScreen, + path: "/settings", + HydrateFallback: () =>
Loading...
, + children: [ + { + Component: ManageOrgWithPortalRoot, + path: "/settings/org", + }, + ], + }, +]); + +let queryClient: QueryClient; + +const renderManageOrg = () => + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + +const { navigateMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), +})); + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization", + ORG$PERSONAL_WORKSPACE: "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +vi.mock("react-router", async () => ({ + ...(await vi.importActual("react-router")), + useNavigate: () => navigateMock, +})); + +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +describe("Manage Org Route", () => { + const getMeSpy = vi.spyOn(organizationService, "getMe"); + + // Test data constants + const TEST_USERS: Record<"OWNER" | "ADMIN", OrganizationMember> = { + OWNER: { + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + ADMIN: { + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + }; + + // Helper function to set up user mock + const setupUserMock = (userData: { + org_id: string; + user_id: string; + email: string; + role: "owner" | "admin" | "member"; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + status: "active" | "invited" | "inactive"; + }) => { + getMeSpy.mockResolvedValue(userData); + }; + + beforeEach(() => { + // Set Zustand store to a team org so clientLoader's org route protection allows access + useSelectedOrganizationStore.setState({ + organizationId: MOCK_TEAM_ORG_ACME.id, + }); + // Seed organizations into the module-level queryClient used by clientLoader + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + queryClient = new QueryClient(); + // Pre-seed organizations so org selector renders immediately (avoids flaky race with API fetch) + queryClient.setQueryData(["organizations"], { + items: INITIAL_MOCK_ORGS, + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, // Enable billing by default so billing UI is shown + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Set default mock for user (owner role has all permissions) + setupUserMock(TEST_USERS.OWNER); + }); + + afterEach(() => { + vi.clearAllMocks(); + // Reset organization mock data to ensure clean state between tests + resetOrgMockData(); + // Reset Zustand store to ensure clean state between tests + useSelectedOrganizationStore.setState({ organizationId: null }); + // Clear module-level queryClient used by clientLoader + mockQueryClient.clear(); + // Clear test queryClient + queryClient?.clear(); + }); + + it("should render the available credits", async () => { + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + await waitFor(() => { + const credits = screen.getByTestId("available-credits"); + expect(credits).toHaveTextContent("100"); + }); + }); + + it("should render account details", async () => { + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + await waitFor(() => { + const orgName = screen.getByTestId("org-name"); + expect(orgName).toHaveTextContent("Personal Workspace"); + }); + }); + + it("should be able to add credits", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); // user is owner in org 1 + + expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(); + // Simulate adding credits — wait for permissions-dependent button + const addCreditsButton = await waitFor(() => screen.getByText(/add/i)); + await userEvent.click(addCreditsButton); + + const addCreditsForm = screen.getByTestId("add-credits-form"); + expect(addCreditsForm).toBeInTheDocument(); + + const amountInput = within(addCreditsForm).getByTestId("amount-input"); + const nextButton = within(addCreditsForm).getByRole("button", { + name: /next/i, + }); + + await userEvent.type(amountInput, "1000"); + await userEvent.click(nextButton); + + // expect redirect to payment page + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000); + + await waitFor(() => + expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(), + ); + }); + + it("should close the modal when clicking cancel", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); // user is owner in org 1 + + expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(); + // Simulate adding credits — wait for permissions-dependent button + const addCreditsButton = await waitFor(() => screen.getByText(/add/i)); + await userEvent.click(addCreditsButton); + + const addCreditsForm = screen.getByTestId("add-credits-form"); + expect(addCreditsForm).toBeInTheDocument(); + + const cancelButton = within(addCreditsForm).getByRole("button", { + name: /close/i, + }); + + await userEvent.click(cancelButton); + + expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(); + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + + describe("AddCreditsModal", () => { + const openAddCreditsModal = async () => { + const user = userEvent.setup(); + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); // user is owner in org 1 + + const addCreditsButton = await waitFor(() => screen.getByText(/add/i)); + await user.click(addCreditsButton); + + const addCreditsForm = screen.getByTestId("add-credits-form"); + expect(addCreditsForm).toBeInTheDocument(); + + return { user, addCreditsForm }; + }; + + describe("Button State Management", () => { + it("should enable submit button initially when modal opens", async () => { + await openAddCreditsModal(); + + const nextButton = screen.getByRole("button", { name: /next/i }); + expect(nextButton).not.toBeDisabled(); + }); + + it("should enable submit button when input contains invalid value", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "-50"); + + expect(nextButton).not.toBeDisabled(); + }); + + it("should enable submit button when input contains valid value", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "100"); + + expect(nextButton).not.toBeDisabled(); + }); + + it("should enable submit button after validation error is shown", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + expect(screen.getByTestId("amount-error")).toBeInTheDocument(); + }); + + expect(nextButton).not.toBeDisabled(); + }); + }); + + describe("Input Attributes & Placeholder", () => { + it("should have min attribute set to 10", async () => { + await openAddCreditsModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute("min", "10"); + }); + + it("should have max attribute set to 25000", async () => { + await openAddCreditsModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute("max", "25000"); + }); + + it("should have step attribute set to 1", async () => { + await openAddCreditsModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute("step", "1"); + }); + }); + + describe("Error Message Display", () => { + it("should not display error message initially when modal opens", async () => { + await openAddCreditsModal(); + + const errorMessage = screen.queryByTestId("amount-error"); + expect(errorMessage).not.toBeInTheDocument(); + }); + + it("should display error message after submitting amount above maximum", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "25001"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MAXIMUM_AMOUNT", + ); + }); + }); + + it("should display error message after submitting decimal value", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "50.5"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER", + ); + }); + }); + + it("should replace error message when submitting different invalid value", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MINIMUM_AMOUNT", + ); + }); + + await user.clear(amountInput); + await user.type(amountInput, "25001"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MAXIMUM_AMOUNT", + ); + }); + }); + }); + + describe("Form Submission Behavior", () => { + it("should prevent submission when amount is invalid", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MINIMUM_AMOUNT", + ); + }); + }); + + it("should call createCheckoutSession with correct amount when valid", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "1000"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000); + const errorMessage = screen.queryByTestId("amount-error"); + expect(errorMessage).not.toBeInTheDocument(); + }); + + it("should not call createCheckoutSession when validation fails", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "-50"); + await user.click(nextButton); + + // Verify mutation was not called + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_NEGATIVE_AMOUNT", + ); + }); + }); + + it("should close modal on successful submission", async () => { + const createCheckoutSessionSpy = vi + .spyOn(BillingService, "createCheckoutSession") + .mockResolvedValue("https://checkout.stripe.com/test-session"); + + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "1000"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000); + + await waitFor(() => { + expect( + screen.queryByTestId("add-credits-form"), + ).not.toBeInTheDocument(); + }); + }); + + it("should allow API call when validation passes and clear any previous errors", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + // First submit invalid value + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + expect(screen.getByTestId("amount-error")).toBeInTheDocument(); + }); + + // Then submit valid value + await user.clear(amountInput); + await user.type(amountInput, "100"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100); + const errorMessage = screen.queryByTestId("amount-error"); + expect(errorMessage).not.toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("should handle zero value correctly", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "0"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MINIMUM_AMOUNT", + ); + }); + }); + + it("should handle whitespace-only input correctly", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + // Number inputs typically don't accept spaces, but test the behavior + await user.type(amountInput, " "); + await user.click(nextButton); + + // Should not call API (empty/invalid input) + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + }); + }); + + it("should show add credits option for ADMIN role", async () => { + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI) + + // Verify credits are shown + await waitFor(() => { + const credits = screen.getByTestId("available-credits"); + expect(credits).toBeInTheDocument(); + }); + + // Verify add credits button is present (admins can add credits) + const addButton = screen.getByText(/add/i); + expect(addButton).toBeInTheDocument(); + }); + + describe("actions", () => { + it("should be able to update the organization name", async () => { + const updateOrgNameSpy = vi.spyOn( + organizationService, + "updateOrganization", + ); + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", // required to enable getMe + }), + ); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + const orgName = screen.getByTestId("org-name"); + await waitFor(() => + expect(orgName).toHaveTextContent("Personal Workspace"), + ); + + expect( + screen.queryByTestId("update-org-name-form"), + ).not.toBeInTheDocument(); + + const changeOrgNameButton = within(orgName).getByRole("button", { + name: /change/i, + }); + await userEvent.click(changeOrgNameButton); + + const orgNameForm = screen.getByTestId("update-org-name-form"); + const orgNameInput = within(orgNameForm).getByRole("textbox"); + const saveButton = within(orgNameForm).getByRole("button", { + name: /save/i, + }); + + await userEvent.type(orgNameInput, "New Org Name"); + await userEvent.click(saveButton); + + expect(updateOrgNameSpy).toHaveBeenCalledWith({ + orgId: "1", + name: "New Org Name", + }); + + await waitFor(() => { + expect( + screen.queryByTestId("update-org-name-form"), + ).not.toBeInTheDocument(); + expect(orgName).toHaveTextContent("New Org Name"); + }); + }); + + it("should NOT allow roles other than owners to change org name", async () => { + // Set admin role before rendering + setupUserMock(TEST_USERS.ADMIN); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI) + + const orgName = screen.getByTestId("org-name"); + const changeOrgNameButton = within(orgName).queryByRole("button", { + name: /change/i, + }); + expect(changeOrgNameButton).not.toBeInTheDocument(); + }); + + it("should NOT allow roles other than owners to delete an organization", async () => { + setupUserMock(TEST_USERS.ADMIN); + + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", // required to enable getMe + }), + ); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI) + + const deleteOrgButton = screen.queryByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }); + expect(deleteOrgButton).not.toBeInTheDocument(); + }); + + it("should be able to delete an organization", async () => { + const deleteOrgSpy = vi.spyOn(organizationService, "deleteOrganization"); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + expect( + screen.queryByTestId("delete-org-confirmation"), + ).not.toBeInTheDocument(); + + const deleteOrgButton = await waitFor(() => + screen.getByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }), + ); + await userEvent.click(deleteOrgButton); + + const deleteConfirmation = screen.getByTestId("delete-org-confirmation"); + const confirmButton = within(deleteConfirmation).getByRole("button", { + name: /BUTTON\$CONFIRM/i, + }); + + await userEvent.click(confirmButton); + + expect(deleteOrgSpy).toHaveBeenCalledWith({ orgId: "1" }); + expect( + screen.queryByTestId("delete-org-confirmation"), + ).not.toBeInTheDocument(); + + // expect to have navigated to home screen + await screen.findByTestId("home-screen"); + }); + + it.todo("should be able to update the organization billing info"); + }); + + describe("Role-based delete organization permission behavior", () => { + it("should show delete organization button when user has canDeleteOrganization permission (Owner role)", async () => { + setupUserMock(TEST_USERS.OWNER); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + const deleteButton = await screen.findByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }); + + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).not.toBeDisabled(); + }); + + it("should not show delete organization button when user lacks canDeleteOrganization permission ('Admin' role)", async () => { + setupUserMock({ + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + const deleteButton = screen.queryByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }); + + expect(deleteButton).not.toBeInTheDocument(); + }); + + it("should not show delete organization button when user lacks canDeleteOrganization permission ('Member' role)", async () => { + setupUserMock({ + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + // Members lack view_billing permission, so the clientLoader redirects away from /settings/org + renderManageOrg(); + + // The manage-org screen should NOT be accessible — clientLoader redirects + await waitFor(() => { + expect( + screen.queryByTestId("manage-org-screen"), + ).not.toBeInTheDocument(); + }); + }); + + it("should open delete confirmation modal when delete button is clicked (with permission)", async () => { + setupUserMock(TEST_USERS.OWNER); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + expect( + screen.queryByTestId("delete-org-confirmation"), + ).not.toBeInTheDocument(); + + const deleteButton = await screen.findByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }); + await userEvent.click(deleteButton); + + expect(screen.getByTestId("delete-org-confirmation")).toBeInTheDocument(); + }); + }); + + describe("enable_billing feature flag", () => { + it("should show credits section when enable_billing is true", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Act + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + await selectOrganization({ orgIndex: 0 }); + + // Assert + await waitFor(() => { + expect(screen.getByTestId("available-credits")).toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + + it("should show organization name section when enable_billing is true", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Act + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + await selectOrganization({ orgIndex: 0 }); + + // Assert + await waitFor(() => { + expect(screen.getByTestId("org-name")).toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + + it("should show Add Credits button when enable_billing is true", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Act + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + await selectOrganization({ orgIndex: 0 }); + + // Assert + await waitFor(() => { + const addButton = screen.getByText(/add/i); + expect(addButton).toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + + it("should hide all billing-related elements when enable_billing is false", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Act + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + await selectOrganization({ orgIndex: 0 }); + + // Assert + await waitFor(() => { + expect( + screen.queryByTestId("available-credits"), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/add/i)).not.toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + }); +}); diff --git a/frontend/__tests__/routes/manage-organization-members.test.tsx b/frontend/__tests__/routes/manage-organization-members.test.tsx new file mode 100644 index 0000000000..391fef1b44 --- /dev/null +++ b/frontend/__tests__/routes/manage-organization-members.test.tsx @@ -0,0 +1,1062 @@ +import { describe, expect, it, vi, test, beforeEach, afterEach } from "vitest"; +import { render, screen, within, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub } from "react-router"; +import { selectOrganization } from "test-utils"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import ManageOrganizationMembers from "#/routes/manage-organization-members"; +import SettingsScreen, { + clientLoader as settingsClientLoader, +} from "#/routes/settings"; +import { + ORGS_AND_MEMBERS, + resetOrgMockData, + resetOrgsAndMembersMockData, + MOCK_TEAM_ORG_ACME, + INITIAL_MOCK_ORGS, +} from "#/mocks/org-handlers"; +import OptionService from "#/api/option-service/option-service.api"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; + +const mockQueryClient = vi.hoisted(() => { + const { QueryClient } = require("@tanstack/react-query"); + return new QueryClient(); +}); + +vi.mock("#/query-client-config", () => ({ + queryClient: mockQueryClient, +})); + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization", + ORG$PERSONAL_WORKSPACE: "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +function ManageOrganizationMembersWithPortalRoot() { + return ( +
+ +
+
+ ); +} + +const RouteStub = createRoutesStub([ + { + // @ts-expect-error - ignoreing error for test stub + loader: settingsClientLoader, + Component: SettingsScreen, + path: "/settings", + HydrateFallback: () =>
Loading...
, + children: [ + { + Component: ManageOrganizationMembersWithPortalRoot, + path: "/settings/org-members", + handle: { hideTitle: true }, + }, + { + Component: () =>
, + path: "/settings/member", + }, + ], + }, +]); + +let queryClient: QueryClient; + +describe("Manage Organization Members Route", () => { + const getMeSpy = vi.spyOn(organizationService, "getMe"); + + beforeEach(() => { + // Set Zustand store to a team org so clientLoader allows access to /settings/org-members + useSelectedOrganizationStore.setState({ + organizationId: MOCK_TEAM_ORG_ACME.id, + }); + // Seed organizations into the module-level queryClient used by clientLoader + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + queryClient = new QueryClient(); + + // Pre-seed organizations so org selector renders immediately (avoids flaky race with API fetch) + queryClient.setQueryData(["organizations"], { + items: INITIAL_MOCK_ORGS, + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + // Set default mock for user (admin role has invite permission) + getMeSpy.mockResolvedValue({ + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Reset organization mock data to ensure clean state between tests + resetOrgMockData(); + // Reset ORGS_AND_MEMBERS to initial state + resetOrgsAndMembersMockData(); + // Clear queryClient cache to ensure fresh data for next test + queryClient.clear(); + // Reset Zustand store and module-level queryClient + useSelectedOrganizationStore.setState({ organizationId: null }); + mockQueryClient.clear(); + }); + + const renderManageOrganizationMembers = () => + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Helper function to find a member by email + const findMemberByEmail = async (email: string) => { + const memberListItems = await screen.findAllByTestId("member-item"); + const member = memberListItems.find((item) => + within(item).queryByText(email), + ); + if (!member) { + throw new Error(`Could not find member with email: ${email}`); + } + return member; + }; + + // Helper function to open role dropdown for a member + const openRoleDropdown = async ( + memberElement: HTMLElement, + roleText: string, + ) => { + // Find the role text that's clickable (has cursor-pointer class or is the main role display) + // Use a more specific query to avoid matching dropdown options + const roleElement = within(memberElement).getByText( + new RegExp(`^${roleText}$`, "i"), + ); + await userEvent.click(roleElement); + return within(memberElement).getByTestId( + "organization-member-role-context-menu", + ); + }; + + // Helper function to change member role + const changeMemberRole = async ( + memberElement: HTMLElement, + currentRole: string, + newRole: string, + ) => { + const dropdown = await openRoleDropdown(memberElement, currentRole); + const roleOption = within(dropdown).getByText(new RegExp(newRole, "i")); + await userEvent.click(roleOption); + + // If role is changing, confirm the modal + if (currentRole.toLowerCase() !== newRole.toLowerCase()) { + const confirmButton = await screen.findByTestId("confirm-button"); + await userEvent.click(confirmButton); + } + }; + + // Helper function to verify dropdown is not visible + const expectDropdownNotVisible = (memberElement: HTMLElement) => { + expect( + within(memberElement).queryByTestId( + "organization-member-role-context-menu", + ), + ).not.toBeInTheDocument(); + }; + + // Helper function to setup test with user and organization + const setupTestWithUserAndOrg = async ( + userData: { + org_id: string; + user_id: string; + email: string; + role: "owner" | "admin" | "member"; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + status: "active" | "invited" | "inactive"; + }, + orgIndex: number, + ) => { + getMeSpy.mockResolvedValue(userData); + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex }); + // Wait for member list to be rendered (async data loaded) + await screen.findAllByTestId("member-item"); + }; + + // Helper function to create updateMember spy + const createUpdateMemberRoleSpy = () => + vi.spyOn(organizationService, "updateMember"); + + // Helper function to verify role change is not permitted + const verifyRoleChangeNotPermitted = async ( + userData: { + org_id: string; + user_id: string; + email: string; + role: "owner" | "admin" | "member"; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + status: "active" | "invited" | "inactive"; + }, + orgIndex: number, + targetMemberIndex: number, + expectedRoleText: string, + ) => { + await setupTestWithUserAndOrg(userData, orgIndex); + + const memberListItems = await screen.findAllByTestId("member-item"); + const targetMember = memberListItems[targetMemberIndex]; + const roleText = within(targetMember).getByText( + new RegExp(`^${expectedRoleText}$`, "i"), + ); + expect(roleText).toBeInTheDocument(); + await userEvent.click(roleText); + + // Verify that the dropdown does not open + expectDropdownNotVisible(targetMember); + }; + + // Helper function to setup invite test (render and select organization) + const setupInviteTest = async (orgIndex: number = 0) => { + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex }); + }; + + // Helper function to setup test with organization (waits for settings screen) + const setupTestWithOrg = async (orgIndex: number = 0) => { + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex }); + }; + + // Helper function to find invite button + const findInviteButton = () => + screen.findByRole("button", { + name: /ORG\$INVITE_ORG_MEMBERS/i, + }); + + // Helper function to verify all three role options are present in dropdown + const expectAllRoleOptionsPresent = (dropdown: HTMLElement) => { + expect(within(dropdown).getByText(/owner/i)).toBeInTheDocument(); + expect(within(dropdown).getByText(/admin/i)).toBeInTheDocument(); + expect(within(dropdown).getByText(/member/i)).toBeInTheDocument(); + }; + + // Helper function to close dropdown by clicking outside + const closeDropdown = async () => { + await userEvent.click(document.body); + }; + + // Helper function to verify owner option is not present in dropdown + const expectOwnerOptionNotPresent = (dropdown: HTMLElement) => { + expect(within(dropdown).queryByText(/owner/i)).not.toBeInTheDocument(); + }; + + it("should render", async () => { + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + }); + + it("should navigate away from the page if not saas", async () => { + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + // @ts-expect-error - partial mock for testing + getConfigSpy.mockResolvedValue({ + app_mode: "oss", + }); + + renderManageOrganizationMembers(); + expect( + screen.queryByTestId("manage-organization-members-settings"), + ).not.toBeInTheDocument(); + }); + + it("should allow the user to select an organization", async () => { + const getOrganizationMembersSpy = vi.spyOn( + organizationService, + "getOrganizationMembers", + ); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + + // First org is auto-selected, so members are fetched for org "1" + await selectOrganization({ orgIndex: 1 }); // Acme Corp + expect(getOrganizationMembersSpy).toHaveBeenLastCalledWith({ + orgId: "2", + page: 1, + limit: 10, + email: undefined, + }); + }); + + it("should render the list of organization members", async () => { + await setupTestWithOrg(0); + const members = ORGS_AND_MEMBERS["1"]; + + // Wait for org "1" member to appear (ensures org switch is complete) + // This is needed because placeholderData: keepPreviousData shows stale data during transitions + await screen.findByText(members[0].email); + + const memberListItems = await screen.findAllByTestId("member-item"); + expect(memberListItems).toHaveLength(members.length); + + members.forEach((member) => { + expect(screen.getByText(member.email)).toBeInTheDocument(); + expect(screen.getByText(member.role)).toBeInTheDocument(); + }); + }); + + test("an admin should be able to change the role of a organization member", async () => { + await setupTestWithUserAndOrg( + { + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 1, // Acme Corp (org "2") - has owner, admin, user + ); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + const memberListItems = await screen.findAllByTestId("member-item"); + const userRoleMember = memberListItems[2]; // third member is "user" (charlie) + + let userCombobox = within(userRoleMember).getByText(/^Member$/i); + expect(userCombobox).toBeInTheDocument(); + + // Change role from user to admin + await changeMemberRole(userRoleMember, "member", "admin"); + + expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({ + userId: "3", // charlie's id + orgId: "2", + role: "admin", + }); + expectDropdownNotVisible(userRoleMember); + + // Verify the role has been updated in the UI + userCombobox = within(userRoleMember).getByText(/^Admin$/i); + expect(userCombobox).toBeInTheDocument(); + + // Revert the role back to user + await changeMemberRole(userRoleMember, "admin", "member"); + + expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(2, { + userId: "3", + orgId: "2", + role: "member", + }); + + // Verify the role has been reverted in the UI + userCombobox = within(userRoleMember).getByText(/^Member$/i); + expect(userCombobox).toBeInTheDocument(); + }); + + it("should not allow an admin to change the owner's role", async () => { + // User is bob (admin, user_id: "2") trying to edit alice (owner, user_id: "1") + // Admins don't have change_user_role:owner permission, so dropdown shouldn't show + + // Reset mock data to ensure clean state + resetOrgsAndMembersMockData(); + + // Pre-seed the /me query data to avoid stale cache issues + const userData = { + org_id: "2", + user_id: "2", // bob (admin) - different from alice + email: "bob@acme.org", + role: "admin" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }; + + getMeSpy.mockResolvedValue(userData); + queryClient.setQueryData(["organizations", "2", "me"], userData); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex: 1 }); // Acme Corp (org "2") + + // Wait for member list to load + const memberListItems = await screen.findAllByTestId("member-item"); + + // First member is alice (owner) + const targetMember = memberListItems[0]; + const roleText = within(targetMember).getByText(/^owner$/i); + expect(roleText).toBeInTheDocument(); + await userEvent.click(roleText); + + // Verify that the dropdown does not open (admin can't edit owner) + expectDropdownNotVisible(targetMember); + }); + + it("should allow an admin to change another admin's role", async () => { + // Mock members to include two admins so we can test admin editing another admin + const getOrganizationMembersSpy = vi.spyOn( + organizationService, + "getOrganizationMembers", + ); + const getOrganizationMembersCountSpy = vi.spyOn( + organizationService, + "getOrganizationMembersCount", + ); + + const twoAdminsMembers = [ + { + org_id: "2", + user_id: "1", + email: "admin1@acme.org", + role: "admin" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + { + org_id: "2", + user_id: "2", + email: "admin2@acme.org", + role: "admin" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + ]; + + getOrganizationMembersSpy.mockResolvedValue({ + items: twoAdminsMembers, + current_page: 1, + per_page: 10, + }); + getOrganizationMembersCountSpy.mockResolvedValue(2); + + // Current user is admin1 (user_id: "1") + getMeSpy.mockResolvedValue({ + org_id: "2", + user_id: "1", + email: "admin1@acme.org", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex: 1 }); // Acme Corp + + // Find admin2 and change their role to member + const admin2Member = await findMemberByEmail("admin2@acme.org"); + await changeMemberRole(admin2Member, "admin", "member"); + + expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({ + userId: "2", + orgId: "2", + role: "member", + }); + + // Restore spies to prevent interference with subsequent tests + getOrganizationMembersSpy.mockRestore(); + getOrganizationMembersCountSpy.mockRestore(); + }); + + it("should not allow a user to change their own role", async () => { + // Mock the /me endpoint to return a user ID that matches one of the members + await verifyRoleChangeNotPermitted( + { + org_id: "1", + user_id: "1", // Same as first member from org 1 + email: "alice@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 0, + 0, // First member (user_id: "1") + "Owner", + ); + }); + + it("should show a remove option in the role dropdown and remove the user from the list", async () => { + const removeMemberSpy = vi.spyOn(organizationService, "removeMember"); + + await setupTestWithOrg(1); // Acme Corp (org "2") - has owner, admin, user + + // Get initial member count + const memberListItems = await screen.findAllByTestId("member-item"); + const initialMemberCount = memberListItems.length; + + const userRoleMember = memberListItems[2]; // third member is "user" + const userEmail = within(userRoleMember).getByText("charlie@acme.org"); + expect(userEmail).toBeInTheDocument(); + + const userCombobox = within(userRoleMember).getByText(/^Member$/i); + await userEvent.click(userCombobox); + + const dropdown = within(userRoleMember).getByTestId( + "organization-member-role-context-menu", + ); + + // Check that remove option exists + const removeOption = within(dropdown).getByTestId("remove-option"); + expect(removeOption).toBeInTheDocument(); + + await userEvent.click(removeOption); + + // Wait for confirmation modal to appear and click confirm + const confirmButton = await screen.findByTestId("confirm-button"); + await userEvent.click(confirmButton); + + expect(removeMemberSpy).toHaveBeenCalledExactlyOnceWith({ + orgId: "2", + userId: "3", + }); + + // Verify the user is no longer in the list + await waitFor(() => { + const updatedMemberListItems = screen.getAllByTestId("member-item"); + expect(updatedMemberListItems).toHaveLength(initialMemberCount - 1); + }); + + // Verify the specific user email is no longer present + expect(screen.queryByText("charlie@acme.org")).not.toBeInTheDocument(); + }); + + + describe("Inviting Organization Members", () => { + it("should render an invite organization member button", async () => { + await setupInviteTest(); + + const inviteButton = await findInviteButton(); + expect(inviteButton).toBeInTheDocument(); + }); + + it("should render a modal when the invite button is clicked", async () => { + await setupInviteTest(); + + expect(screen.queryByTestId("invite-modal")).not.toBeInTheDocument(); + const inviteButton = await findInviteButton(); + await userEvent.click(inviteButton); + + const portalRoot = screen.getByTestId("portal-root"); + expect( + within(portalRoot).getByTestId("invite-modal"), + ).toBeInTheDocument(); + }); + + it("should close the modal when the close button is clicked", async () => { + await setupInviteTest(); + + const inviteButton = await findInviteButton(); + await userEvent.click(inviteButton); + + const modal = screen.getByTestId("invite-modal"); + const closeButton = within(modal).getByText("BUTTON$CLOSE"); + await userEvent.click(closeButton); + + expect(screen.queryByTestId("invite-modal")).not.toBeInTheDocument(); + }); + + it("should render a list item in an invited state when a the user is is invited", async () => { + const getOrganizationMembersSpy = vi.spyOn( + organizationService, + "getOrganizationMembers", + ); + const getOrganizationMembersCountSpy = vi.spyOn( + organizationService, + "getOrganizationMembersCount", + ); + + getOrganizationMembersSpy.mockResolvedValue({ + items: [ + { + org_id: "1", + user_id: "4", + email: "tom@acme.org", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "invited", + }, + ], + current_page: 1, + per_page: 10, + }); + getOrganizationMembersCountSpy.mockResolvedValue(1); + + await setupInviteTest(); + + const members = await screen.findAllByTestId("member-item"); + expect(members).toHaveLength(1); + + const invitedMember = members[0]; + expect(invitedMember).toBeInTheDocument(); + + // should have an "invited" badge + const invitedBadge = within(invitedMember).getByText(/invited/i); + expect(invitedBadge).toBeInTheDocument(); + + // should not have a role combobox + await userEvent.click(within(invitedMember).getByText(/^Member$/i)); + expect( + within(invitedMember).queryByTestId( + "organization-member-role-context-menu", + ), + ).not.toBeInTheDocument(); + }); + }); + + describe("Role-based invite permission behavior", () => { + it.each([ + { role: "owner" as const, roleName: "Owner" }, + { role: "admin" as const, roleName: "Admin" }, + ])( + "should show invite button when user has canInviteUsers permission ($roleName role)", + async ({ role }) => { + getMeSpy.mockResolvedValue({ + org_id: "1", + user_id: "1", + email: "test@example.com", + role, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + await setupTestWithOrg(0); + + const inviteButton = await findInviteButton(); + + expect(inviteButton).toBeInTheDocument(); + expect(inviteButton).not.toBeDisabled(); + }, + ); + + it("should not show invite button when user lacks canInviteUsers permission (User role)", async () => { + const userData = { + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "member" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }; + + // Set mock and remove cached query before rendering + getMeSpy.mockResolvedValue(userData); + // Remove any cached "me" queries so fresh data is fetched + queryClient.removeQueries({ queryKey: ["organizations"] }); + + await setupTestWithOrg(0); + + // Directly set the query data to force component re-render with user role + // This ensures the component uses the user role data instead of cached admin data + queryClient.setQueryData(["organizations", "1", "me"], userData); + + // Wait for the component to update with the new query data + await waitFor( + () => { + const inviteButton = screen.queryByRole("button", { + name: /ORG\$INVITE_ORG_MEMBERS/i, + }); + expect(inviteButton).not.toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + }); + }); + + describe("Role-based role change permission behavior", () => { + it("should not allow an owner to change their own role", async () => { + // Acme Corp (org "2") - alice is owner, can't change her own role + + // Reset mock data to ensure clean state + resetOrgsAndMembersMockData(); + + // Pre-seed the /me query data to avoid stale cache issues + const userData = { + org_id: "2", + user_id: "1", // alice (owner) - same as first member + email: "alice@acme.org", + role: "owner" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }; + + getMeSpy.mockResolvedValue(userData); + queryClient.setQueryData(["organizations", "2", "me"], userData); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex: 1 }); // Acme Corp (org "2") + + // Wait for member list to load + const memberListItems = await screen.findAllByTestId("member-item"); + + // First member is alice (owner) - same as current user + const targetMember = memberListItems[0]; + const roleText = within(targetMember).getByText(/^owner$/i); + expect(roleText).toBeInTheDocument(); + await userEvent.click(roleText); + + // Verify that the dropdown does not open (can't edit own role) + expectDropdownNotVisible(targetMember); + }); + + it("should allow an owner to change another owner's role", async () => { + // Mock members to include two owners so we can test owner editing another owner + const getOrganizationMembersSpy = vi.spyOn( + organizationService, + "getOrganizationMembers", + ); + const getOrganizationMembersCountSpy = vi.spyOn( + organizationService, + "getOrganizationMembersCount", + ); + + const twoOwnersMembers = [ + { + org_id: "2", + user_id: "1", + email: "owner1@acme.org", + role: "owner" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + { + org_id: "2", + user_id: "2", + email: "owner2@acme.org", + role: "owner" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + ]; + + getOrganizationMembersSpy.mockResolvedValue({ + items: twoOwnersMembers, + current_page: 1, + per_page: 10, + }); + getOrganizationMembersCountSpy.mockResolvedValue(2); + + // Current user is owner1 (user_id: "1") + getMeSpy.mockResolvedValue({ + org_id: "2", + user_id: "1", + email: "owner1@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex: 1 }); // Acme Corp + + // Find owner2 and change their role to admin + const owner2Member = await findMemberByEmail("owner2@acme.org"); + await changeMemberRole(owner2Member, "owner", "admin"); + + expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({ + userId: "2", + orgId: "2", + role: "admin", + }); + + // Restore spies to prevent interference with subsequent tests + getOrganizationMembersSpy.mockRestore(); + getOrganizationMembersCountSpy.mockRestore(); + }); + + it("Owner should see all three role options (owner, admin, user) in dropdown regardless of target member's role", async () => { + await setupTestWithUserAndOrg( + { + org_id: "1", + user_id: "1", // First member is owner in org 1 + email: "alice@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 1, // Acme Corp (org "2") + ); + + const memberListItems = await screen.findAllByTestId("member-item"); + + // Test with admin member + const adminMember = memberListItems[1]; // Second member is admin (user_id: "2") + const adminDropdown = await openRoleDropdown(adminMember, "admin"); + + // Verify all three role options are present for admin member + expectAllRoleOptionsPresent(adminDropdown); + + // Close dropdown by clicking outside + await closeDropdown(); + + // Test with user member + const userMember = await findMemberByEmail("charlie@acme.org"); + const userDropdown = await openRoleDropdown(userMember, "member"); + + // Verify all three role options are present for user member + expectAllRoleOptionsPresent(userDropdown); + }); + + it("Admin should not see owner option in role dropdown for any member", async () => { + await setupTestWithUserAndOrg( + { + org_id: "3", + user_id: "7", // Ray is admin in org 3 + email: "ray@all-hands.dev", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 3, // All Hands AI (org "4") + ); + + const memberListItems = await screen.findAllByTestId("member-item"); + + // Check user member dropdown + const userMember = memberListItems[2]; // user member + const userDropdown = await openRoleDropdown(userMember, "member"); + expectOwnerOptionNotPresent(userDropdown); + await closeDropdown(); + + // Check another user member dropdown (stephan is at index 3) + if (memberListItems.length > 3) { + const anotherUserMember = memberListItems[3]; // stephan@all-hands.dev + const anotherUserDropdown = await openRoleDropdown( + anotherUserMember, + "member", + ); + expectOwnerOptionNotPresent(anotherUserDropdown); + } + }); + + it("Owner should be able to change any member's role to owner", async () => { + await setupTestWithUserAndOrg( + { + org_id: "1", + user_id: "1", // First member is owner in org 1 + email: "alice@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 1, // Acme Corp (org "2") + ); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + const memberListItems = await screen.findAllByTestId("member-item"); + + // Test changing admin to owner + const adminMember = memberListItems[1]; // Second member is admin (user_id: "2") + await changeMemberRole(adminMember, "admin", "owner"); + + expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(1, { + userId: "2", + orgId: "2", + role: "owner", + }); + + // Test changing user to owner + const userMember = await findMemberByEmail("charlie@acme.org"); + await changeMemberRole(userMember, "member", "owner"); + + expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(2, { + userId: "3", + orgId: "2", + role: "owner", + }); + }); + + it("Admin should be able to change member's role to admin", async () => { + await setupTestWithUserAndOrg( + { + org_id: "4", + user_id: "7", // Ray is admin in org 4 + email: "ray@all-hands.dev", + role: "admin" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + 3, // All Hands AI (org "4") + ); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + const member = await findMemberByEmail("stephan@all-hands.dev"); + + await changeMemberRole(member, "member", "admin"); + + expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({ + userId: "9", + orgId: "4", + role: "admin" as const, + }); + }); + + it("should not show confirmation modal or call API when selecting the same role", async () => { + await setupTestWithUserAndOrg( + { + org_id: "1", + user_id: "1", // First member is owner in org 1 + email: "alice@acme.org", + role: "owner" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + 1, // Acme Corp (org "2") + ); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + const member = await findMemberByEmail("bob@acme.org"); + + // Open dropdown and select the same role (admin -> admin) + const dropdown = await openRoleDropdown(member, "admin"); + const roleOption = within(dropdown).getByText(/admin/i); + await userEvent.click(roleOption); + + // Verify no confirmation modal appears + expect(screen.queryByTestId("confirm-button")).not.toBeInTheDocument(); + + // Verify no API call was made + expect(updateMemberRoleSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/__tests__/routes/mcp-settings.test.tsx b/frontend/__tests__/routes/mcp-settings.test.tsx new file mode 100644 index 0000000000..955c290e67 --- /dev/null +++ b/frontend/__tests__/routes/mcp-settings.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { clientLoader } from "#/routes/mcp-settings"; + +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", () => { + // This test verifies the clientLoader is exported (for consistency with other routes) + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); diff --git a/frontend/__tests__/routes/secrets-settings.test.tsx b/frontend/__tests__/routes/secrets-settings.test.tsx index 2062117f8b..f67d15f42e 100644 --- a/frontend/__tests__/routes/secrets-settings.test.tsx +++ b/frontend/__tests__/routes/secrets-settings.test.tsx @@ -3,12 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { createRoutesStub, Outlet } from "react-router"; -import SecretsSettingsScreen from "#/routes/secrets-settings"; +import SecretsSettingsScreen, { clientLoader } from "#/routes/secrets-settings"; import { SecretsService } from "#/api/secrets-service"; import { GetSecretsResponse } from "#/api/secrets-service.types"; import SettingsService from "#/api/settings-service/settings-service.api"; import OptionService from "#/api/option-service/option-service.api"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; +import { OrganizationMember } from "#/types/org"; +import * as orgStore from "#/stores/selected-organization-store"; +import { organizationService } from "#/api/organization-service/organization-service.api"; const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [ { @@ -66,6 +69,75 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe("clientLoader permission checks", () => { + const createMockUser = ( + overrides: Partial = {}, + ): OrganizationMember => ({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + ...overrides, + }); + + const seedActiveUser = (user: Partial) => { + orgStore.useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser(user), + ); + }; + + it("should export a clientLoader for route protection", () => { + // This test verifies the clientLoader is exported (for consistency with other routes) + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); + + it("should allow members to access secrets settings (all roles have manage_secrets)", async () => { + // Arrange + seedActiveUser({ role: "member" }); + + const RouterStub = createRoutesStub([ + { + Component: SecretsSettingsScreen, + loader: clientLoader, + path: "/settings/secrets", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should stay on secrets settings page (not redirected) + await waitFor(() => { + expect(screen.getByTestId("secrets-settings-screen")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("user-settings-screen")).not.toBeInTheDocument(); + }); +}); + describe("Content", () => { it("should render the secrets settings screen", () => { renderSecretsSettings(); diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx index 0131b86d43..1a682c96b7 100644 --- a/frontend/__tests__/routes/settings-with-payment.test.tsx +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -1,22 +1,22 @@ -import { screen, within } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRoutesStub } from "react-router"; -import { renderWithProviders } from "test-utils"; import SettingsScreen from "#/routes/settings"; import { PaymentForm } from "#/components/features/payment/payment-form"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +let queryClient: QueryClient; // Mock the useSettings hook vi.mock("#/hooks/query/use-settings", async () => { - const actual = await vi.importActual< - typeof import("#/hooks/query/use-settings") - >("#/hooks/query/use-settings"); + const actual = await vi.importActual( + "#/hooks/query/use-settings" + ); return { ...actual, useSettings: vi.fn().mockReturnValue({ - data: { - EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection - }, + data: { EMAIL_VERIFIED: true }, isLoading: false, }), }; @@ -52,21 +52,36 @@ vi.mock("react-i18next", async () => { }); // Mock useConfig hook -const { mockUseConfig } = vi.hoisted(() => ({ +const { mockUseConfig, mockUseMe, mockUsePermission } = vi.hoisted(() => ({ mockUseConfig: vi.fn(), + mockUseMe: vi.fn(), + mockUsePermission: vi.fn(), })); + vi.mock("#/hooks/query/use-config", () => ({ useConfig: mockUseConfig, })); +vi.mock("#/hooks/query/use-me", () => ({ + useMe: mockUseMe, +})); + +vi.mock("#/hooks/organizations/use-permissions", () => ({ + usePermission: () => ({ + hasPermission: mockUsePermission, + }), +})); + describe("Settings Billing", () => { beforeEach(() => { - // Set default config to OSS mode + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + // Set default config to OSS mode with lowercase keys mockUseConfig.mockReturnValue({ data: { app_mode: "oss", - github_client_id: "123", - posthog_client_key: "456", feature_flags: { enable_billing: false, hide_llm_settings: false, @@ -80,6 +95,13 @@ describe("Settings Billing", () => { }, isLoading: false, }); + + mockUseMe.mockReturnValue({ + data: { role: "admin" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(false); // default: no billing access }); const RoutesStub = createRoutesStub([ @@ -104,14 +126,38 @@ describe("Settings Billing", () => { ]); const renderSettingsScreen = () => - renderWithProviders(); + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); - afterEach(() => { - vi.clearAllMocks(); - }); + afterEach(() => vi.clearAllMocks()); it("should not render the billing tab if OSS mode", async () => { - // OSS mode is set by default in beforeEach + mockUseConfig.mockReturnValue({ + data: { + app_mode: "oss", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, + }, + isLoading: false, + }); + + mockUseMe.mockReturnValue({ + data: { role: "admin" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(true); + renderSettingsScreen(); const navbar = await screen.findByTestId("settings-navbar"); @@ -119,12 +165,10 @@ describe("Settings Billing", () => { expect(credits).not.toBeInTheDocument(); }); - it("should render the billing tab if SaaS mode and billing is enabled", async () => { + it("should render the billing tab if: SaaS mode, billing enabled, admin user", async () => { mockUseConfig.mockReturnValue({ data: { app_mode: "saas", - github_client_id: "123", - posthog_client_key: "456", feature_flags: { enable_billing: true, hide_llm_settings: false, @@ -139,19 +183,23 @@ describe("Settings Billing", () => { isLoading: false, }); + mockUseMe.mockReturnValue({ + data: { role: "admin" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(true); + renderSettingsScreen(); const navbar = await screen.findByTestId("settings-navbar"); - within(navbar).getByText("Billing"); + expect(within(navbar).getByText("Billing")).toBeInTheDocument(); }); - it("should render the billing settings if clicking the billing item", async () => { - const user = userEvent.setup(); + it("should NOT render the billing tab if: SaaS mode, billing is enabled, and member user", async () => { mockUseConfig.mockReturnValue({ data: { app_mode: "saas", - github_client_id: "123", - posthog_client_key: "456", feature_flags: { enable_billing: true, hide_llm_settings: false, @@ -166,6 +214,43 @@ describe("Settings Billing", () => { isLoading: false, }); + mockUseMe.mockReturnValue({ + data: { role: "member" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(false); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument(); + }); + + it("should render the billing settings if clicking the billing item", async () => { + const user = userEvent.setup(); + // When enable_billing is true, the billing nav item is shown + mockUseConfig.mockReturnValue({ + data: { + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, + }, + isLoading: false, + }); + + mockUseMe.mockReturnValue({ + data: { role: "admin" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(true); + renderSettingsScreen(); const navbar = await screen.findByTestId("settings-navbar"); diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx index c39f389c3c..bdc2c0c822 100644 --- a/frontend/__tests__/routes/settings.test.tsx +++ b/frontend/__tests__/routes/settings.test.tsx @@ -1,13 +1,16 @@ -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import { createRoutesStub } from "react-router"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { QueryClientProvider } from "@tanstack/react-query"; -import SettingsScreen, { - clientLoader, - getFirstAvailablePath, -} from "#/routes/settings"; +import SettingsScreen, { clientLoader } from "#/routes/settings"; +import { getFirstAvailablePath } from "#/utils/settings-utils"; import OptionService from "#/api/option-service/option-service.api"; +import { OrganizationMember } from "#/types/org"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; import { WebClientFeatureFlags } from "#/api/option-service/option.types"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; // Module-level mocks using vi.hoisted const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({ @@ -57,17 +60,44 @@ vi.mock("react-i18next", async () => { }); describe("Settings Screen", () => { + const createMockUser = ( + overrides: Partial = {}, + ): OrganizationMember => ({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + ...overrides, + }); + + const seedActiveUser = (user: Partial) => { + useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser(user), + ); + }; + const RouterStub = createRoutesStub([ { Component: SettingsScreen, // @ts-expect-error - custom loader - clientLoader, + loader: clientLoader, path: "/settings", children: [ { Component: () =>
, path: "/settings", }, + { + Component: () =>
, + path: "/settings/user", + }, { Component: () =>
, path: "/settings/integrations", @@ -84,6 +114,15 @@ describe("Settings Screen", () => { Component: () =>
, path: "/settings/api-keys", }, + { + Component: () =>
, + path: "/settings/org-members", + handle: { hideTitle: true }, + }, + { + Component: () =>
, + path: "/settings/org", + }, ], }, ]); @@ -129,11 +168,21 @@ describe("Settings Screen", () => { }); it("should render the saas navbar", async () => { - const saasConfig = { app_mode: "saas" }; + const saasConfig = { + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, + }; // Clear any existing query data and set the config mockQueryClient.clear(); mockQueryClient.setQueryData(["web-client-config"], saasConfig); + seedActiveUser({ role: "admin" }); const sectionsToInclude = [ "llm", // LLM settings are now always shown in SaaS mode @@ -149,6 +198,9 @@ describe("Settings Screen", () => { renderSettingsScreen(); const navbar = await screen.findByTestId("settings-navbar"); + await waitFor(() => { + expect(within(navbar).getByText("Billing")).toBeInTheDocument(); + }); sectionsToInclude.forEach((section) => { const sectionElement = within(navbar).getByText(section, { exact: false, // case insensitive @@ -200,12 +252,367 @@ describe("Settings Screen", () => { it.todo("should not be able to access oss-only routes in saas mode"); + describe("Personal org vs team org visibility", () => { + it("should not show Organization and Organization Members settings items when personal org is selected", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + + // Organization and Organization Members should NOT be visible for personal org + expect( + within(navbar).queryByText("Organization Members"), + ).not.toBeInTheDocument(); + expect( + within(navbar).queryByText("Organization"), + ).not.toBeInTheDocument(); + }); + + it("should not show Billing settings item when team org is selected", async () => { + // Set up SaaS mode (which has Billing in nav items) + mockQueryClient.clear(); + mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" }); + // Pre-select the team org in the query client and Zustand store + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "2" }); + + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "2", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + + // Wait for orgs to load, then verify Billing is hidden for team orgs + await waitFor(() => { + expect( + within(navbar).queryByText("Billing", { exact: false }), + ).not.toBeInTheDocument(); + }); + }); + + it("should not allow direct URL access to /settings/org when personal org is selected", async () => { + // Set up orgs in query client so clientLoader can access them + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + // Use Zustand store instead of query client for selected org ID + // This is the correct pattern - the query client key ["selected_organization"] is never set in production + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderSettingsScreen("/settings/org"); + + // Should redirect away from org settings for personal org + await waitFor(() => { + expect( + screen.queryByTestId("organization-settings-screen"), + ).not.toBeInTheDocument(); + }); + }); + + it("should not allow direct URL access to /settings/org-members when personal org is selected", async () => { + // Set up config and organizations in query client so clientLoader can access them + mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" }); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + // Use Zustand store for selected org ID + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + // Mock getMe so getActiveOrganizationUser returns admin + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser({ role: "admin", org_id: "1" }), + ); + + // Act: Call clientLoader directly with the REAL route path (as defined in routes.ts) + const request = new Request("http://localhost/settings/org-members"); + // @ts-expect-error - test only needs request and params, not full loader args + const result = await clientLoader({ request, params: {} }); + + // Assert: Should redirect away from org-members settings for personal org + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/settings"); + }); + + it("should not allow direct URL access to /settings/billing when team org is selected", async () => { + // Set up orgs in query client so clientLoader can access them + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + // Use Zustand store instead of query client for selected org ID + useSelectedOrganizationStore.setState({ organizationId: "2" }); + + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderSettingsScreen("/settings/billing"); + + // Should redirect away from billing settings for team org + await waitFor(() => { + expect( + screen.queryByTestId("billing-settings-screen"), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("enable_billing feature flag", () => { + it("should show billing navigation item when enable_billing is true", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, // When enable_billing is true, billing nav is shown + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + mockQueryClient.clear(); + // Set up personal org (billing is only shown for personal orgs, not team orgs) + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + // Act + renderSettingsScreen(); + + // Assert + const navbar = await screen.findByTestId("settings-navbar"); + await waitFor(() => { + expect(within(navbar).getByText("Billing")).toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + + it("should hide billing navigation item when enable_billing is false", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, // When enable_billing is false, billing nav is hidden + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + mockQueryClient.clear(); + + // Act + renderSettingsScreen(); + + // Assert + const navbar = await screen.findByTestId("settings-navbar"); + expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument(); + + getConfigSpy.mockRestore(); + }); + }); + + describe("clientLoader reads org ID from Zustand store", () => { + beforeEach(() => { + mockQueryClient.clear(); + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should redirect away from /settings/org when personal org is selected in Zustand store", async () => { + // Arrange: Set up config and organizations in query client + mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" }); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + + // Set org ID ONLY in Zustand store (not in query client) + // This tests that clientLoader reads from the correct source + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + // Mock getMe so getActiveOrganizationUser returns admin + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser({ role: "admin", org_id: "1" }), + ); + + // Act: Call clientLoader directly + const request = new Request("http://localhost/settings/org"); + // @ts-expect-error - test only needs request and params, not full loader args + const result = await clientLoader({ request, params: {} }); + + // Assert: Should redirect away from org settings for personal org + expect(result).not.toBeNull(); + // In React Router, redirect returns a Response object + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/settings"); + }); + + it("should redirect away from /settings/billing when team org is selected in Zustand store", async () => { + // Arrange: Set up config and organizations in query client + mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" }); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + // Set org ID ONLY in Zustand store (not in query client) + useSelectedOrganizationStore.setState({ organizationId: "2" }); + + // Mock getMe so getActiveOrganizationUser returns admin + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser({ role: "admin", org_id: "2" }), + ); + + // Act: Call clientLoader directly + const request = new Request("http://localhost/settings/billing"); + // @ts-expect-error - test only needs request and params, not full loader args + const result = await clientLoader({ request, params: {} }); + + // Assert: Should redirect away from billing settings for team org + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/settings/user"); + }); + }); + describe("hide page feature flags", () => { + beforeEach(() => { + // Set up as personal org admin so billing is accessible + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + }); + it("should hide users page in navbar when hide_users_page is true", async () => { const saasConfig = { app_mode: "saas", feature_flags: { - enable_billing: false, + enable_billing: true, // Enable billing so it's not hidden by isBillingHidden hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, @@ -218,6 +625,14 @@ describe("Settings Screen", () => { mockQueryClient.clear(); mockQueryClient.setQueryData(["web-client-config"], saasConfig); + // Set up personal org so billing is visible + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); + // Pre-populate user data in cache so useMe() returns admin role immediately + mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" })); renderSettingsScreen(); @@ -238,7 +653,7 @@ describe("Settings Screen", () => { const saasConfig = { app_mode: "saas", feature_flags: { - enable_billing: false, + enable_billing: true, hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, @@ -251,6 +666,11 @@ describe("Settings Screen", () => { mockQueryClient.clear(); mockQueryClient.setQueryData(["web-client-config"], saasConfig); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); renderSettingsScreen(); @@ -271,7 +691,7 @@ describe("Settings Screen", () => { const saasConfig = { app_mode: "saas", feature_flags: { - enable_billing: false, + enable_billing: true, hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, @@ -284,6 +704,13 @@ describe("Settings Screen", () => { mockQueryClient.clear(); mockQueryClient.setQueryData(["web-client-config"], saasConfig); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); + // Pre-populate user data in cache so useMe() returns admin role immediately + mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" })); renderSettingsScreen(); diff --git a/frontend/__tests__/stores/selected-organization-store.test.ts b/frontend/__tests__/stores/selected-organization-store.test.ts new file mode 100644 index 0000000000..d3abd6d975 --- /dev/null +++ b/frontend/__tests__/stores/selected-organization-store.test.ts @@ -0,0 +1,51 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +describe("useSelectedOrganizationStore", () => { + it("should have null as initial organizationId", () => { + const { result } = renderHook(() => useSelectedOrganizationStore()); + expect(result.current.organizationId).toBeNull(); + }); + + it("should update organizationId when setOrganizationId is called", () => { + const { result } = renderHook(() => useSelectedOrganizationStore()); + + act(() => { + result.current.setOrganizationId("org-123"); + }); + + expect(result.current.organizationId).toBe("org-123"); + }); + + it("should allow setting organizationId to null", () => { + const { result } = renderHook(() => useSelectedOrganizationStore()); + + act(() => { + result.current.setOrganizationId("org-123"); + }); + + expect(result.current.organizationId).toBe("org-123"); + + act(() => { + result.current.setOrganizationId(null); + }); + + expect(result.current.organizationId).toBeNull(); + }); + + it("should share state across multiple hook instances", () => { + const { result: result1 } = renderHook(() => + useSelectedOrganizationStore(), + ); + const { result: result2 } = renderHook(() => + useSelectedOrganizationStore(), + ); + + act(() => { + result1.current.setOrganizationId("shared-organization"); + }); + + expect(result2.current.organizationId).toBe("shared-organization"); + }); +}); diff --git a/frontend/__tests__/utils/billing-visibility.test.ts b/frontend/__tests__/utils/billing-visibility.test.ts new file mode 100644 index 0000000000..2feb63f52d --- /dev/null +++ b/frontend/__tests__/utils/billing-visibility.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { WebClientConfig } from "#/api/option-service/option.types"; + +describe("isBillingHidden", () => { + const createConfig = ( + featureFlagOverrides: Partial = {}, + ): WebClientConfig => + ({ + app_mode: "saas", + posthog_client_key: "test", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + ...featureFlagOverrides, + }, + }) as WebClientConfig; + + it("should return true when config is undefined (safe default)", () => { + expect(isBillingHidden(undefined, true)).toBe(true); + }); + + it("should return true when enable_billing is false", () => { + const config = createConfig({ enable_billing: false }); + expect(isBillingHidden(config, true)).toBe(true); + }); + + it("should return true when user lacks view_billing permission", () => { + const config = createConfig(); + expect(isBillingHidden(config, false)).toBe(true); + }); + + it("should return true when both enable_billing is false and user lacks permission", () => { + const config = createConfig({ enable_billing: false }); + expect(isBillingHidden(config, false)).toBe(true); + }); + + it("should return false when enable_billing is true and user has view_billing permission", () => { + const config = createConfig(); + expect(isBillingHidden(config, true)).toBe(false); + }); + + it("should treat enable_billing as true by default (billing visible, subject to permission)", () => { + const config = createConfig({ enable_billing: true }); + expect(isBillingHidden(config, true)).toBe(false); + }); +}); diff --git a/frontend/__tests__/utils/input-validation.test.ts b/frontend/__tests__/utils/input-validation.test.ts new file mode 100644 index 0000000000..82f3aebe91 --- /dev/null +++ b/frontend/__tests__/utils/input-validation.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "vitest"; +import { + isValidEmail, + getInvalidEmails, + areAllEmailsValid, + hasDuplicates, +} from "#/utils/input-validation"; + +describe("isValidEmail", () => { + describe("valid email formats", () => { + test("accepts standard email formats", () => { + expect(isValidEmail("user@example.com")).toBe(true); + expect(isValidEmail("john.doe@company.org")).toBe(true); + expect(isValidEmail("test@subdomain.domain.com")).toBe(true); + }); + + test("accepts emails with numbers", () => { + expect(isValidEmail("user123@example.com")).toBe(true); + expect(isValidEmail("123user@example.com")).toBe(true); + expect(isValidEmail("user@example123.com")).toBe(true); + }); + + test("accepts emails with special characters in local part", () => { + expect(isValidEmail("user.name@example.com")).toBe(true); + expect(isValidEmail("user+tag@example.com")).toBe(true); + expect(isValidEmail("user_name@example.com")).toBe(true); + expect(isValidEmail("user-name@example.com")).toBe(true); + expect(isValidEmail("user%tag@example.com")).toBe(true); + }); + + test("accepts emails with various TLDs", () => { + expect(isValidEmail("user@example.io")).toBe(true); + expect(isValidEmail("user@example.co.uk")).toBe(true); + expect(isValidEmail("user@example.travel")).toBe(true); + }); + }); + + describe("invalid email formats", () => { + test("rejects empty strings", () => { + expect(isValidEmail("")).toBe(false); + }); + + test("rejects strings without @", () => { + expect(isValidEmail("userexample.com")).toBe(false); + expect(isValidEmail("user.example.com")).toBe(false); + }); + + test("rejects strings without domain", () => { + expect(isValidEmail("user@")).toBe(false); + expect(isValidEmail("user@.com")).toBe(false); + }); + + test("rejects strings without local part", () => { + expect(isValidEmail("@example.com")).toBe(false); + }); + + test("rejects strings without TLD", () => { + expect(isValidEmail("user@example")).toBe(false); + expect(isValidEmail("user@example.")).toBe(false); + }); + + test("rejects strings with single character TLD", () => { + expect(isValidEmail("user@example.c")).toBe(false); + }); + + test("rejects plain text", () => { + expect(isValidEmail("test")).toBe(false); + expect(isValidEmail("just some text")).toBe(false); + }); + + test("rejects emails with spaces", () => { + expect(isValidEmail("user @example.com")).toBe(false); + expect(isValidEmail("user@ example.com")).toBe(false); + expect(isValidEmail(" user@example.com")).toBe(false); + expect(isValidEmail("user@example.com ")).toBe(false); + }); + + test("rejects emails with multiple @ symbols", () => { + expect(isValidEmail("user@@example.com")).toBe(false); + expect(isValidEmail("user@domain@example.com")).toBe(false); + }); + }); +}); + +describe("getInvalidEmails", () => { + test("returns empty array when all emails are valid", () => { + const emails = ["user@example.com", "test@domain.org"]; + expect(getInvalidEmails(emails)).toEqual([]); + }); + + test("returns all invalid emails", () => { + const emails = ["valid@example.com", "invalid", "test@", "another@valid.org"]; + expect(getInvalidEmails(emails)).toEqual(["invalid", "test@"]); + }); + + test("returns all emails when none are valid", () => { + const emails = ["invalid", "also-invalid", "no-at-symbol"]; + expect(getInvalidEmails(emails)).toEqual(emails); + }); + + test("handles empty array", () => { + expect(getInvalidEmails([])).toEqual([]); + }); + + test("handles array with single invalid email", () => { + expect(getInvalidEmails(["invalid"])).toEqual(["invalid"]); + }); + + test("handles array with single valid email", () => { + expect(getInvalidEmails(["valid@example.com"])).toEqual([]); + }); +}); + +describe("areAllEmailsValid", () => { + test("returns true when all emails are valid", () => { + const emails = ["user@example.com", "test@domain.org", "admin@company.io"]; + expect(areAllEmailsValid(emails)).toBe(true); + }); + + test("returns false when any email is invalid", () => { + const emails = ["user@example.com", "invalid", "test@domain.org"]; + expect(areAllEmailsValid(emails)).toBe(false); + }); + + test("returns false when all emails are invalid", () => { + const emails = ["invalid", "also-invalid"]; + expect(areAllEmailsValid(emails)).toBe(false); + }); + + test("returns true for empty array", () => { + expect(areAllEmailsValid([])).toBe(true); + }); + + test("returns true for single valid email", () => { + expect(areAllEmailsValid(["valid@example.com"])).toBe(true); + }); + + test("returns false for single invalid email", () => { + expect(areAllEmailsValid(["invalid"])).toBe(false); + }); +}); + +describe("hasDuplicates", () => { + test("returns false when all values are unique", () => { + expect(hasDuplicates(["a@test.com", "b@test.com", "c@test.com"])).toBe( + false, + ); + }); + + test("returns true when duplicates exist", () => { + expect(hasDuplicates(["a@test.com", "b@test.com", "a@test.com"])).toBe(true); + }); + + test("returns true for case-insensitive duplicates", () => { + expect(hasDuplicates(["User@Test.com", "user@test.com"])).toBe(true); + expect(hasDuplicates(["A@EXAMPLE.COM", "a@example.com"])).toBe(true); + }); + + test("returns false for empty array", () => { + expect(hasDuplicates([])).toBe(false); + }); + + test("returns false for single item array", () => { + expect(hasDuplicates(["single@test.com"])).toBe(false); + }); + + test("handles multiple duplicates", () => { + expect( + hasDuplicates(["a@test.com", "a@test.com", "b@test.com", "b@test.com"]), + ).toBe(true); + }); +}); diff --git a/frontend/__tests__/utils/permission-checks.test.ts b/frontend/__tests__/utils/permission-checks.test.ts new file mode 100644 index 0000000000..d4730cb2a7 --- /dev/null +++ b/frontend/__tests__/utils/permission-checks.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { PermissionKey } from "#/utils/org/permissions"; + +// Mock dependencies for getActiveOrganizationUser tests +vi.mock("#/api/organization-service/organization-service.api", () => ({ + organizationService: { + getMe: vi.fn(), + }, +})); + +vi.mock("#/stores/selected-organization-store", () => ({ + getSelectedOrganizationIdFromStore: vi.fn(), +})); + +vi.mock("#/utils/query-client-getters", () => ({ + getMeFromQueryClient: vi.fn(), +})); + +vi.mock("#/query-client-config", () => ({ + queryClient: { + setQueryData: vi.fn(), + }, +})); + +// Import after mocks are set up +import { + getAvailableRolesAUserCanAssign, + getActiveOrganizationUser, +} from "#/utils/org/permission-checks"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store"; +import { getMeFromQueryClient } from "#/utils/query-client-getters"; + +describe("getAvailableRolesAUserCanAssign", () => { + it("returns empty array if user has no permissions", () => { + const result = getAvailableRolesAUserCanAssign([]); + expect(result).toEqual([]); + }); + + it("returns only roles the user has permission for", () => { + const userPermissions: PermissionKey[] = [ + "change_user_role:member", + "change_user_role:admin", + ]; + const result = getAvailableRolesAUserCanAssign(userPermissions); + expect(result.sort()).toEqual(["admin", "member"].sort()); + }); + + it("returns all roles if user has all permissions", () => { + const allPermissions: PermissionKey[] = [ + "change_user_role:member", + "change_user_role:admin", + "change_user_role:owner", + ]; + const result = getAvailableRolesAUserCanAssign(allPermissions); + expect(result.sort()).toEqual(["member", "admin", "owner"].sort()); + }); +}); + +describe("getActiveOrganizationUser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return undefined when API call throws an error", async () => { + // Arrange: orgId exists, cache is empty, API call fails + vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1"); + vi.mocked(getMeFromQueryClient).mockReturnValue(undefined); + vi.mocked(organizationService.getMe).mockRejectedValue( + new Error("Network error"), + ); + + // Act + const result = await getActiveOrganizationUser(); + + // Assert: should return undefined instead of propagating the error + expect(result).toBeUndefined(); + }); +}); diff --git a/frontend/__tests__/utils/permission-guard.test.ts b/frontend/__tests__/utils/permission-guard.test.ts new file mode 100644 index 0000000000..5b1d19dd51 --- /dev/null +++ b/frontend/__tests__/utils/permission-guard.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { redirect } from "react-router"; + +// Mock dependencies before importing the module under test +vi.mock("react-router", () => ({ + redirect: vi.fn((path: string) => ({ type: "redirect", path })), +})); + +vi.mock("#/utils/org/permission-checks", () => ({ + getActiveOrganizationUser: vi.fn(), +})); + +vi.mock("#/api/option-service/option-service.api", () => ({ + default: { + getConfig: vi.fn().mockResolvedValue({ + app_mode: "saas", + feature_flags: { + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + hide_llm_settings: false, + }, + }), + }, +})); + +const mockConfig = { + app_mode: "saas", + feature_flags: { + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + hide_llm_settings: false, + }, +}; + +vi.mock("#/query-client-config", () => ({ + queryClient: { + getQueryData: vi.fn(() => mockConfig), + setQueryData: vi.fn(), + }, +})); + +// Import after mocks are set up +import { createPermissionGuard } from "#/utils/org/permission-guard"; +import { getActiveOrganizationUser } from "#/utils/org/permission-checks"; + +// Helper to create a mock request +const createMockRequest = (pathname: string = "/settings/billing") => ({ + request: new Request(`http://localhost${pathname}`), +}); + +describe("createPermissionGuard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("permission checking", () => { + it("should redirect when user lacks required permission", async () => { + // Arrange: member lacks view_billing permission + vi.mocked(getActiveOrganizationUser).mockResolvedValue({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + }); + + // Act + const guard = createPermissionGuard("view_billing"); + await guard(createMockRequest("/settings/billing")); + + // Assert: should redirect to first available path (/settings/user in SaaS mode) + expect(redirect).toHaveBeenCalledWith("/settings/user"); + }); + + it("should allow access when user has required permission", async () => { + // Arrange: admin has view_billing permission + vi.mocked(getActiveOrganizationUser).mockResolvedValue({ + org_id: "org-1", + user_id: "user-1", + email: "admin@example.com", + role: "admin", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + }); + + // Act + const guard = createPermissionGuard("view_billing"); + const result = await guard(createMockRequest("/settings/billing")); + + // Assert: should not redirect, return null + expect(redirect).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("should redirect when user is undefined (no org selected)", async () => { + // Arrange: no user (e.g., no organization selected) + vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined); + + // Act + const guard = createPermissionGuard("view_billing"); + await guard(createMockRequest("/settings/billing")); + + // Assert: should redirect to first available path + expect(redirect).toHaveBeenCalledWith("/settings/user"); + }); + + it("should redirect when user is undefined even for member-level permissions", async () => { + // Arrange: no user — manage_secrets is a member-level permission, + // but undefined user should NOT get member access + vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined); + + // Act + const guard = createPermissionGuard("manage_secrets"); + await guard(createMockRequest("/settings/secrets")); + + // Assert: should redirect, not silently grant member-level access + expect(redirect).toHaveBeenCalledWith("/settings/user"); + }); + }); + + describe("custom redirect path", () => { + it("should redirect to custom path when specified", async () => { + // Arrange: member lacks permission + vi.mocked(getActiveOrganizationUser).mockResolvedValue({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + }); + + // Act + const guard = createPermissionGuard("view_billing", "/custom/redirect"); + await guard(createMockRequest("/settings/billing")); + + // Assert: should redirect to custom path + expect(redirect).toHaveBeenCalledWith("/custom/redirect"); + }); + }); + + describe("infinite loop prevention", () => { + it("should return null instead of redirecting when fallback path equals current path", async () => { + // Arrange: no user + vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined); + + // Act: access /settings/user when fallback would also be /settings/user + const guard = createPermissionGuard("view_billing"); + const result = await guard(createMockRequest("/settings/user")); + + // Assert: should NOT redirect to avoid infinite loop + expect(redirect).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); +}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 4cd811ade2..0ca603e0ca 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -75,7 +75,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: "npm run dev:mock -- --port 3001", + command: "npm run dev:mock:saas -- --port 3001", url: "http://localhost:3001/", reuseExistingServer: !process.env.CI, }, diff --git a/frontend/src/api/organization-service/organization-service.api.ts b/frontend/src/api/organization-service/organization-service.api.ts new file mode 100644 index 0000000000..690adbac50 --- /dev/null +++ b/frontend/src/api/organization-service/organization-service.api.ts @@ -0,0 +1,159 @@ +import { + Organization, + OrganizationMember, + OrganizationMembersPage, + UpdateOrganizationMemberParams, +} from "#/types/org"; +import { openHands } from "../open-hands-axios"; + +export const organizationService = { + getMe: async ({ orgId }: { orgId: string }) => { + const { data } = await openHands.get( + `/api/organizations/${orgId}/me`, + ); + + return data; + }, + + getOrganization: async ({ orgId }: { orgId: string }) => { + const { data } = await openHands.get( + `/api/organizations/${orgId}`, + ); + return data; + }, + + getOrganizations: async () => { + const { data } = await openHands.get<{ + items: Organization[]; + current_org_id: string | null; + }>("/api/organizations"); + return { + items: data?.items || [], + currentOrgId: data?.current_org_id || null, + }; + }, + + updateOrganization: async ({ + orgId, + name, + }: { + orgId: string; + name: string; + }) => { + const { data } = await openHands.patch( + `/api/organizations/${orgId}`, + { name }, + ); + return data; + }, + + deleteOrganization: async ({ orgId }: { orgId: string }) => { + await openHands.delete(`/api/organizations/${orgId}`); + }, + + getOrganizationMembers: async ({ + orgId, + page = 1, + limit = 10, + email, + }: { + orgId: string; + page?: number; + limit?: number; + email?: string; + }) => { + const params = new URLSearchParams(); + + // Calculate offset from page number (page_id is offset-based) + const offset = (page - 1) * limit; + params.set("page_id", String(offset)); + params.set("limit", String(limit)); + + if (email) { + params.set("email", email); + } + + const { data } = await openHands.get( + `/api/organizations/${orgId}/members?${params.toString()}`, + ); + + return data; + }, + + getOrganizationMembersCount: async ({ + orgId, + email, + }: { + orgId: string; + email?: string; + }) => { + const params = new URLSearchParams(); + + if (email) { + params.set("email", email); + } + + const { data } = await openHands.get( + `/api/organizations/${orgId}/members/count?${params.toString()}`, + ); + + return data; + }, + + getOrganizationPaymentInfo: async ({ orgId }: { orgId: string }) => { + const { data } = await openHands.get<{ + cardNumber: string; + }>(`/api/organizations/${orgId}/payment`); + return data; + }, + + updateMember: async ({ + orgId, + userId, + ...updateData + }: { + orgId: string; + userId: string; + } & UpdateOrganizationMemberParams) => { + const { data } = await openHands.patch( + `/api/organizations/${orgId}/members/${userId}`, + updateData, + ); + + return data; + }, + + removeMember: async ({ + orgId, + userId, + }: { + orgId: string; + userId: string; + }) => { + await openHands.delete(`/api/organizations/${orgId}/members/${userId}`); + }, + + inviteMembers: async ({ + orgId, + emails, + }: { + orgId: string; + emails: string[]; + }) => { + const { data } = await openHands.post( + `/api/organizations/${orgId}/members/invite`, + { + emails, + }, + ); + + return data; + }, + + switchOrganization: async ({ orgId }: { orgId: string }) => { + const { data } = await openHands.post( + `/api/organizations/${orgId}/switch`, + ); + return data; + }, +}; diff --git a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx deleted file mode 100644 index cdc18521cb..0000000000 --- a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; -import { useFeatureFlagEnabled } from "posthog-js/react"; -import { ContextMenu } from "#/ui/context-menu"; -import { ContextMenuListItem } from "./context-menu-list-item"; -import { Divider } from "#/ui/divider"; -import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; -import { I18nKey } from "#/i18n/declaration"; -import LogOutIcon from "#/icons/log-out.svg?react"; -import DocumentIcon from "#/icons/document.svg?react"; -import PlusIcon from "#/icons/plus.svg?react"; -import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; -import { useConfig } from "#/hooks/query/use-config"; -import { useSettings } from "#/hooks/query/use-settings"; -import { useTracking } from "#/hooks/use-tracking"; - -interface AccountSettingsContextMenuProps { - onLogout: () => void; - onClose: () => void; -} - -export function AccountSettingsContextMenu({ - onLogout, - onClose, -}: AccountSettingsContextMenuProps) { - const ref = useClickOutsideElement(onClose); - const { t } = useTranslation(); - const { trackAddTeamMembersButtonClick } = useTracking(); - const { data: config } = useConfig(); - const { data: settings } = useSettings(); - const isAddTeamMemberEnabled = useFeatureFlagEnabled( - "exp_add_team_member_button", - ); - // Get navigation items and filter out LLM settings if the feature flag is enabled - const items = useSettingsNavItems(); - - const isSaasMode = config?.app_mode === "saas"; - const hasAnalyticsConsent = settings?.user_consents_to_analytics === true; - const showAddTeamMembers = - isSaasMode && isAddTeamMemberEnabled && hasAnalyticsConsent; - - const navItems = items.map((item) => ({ - ...item, - icon: React.cloneElement(item.icon, { - width: 16, - height: 16, - } as React.SVGProps), - })); - const handleNavigationClick = () => onClose(); - - const handleAddTeamMembers = () => { - trackAddTeamMembersButtonClick(); - onClose(); - }; - - return ( - - {showAddTeamMembers && ( - - - - {t(I18nKey.SETTINGS$NAV_ADD_TEAM_MEMBERS)} - - - )} - {navItems.map(({ to, text, icon }) => ( - - - {icon} - {t(text)} - - - ))} - - - - - - - {t(I18nKey.SIDEBAR$DOCS)} - - - - - - - {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)} - - - - ); -} diff --git a/frontend/src/components/features/org/change-org-name-modal.tsx b/frontend/src/components/features/org/change-org-name-modal.tsx new file mode 100644 index 0000000000..56adb4e6c9 --- /dev/null +++ b/frontend/src/components/features/org/change-org-name-modal.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { I18nKey } from "#/i18n/declaration"; +import { useUpdateOrganization } from "#/hooks/mutation/use-update-organization"; + +interface ChangeOrgNameModalProps { + onClose: () => void; +} + +export function ChangeOrgNameModal({ onClose }: ChangeOrgNameModalProps) { + const { t } = useTranslation(); + const { mutate: updateOrganization, isPending } = useUpdateOrganization(); + const [orgName, setOrgName] = useState(""); + + const handleSubmit = () => { + if (orgName?.trim()) { + updateOrganization(orgName, { + onSuccess: () => { + onClose(); + }, + }); + } + }; + + return ( + + setOrgName(e.target.value)} + className="bg-tertiary border border-[#717888] h-10 w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt" + /> + + ); +} diff --git a/frontend/src/components/features/org/confirm-remove-member-modal.tsx b/frontend/src/components/features/org/confirm-remove-member-modal.tsx new file mode 100644 index 0000000000..bfda8c7d32 --- /dev/null +++ b/frontend/src/components/features/org/confirm-remove-member-modal.tsx @@ -0,0 +1,42 @@ +import { Trans, useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { I18nKey } from "#/i18n/declaration"; + +interface ConfirmRemoveMemberModalProps { + onConfirm: () => void; + onCancel: () => void; + memberEmail: string; + isLoading?: boolean; +} + +export function ConfirmRemoveMemberModal({ + onConfirm, + onCancel, + memberEmail, + isLoading = false, +}: ConfirmRemoveMemberModalProps) { + const { t } = useTranslation(); + + const confirmationMessage = ( + }} + /> + ); + + return ( + + ); +} diff --git a/frontend/src/components/features/org/confirm-update-role-modal.tsx b/frontend/src/components/features/org/confirm-update-role-modal.tsx new file mode 100644 index 0000000000..f0b84affad --- /dev/null +++ b/frontend/src/components/features/org/confirm-update-role-modal.tsx @@ -0,0 +1,47 @@ +import { Trans, useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { I18nKey } from "#/i18n/declaration"; +import { OrganizationUserRole } from "#/types/org"; + +interface ConfirmUpdateRoleModalProps { + onConfirm: () => void; + onCancel: () => void; + memberEmail: string; + newRole: OrganizationUserRole; + isLoading?: boolean; +} + +export function ConfirmUpdateRoleModal({ + onConfirm, + onCancel, + memberEmail, + newRole, + isLoading = false, +}: ConfirmUpdateRoleModalProps) { + const { t } = useTranslation(); + + const confirmationMessage = ( + , + role: , + }} + /> + ); + + return ( + + ); +} diff --git a/frontend/src/components/features/org/delete-org-confirmation-modal.tsx b/frontend/src/components/features/org/delete-org-confirmation-modal.tsx new file mode 100644 index 0000000000..164355e6d2 --- /dev/null +++ b/frontend/src/components/features/org/delete-org-confirmation-modal.tsx @@ -0,0 +1,52 @@ +import { Trans, useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { I18nKey } from "#/i18n/declaration"; +import { useDeleteOrganization } from "#/hooks/mutation/use-delete-organization"; +import { useOrganization } from "#/hooks/query/use-organization"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; + +interface DeleteOrgConfirmationModalProps { + onClose: () => void; +} + +export function DeleteOrgConfirmationModal({ + onClose, +}: DeleteOrgConfirmationModalProps) { + const { t } = useTranslation(); + const { mutate: deleteOrganization, isPending } = useDeleteOrganization(); + const { data: organization } = useOrganization(); + + const handleConfirm = () => { + deleteOrganization(undefined, { + onSuccess: onClose, + onError: () => { + displayErrorToast(t(I18nKey.ORG$DELETE_ORGANIZATION_ERROR)); + }, + }); + }; + + const confirmationMessage = organization?.name ? ( + }} + /> + ) : ( + t(I18nKey.ORG$DELETE_ORGANIZATION_WARNING) + ); + + return ( + + ); +} diff --git a/frontend/src/components/features/org/invite-organization-member-modal.tsx b/frontend/src/components/features/org/invite-organization-member-modal.tsx new file mode 100644 index 0000000000..37ce1338cc --- /dev/null +++ b/frontend/src/components/features/org/invite-organization-member-modal.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { useInviteMembersBatch } from "#/hooks/mutation/use-invite-members-batch"; +import { BadgeInput } from "#/components/shared/inputs/badge-input"; +import { I18nKey } from "#/i18n/declaration"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { areAllEmailsValid, hasDuplicates } from "#/utils/input-validation"; + +interface InviteOrganizationMemberModalProps { + onClose: (event?: React.MouseEvent) => void; +} + +export function InviteOrganizationMemberModal({ + onClose, +}: InviteOrganizationMemberModalProps) { + const { t } = useTranslation(); + const { mutate: inviteMembers, isPending } = useInviteMembersBatch(); + const [emails, setEmails] = React.useState([]); + + const handleEmailsChange = (newEmails: string[]) => { + const trimmedEmails = newEmails.map((email) => email.trim()); + setEmails(trimmedEmails); + }; + + const handleSubmit = () => { + if (emails.length === 0) { + displayErrorToast(t(I18nKey.ORG$NO_EMAILS_ADDED_HINT)); + return; + } + + if (!areAllEmailsValid(emails)) { + displayErrorToast(t(I18nKey.SETTINGS$INVALID_EMAIL_FORMAT)); + return; + } + + if (hasDuplicates(emails)) { + displayErrorToast(t(I18nKey.ORG$DUPLICATE_EMAILS_ERROR)); + return; + } + + inviteMembers( + { emails }, + { + onSuccess: () => onClose(), + }, + ); + }; + + return ( + + + + ); +} diff --git a/frontend/src/components/features/org/org-selector.tsx b/frontend/src/components/features/org/org-selector.tsx new file mode 100644 index 0000000000..d5b982a112 --- /dev/null +++ b/frontend/src/components/features/org/org-selector.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { useSwitchOrganization } from "#/hooks/mutation/use-switch-organization"; +import { useOrganizations } from "#/hooks/query/use-organizations"; +import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector"; +import { I18nKey } from "#/i18n/declaration"; +import { Organization } from "#/types/org"; +import { Dropdown } from "#/ui/dropdown/dropdown"; + +export function OrgSelector() { + const { t } = useTranslation(); + const { organizationId } = useSelectedOrganizationId(); + const { data, isLoading } = useOrganizations(); + const organizations = data?.organizations; + const { mutate: switchOrganization, isPending: isSwitching } = + useSwitchOrganization(); + const shouldHideSelector = useShouldHideOrgSelector(); + + const getOrgDisplayName = React.useCallback( + (org: Organization) => + org.is_personal ? t(I18nKey.ORG$PERSONAL_WORKSPACE) : org.name, + [t], + ); + + const selectedOrg = React.useMemo(() => { + if (organizationId) { + return organizations?.find((org) => org.id === organizationId); + } + + return organizations?.[0]; + }, [organizationId, organizations]); + + if (shouldHideSelector) { + return null; + } + + return ( + { + if (item && item.value !== organizationId) { + switchOrganization(item.value); + } + }} + placeholder={t(I18nKey.ORG$SELECT_ORGANIZATION_PLACEHOLDER)} + loading={isLoading || isSwitching} + options={ + organizations?.map((org) => ({ + value: org.id, + label: getOrgDisplayName(org), + })) || [] + } + /> + ); +} diff --git a/frontend/src/components/features/org/organization-member-list-item.tsx b/frontend/src/components/features/org/organization-member-list-item.tsx new file mode 100644 index 0000000000..fdff1f43bc --- /dev/null +++ b/frontend/src/components/features/org/organization-member-list-item.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; +import { OrganizationMember, OrganizationUserRole } from "#/types/org"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; +import { OrganizationMemberRoleContextMenu } from "./organization-member-role-context-menu"; + +interface OrganizationMemberListItemProps { + email: OrganizationMember["email"]; + role: OrganizationMember["role"]; + status: OrganizationMember["status"]; + hasPermissionToChangeRole: boolean; + availableRolesToChangeTo: OrganizationUserRole[]; + + onRoleChange: (role: OrganizationUserRole) => void; + onRemove?: () => void; +} + +export function OrganizationMemberListItem({ + email, + role, + status, + hasPermissionToChangeRole, + availableRolesToChangeTo, + onRoleChange, + onRemove, +}: OrganizationMemberListItemProps) { + const { t } = useTranslation(); + const [contextMenuOpen, setContextMenuOpen] = React.useState(false); + + const roleSelectionIsPermitted = + status !== "invited" && hasPermissionToChangeRole; + + const handleRoleClick = (event: React.MouseEvent) => { + if (roleSelectionIsPermitted) { + event.preventDefault(); + event.stopPropagation(); + setContextMenuOpen(true); + } + }; + + return ( +
+
+ + {email} + + + {status === "invited" && ( + + {t(I18nKey.ORG$STATUS_INVITED)} + + )} +
+ +
+ + {role} + {hasPermissionToChangeRole && } + + + {roleSelectionIsPermitted && contextMenuOpen && ( + setContextMenuOpen(false)} + onRoleChange={onRoleChange} + onRemove={onRemove} + availableRolesToChangeTo={availableRolesToChangeTo} + /> + )} +
+
+ ); +} diff --git a/frontend/src/components/features/org/organization-member-role-context-menu.tsx b/frontend/src/components/features/org/organization-member-role-context-menu.tsx new file mode 100644 index 0000000000..84278035e6 --- /dev/null +++ b/frontend/src/components/features/org/organization-member-role-context-menu.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { ContextMenu } from "#/ui/context-menu"; +import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { ContextMenuIconText } from "#/ui/context-menu-icon-text"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { OrganizationUserRole } from "#/types/org"; +import { cn } from "#/utils/utils"; +import UserIcon from "#/icons/user.svg?react"; +import DeleteIcon from "#/icons/u-delete.svg?react"; +import AdminIcon from "#/icons/admin.svg?react"; + +const contextMenuListItemClassName = cn( + "cursor-pointer p-0 h-auto hover:bg-transparent", +); + +interface OrganizationMemberRoleContextMenuProps { + onClose: () => void; + onRoleChange: (role: OrganizationUserRole) => void; + onRemove?: () => void; + availableRolesToChangeTo: OrganizationUserRole[]; +} + +export function OrganizationMemberRoleContextMenu({ + onClose, + onRoleChange, + onRemove, + availableRolesToChangeTo, +}: OrganizationMemberRoleContextMenuProps) { + const { t } = useTranslation(); + const menuRef = useClickOutsideElement(onClose); + + const handleRoleChangeClick = ( + event: React.MouseEvent, + role: OrganizationUserRole, + ) => { + event.preventDefault(); + event.stopPropagation(); + onRoleChange(role); + onClose(); + }; + + const handleRemoveClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onRemove?.(); + onClose(); + }; + + return ( + + {availableRolesToChangeTo.includes("owner") && ( + handleRoleChangeClick(event, "owner")} + className={contextMenuListItemClassName} + > + + } + text={t(I18nKey.ORG$ROLE_OWNER)} + className="capitalize" + /> + + )} + {availableRolesToChangeTo.includes("admin") && ( + handleRoleChangeClick(event, "admin")} + className={contextMenuListItemClassName} + > + + } + text={t(I18nKey.ORG$ROLE_ADMIN)} + className="capitalize" + /> + + )} + {availableRolesToChangeTo.includes("member") && ( + handleRoleChangeClick(event, "member")} + className={contextMenuListItemClassName} + > + } + text={t(I18nKey.ORG$ROLE_MEMBER)} + className="capitalize" + /> + + )} + + } + text={t(I18nKey.ORG$REMOVE)} + className="text-red-500 capitalize" + /> + + + ); +} diff --git a/frontend/src/components/features/payment/payment-form.tsx b/frontend/src/components/features/payment/payment-form.tsx index 926ae831e0..0ac9ede2d6 100644 --- a/frontend/src/components/features/payment/payment-form.tsx +++ b/frontend/src/components/features/payment/payment-form.tsx @@ -11,7 +11,7 @@ import { amountIsValid } from "#/utils/amount-is-valid"; import { I18nKey } from "#/i18n/declaration"; import { PoweredByStripeTag } from "./powered-by-stripe-tag"; -export function PaymentForm() { +export function PaymentForm({ isDisabled }: { isDisabled?: boolean }) { const { t } = useTranslation(); const { data: balance, isLoading } = useBalance(); const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession(); @@ -69,13 +69,14 @@ export function PaymentForm() { min={10} max={25000} step={1} + isDisabled={isDisabled} />
{t(I18nKey.PAYMENT$ADD_CREDIT)} diff --git a/frontend/src/components/features/payment/setup-payment-modal.tsx b/frontend/src/components/features/payment/setup-payment-modal.tsx index 7d8883a719..a6513840b3 100644 --- a/frontend/src/components/features/payment/setup-payment-modal.tsx +++ b/frontend/src/components/features/payment/setup-payment-modal.tsx @@ -31,7 +31,7 @@ export function SetupPaymentModal() { variant="primary" className="w-full" isDisabled={isPending} - onClick={mutate} + onClick={() => mutate()} > {t(I18nKey.BILLING$PROCEED_TO_STRIPE)} diff --git a/frontend/src/components/features/settings/brand-button.tsx b/frontend/src/components/features/settings/brand-button.tsx index 624a03e915..4dc98e3613 100644 --- a/frontend/src/components/features/settings/brand-button.tsx +++ b/frontend/src/components/features/settings/brand-button.tsx @@ -7,7 +7,7 @@ interface BrandButtonProps { type: React.ButtonHTMLAttributes["type"]; isDisabled?: boolean; className?: string; - onClick?: () => void; + onClick?: (event?: React.MouseEvent) => void; startContent?: React.ReactNode; } diff --git a/frontend/src/components/features/settings/settings-dropdown-input.tsx b/frontend/src/components/features/settings/settings-dropdown-input.tsx index da0595795d..01b3ef5325 100644 --- a/frontend/src/components/features/settings/settings-dropdown-input.tsx +++ b/frontend/src/components/features/settings/settings-dropdown-input.tsx @@ -63,7 +63,7 @@ export function SettingsDropdownInput({ aria-label={typeof label === "string" ? label : name} data-testid={testId} name={name} - defaultItems={items} + items={items} defaultSelectedKey={defaultSelectedKey} selectedKey={selectedKey} onSelectionChange={onSelectionChange} @@ -76,7 +76,7 @@ export function SettingsDropdownInput({ isRequired={required} className="w-full" classNames={{ - popoverContent: "bg-tertiary rounded-xl border border-[#717888]", + popoverContent: "bg-tertiary rounded-xl", }} inputProps={{ classNames: { diff --git a/frontend/src/components/features/settings/settings-navigation.tsx b/frontend/src/components/features/settings/settings-navigation.tsx index 5a35f01495..bbc4f57795 100644 --- a/frontend/src/components/features/settings/settings-navigation.tsx +++ b/frontend/src/components/features/settings/settings-navigation.tsx @@ -5,7 +5,9 @@ import { Typography } from "#/ui/typography"; import { I18nKey } from "#/i18n/declaration"; import SettingsIcon from "#/icons/settings-gear.svg?react"; import CloseIcon from "#/icons/close.svg?react"; +import { OrgSelector } from "../org/org-selector"; import { SettingsNavItem } from "#/constants/settings-nav"; +import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector"; interface SettingsNavigationProps { isMobileMenuOpen: boolean; @@ -19,6 +21,7 @@ export function SettingsNavigation({ navigationItems, }: SettingsNavigationProps) { const { t } = useTranslation(); + const shouldHideSelector = useShouldHideOrgSelector(); return ( <> @@ -50,13 +53,15 @@ export function SettingsNavigation({
+ {!shouldHideSelector && } +
{navigationItems.map(({ to, icon, text }) => ( cn( - "flex items-center gap-3 p-1 sm:px-[14px] sm:py-2 rounded-md transition-colors", - isActive ? "bg-[#454545]" : "hover:bg-[#454545]", + "group flex items-center gap-3 p-1 sm:px-3.5 sm:py-2 rounded-md transition-all duration-200", + isActive ? "bg-tertiary" : "hover:bg-tertiary", ) } > - {icon} -
- + + {icon} + +
+ {t(text as I18nKey)}
diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index f7b11cf2d7..258eda9e30 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -10,7 +10,6 @@ import { SettingsModal } from "#/components/shared/modals/settings/settings-moda import { useSettings } from "#/hooks/query/use-settings"; import { ConversationPanel } from "../conversation-panel/conversation-panel"; import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper"; -import { useLogout } from "#/hooks/mutation/use-logout"; import { useConfig } from "#/hooks/query/use-config"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; @@ -27,7 +26,6 @@ export function Sidebar() { isError: settingsIsError, isFetching: isFetchingSettings, } = useSettings(); - const { mutate: logout } = useLogout(); const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false); @@ -96,7 +94,6 @@ export function Sidebar() { user={ user.data ? { avatar_url: user.data.avatar_url } : undefined } - onLogout={logout} isLoading={user.isFetching} />
diff --git a/frontend/src/components/features/sidebar/user-actions.tsx b/frontend/src/components/features/sidebar/user-actions.tsx index 56584f78f5..3620663789 100644 --- a/frontend/src/components/features/sidebar/user-actions.tsx +++ b/frontend/src/components/features/sidebar/user-actions.tsx @@ -1,74 +1,88 @@ import React from "react"; +import ReactDOM from "react-dom"; import { UserAvatar } from "./user-avatar"; -import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu"; +import { useMe } from "#/hooks/query/use-me"; import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features"; +import { UserContextMenu } from "../user/user-context-menu"; +import { InviteOrganizationMemberModal } from "../org/invite-organization-member-modal"; import { cn } from "#/utils/utils"; -import { useConfig } from "#/hooks/query/use-config"; interface UserActionsProps { - onLogout: () => void; user?: { avatar_url: string }; isLoading?: boolean; } -export function UserActions({ onLogout, user, isLoading }: UserActionsProps) { +export function UserActions({ user, isLoading }: UserActionsProps) { + const { data: me } = useMe(); const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] = React.useState(false); - - const { data: config } = useConfig(); + // Counter that increments each time the menu hides, used as a React key + // to force UserContextMenu to remount with fresh state (resets dropdown + // open/close, search text, and scroll position in the org selector). + const [menuResetCount, setMenuResetCount] = React.useState(0); + const [inviteMemberModalIsOpen, setInviteMemberModalIsOpen] = + React.useState(false); // Use the shared hook to determine if user actions should be shown const shouldShowUserActions = useShouldShowUserFeatures(); - const toggleAccountMenu = () => { - // Always toggle the menu, even if user is undefined - setAccountContextMenuIsVisible((prev) => !prev); + const showAccountMenu = () => { + setAccountContextMenuIsVisible(true); + }; + + const hideAccountMenu = () => { + setAccountContextMenuIsVisible(false); + setMenuResetCount((c) => c + 1); }; const closeAccountMenu = () => { if (accountContextMenuIsVisible) { setAccountContextMenuIsVisible(false); + setMenuResetCount((c) => c + 1); } }; - const handleLogout = () => { - onLogout(); - closeAccountMenu(); + const openInviteMemberModal = () => { + setInviteMemberModalIsOpen(true); }; - const isOSS = config?.app_mode === "oss"; - - // Show the menu based on the new logic - const showMenu = - accountContextMenuIsVisible && (shouldShowUserActions || isOSS); - return ( -
- + <> +
+ - {(shouldShowUserActions || isOSS) && ( -
- -
- )} -
+ {shouldShowUserActions && user && ( +
+ +
+ )} +
+ + {inviteMemberModalIsOpen && + ReactDOM.createPortal( + setInviteMemberModalIsOpen(false)} + />, + document.getElementById("portal-root") || document.body, + )} + ); } diff --git a/frontend/src/components/features/sidebar/user-avatar.tsx b/frontend/src/components/features/sidebar/user-avatar.tsx index 2f9b5e9d30..52e0ecc2e9 100644 --- a/frontend/src/components/features/sidebar/user-avatar.tsx +++ b/frontend/src/components/features/sidebar/user-avatar.tsx @@ -6,12 +6,11 @@ import { cn } from "#/utils/utils"; import { Avatar } from "./avatar"; interface UserAvatarProps { - onClick: () => void; avatarUrl?: string; isLoading?: boolean; } -export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) { +export function UserAvatar({ avatarUrl, isLoading }: UserAvatarProps) { const { t } = useTranslation(); return ( @@ -22,7 +21,6 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) { "w-8 h-8 rounded-full flex items-center justify-center cursor-pointer", isLoading && "bg-transparent", )} - onClick={onClick} > {!isLoading && avatarUrl && } {!isLoading && !avatarUrl && ( diff --git a/frontend/src/components/features/user/user-context-menu.tsx b/frontend/src/components/features/user/user-context-menu.tsx new file mode 100644 index 0000000000..c78d6c15f3 --- /dev/null +++ b/frontend/src/components/features/user/user-context-menu.tsx @@ -0,0 +1,168 @@ +import React from "react"; +import { Link, useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { + IoCardOutline, + IoLogOutOutline, + IoPersonAddOutline, +} from "react-icons/io5"; +import { FiUsers } from "react-icons/fi"; +import { useLogout } from "#/hooks/mutation/use-logout"; +import { OrganizationUserRole } from "#/types/org"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access"; +import { cn } from "#/utils/utils"; +import { OrgSelector } from "../org/org-selector"; +import { I18nKey } from "#/i18n/declaration"; +import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; +import DocumentIcon from "#/icons/document.svg?react"; +import { Divider } from "#/ui/divider"; +import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector"; + +// Shared className for context menu list items in the user context menu +const contextMenuListItemClassName = cn( + "flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded", +); + +interface UserContextMenuProps { + type: OrganizationUserRole; + onClose: () => void; + onOpenInviteModal: () => void; +} + +export function UserContextMenu({ + type, + onClose, + onOpenInviteModal, +}: UserContextMenuProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { mutate: logout } = useLogout(); + const { isPersonalOrg } = useOrgTypeAndAccess(); + const ref = useClickOutsideElement(onClose); + const settingsNavItems = useSettingsNavItems(); + const shouldHideSelector = useShouldHideOrgSelector(); + + // Filter out org routes since they're handled separately via buttons in this menu + const navItems = settingsNavItems.filter( + (item) => + item.to !== "/settings/org" && item.to !== "/settings/org-members", + ); + + const isMember = type === "member"; + + const handleLogout = () => { + logout(); + onClose(); + }; + + const handleInviteMemberClick = () => { + onOpenInviteModal(); + onClose(); + }; + + const handleManageOrganizationMembersClick = () => { + navigate("/settings/org-members"); + onClose(); + }; + + const handleManageAccountClick = () => { + navigate("/settings/org"); + onClose(); + }; + + return ( +
+

+ {t(I18nKey.ORG$ACCOUNT)} +

+ +
+ {!shouldHideSelector && ( +
+ +
+ )} + + {!isMember && !isPersonalOrg && ( +
+ + + {t(I18nKey.ORG$INVITE_ORG_MEMBERS)} + + + + + + + {t(I18nKey.COMMON$ORGANIZATION)} + + + + {t(I18nKey.ORG$ORGANIZATION_MEMBERS)} + + +
+ )} + +
+ {navItems.map((item) => ( + + {React.cloneElement(item.icon, { + className: "text-white", + width: 14, + height: 14, + } as React.SVGProps)} + {t(item.text)} + + ))} +
+ + + +
+ + + {t(I18nKey.SIDEBAR$DOCS)} + + + + + {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)} + +
+
+
+ ); +} diff --git a/frontend/src/components/shared/inputs/badge-input.tsx b/frontend/src/components/shared/inputs/badge-input.tsx index 3d06bd4bf3..cc34db4293 100644 --- a/frontend/src/components/shared/inputs/badge-input.tsx +++ b/frontend/src/components/shared/inputs/badge-input.tsx @@ -8,6 +8,8 @@ interface BadgeInputProps { value: string[]; placeholder?: string; onChange: (value: string[]) => void; + className?: string; + inputClassName?: string; } export function BadgeInput({ @@ -15,6 +17,8 @@ export function BadgeInput({ value, placeholder, onChange, + className, + inputClassName, }: BadgeInputProps) { const [inputValue, setInputValue] = React.useState(""); @@ -45,6 +49,7 @@ export function BadgeInput({ className={cn( "bg-tertiary border border-[#717888] rounded w-full p-2 placeholder:italic placeholder:text-tertiary-alt", "flex flex-wrap items-center gap-2", + className, )} > {value.map((badge, index) => ( @@ -69,7 +74,7 @@ export function BadgeInput({ placeholder={value.length === 0 ? placeholder : ""} onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} - className="flex-grow outline-none bg-transparent" + className={cn("flex-grow outline-none bg-transparent", inputClassName)} />
); diff --git a/frontend/src/components/shared/loading-spinner.tsx b/frontend/src/components/shared/loading-spinner.tsx index 5f19d0fe4d..d304dde2a4 100644 --- a/frontend/src/components/shared/loading-spinner.tsx +++ b/frontend/src/components/shared/loading-spinner.tsx @@ -3,21 +3,35 @@ import { cn } from "#/utils/utils"; interface LoadingSpinnerProps { size: "small" | "large"; + className?: string; + innerClassName?: string; + outerClassName?: string; } -export function LoadingSpinner({ size }: LoadingSpinnerProps) { +export function LoadingSpinner({ + size, + className, + innerClassName, + outerClassName, +}: LoadingSpinnerProps) { const sizeStyle = size === "small" ? "w-[25px] h-[25px]" : "w-[50px] h-[50px]"; return ( -
+
- +
); } diff --git a/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx b/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx index c5b4e3f255..0cda57e832 100644 --- a/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx +++ b/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx @@ -30,7 +30,7 @@ export function BaseModalDescription({ children, }: BaseModalDescriptionProps) { return ( - {children || description} + {children || description} ); } diff --git a/frontend/src/components/shared/modals/modal-backdrop.tsx b/frontend/src/components/shared/modals/modal-backdrop.tsx index 6f8c16016f..301f13c328 100644 --- a/frontend/src/components/shared/modals/modal-backdrop.tsx +++ b/frontend/src/components/shared/modals/modal-backdrop.tsx @@ -3,9 +3,14 @@ import React from "react"; interface ModalBackdropProps { children: React.ReactNode; onClose?: () => void; + "aria-label"?: string; } -export function ModalBackdrop({ children, onClose }: ModalBackdropProps) { +export function ModalBackdrop({ + children, + onClose, + "aria-label": ariaLabel, +}: ModalBackdropProps) { React.useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") onClose?.(); @@ -20,7 +25,12 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) { }; return ( -
+
void; + onSecondaryClick: () => void; + isLoading?: boolean; + primaryType?: "button" | "submit"; + primaryTestId?: string; + secondaryTestId?: string; + fullWidth?: boolean; +} + +export function ModalButtonGroup({ + primaryText, + secondaryText, + onPrimaryClick, + onSecondaryClick, + isLoading = false, + primaryType = "button", + primaryTestId, + secondaryTestId, + fullWidth = false, +}: ModalButtonGroupProps) { + const { t } = useTranslation(); + const closeText = secondaryText ?? t(I18nKey.BUTTON$CLOSE); + + return ( +
+ + {isLoading ? ( + + ) : ( + primaryText + )} + + + {closeText} + +
+ ); +} diff --git a/frontend/src/components/shared/modals/org-modal.tsx b/frontend/src/components/shared/modals/org-modal.tsx new file mode 100644 index 0000000000..054fc1f673 --- /dev/null +++ b/frontend/src/components/shared/modals/org-modal.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { ModalBackdrop } from "./modal-backdrop"; +import { ModalBody } from "./modal-body"; +import { ModalButtonGroup } from "./modal-button-group"; + +interface OrgModalProps { + testId?: string; + title: string; + description?: React.ReactNode; + children?: React.ReactNode; + primaryButtonText: string; + secondaryButtonText?: string; + onPrimaryClick?: () => void; + onClose: () => void; + isLoading?: boolean; + primaryButtonType?: "button" | "submit"; + primaryButtonTestId?: string; + secondaryButtonTestId?: string; + ariaLabel?: string; + asForm?: boolean; + formAction?: (formData: FormData) => void; + fullWidthButtons?: boolean; +} + +export function OrgModal({ + testId, + title, + description, + children, + primaryButtonText, + secondaryButtonText, + onPrimaryClick, + onClose, + isLoading = false, + primaryButtonType = "button", + primaryButtonTestId, + secondaryButtonTestId, + ariaLabel, + asForm = false, + formAction, + fullWidthButtons = false, +}: OrgModalProps) { + const content = ( + <> +
+

{title}

+ {description && ( +

{description}

+ )} + {children} +
+ + + ); + + const modalBodyClassName = + "items-start rounded-xl p-6 w-sm flex flex-col gap-4 bg-base-secondary border border-tertiary"; + + return ( + + {asForm ? ( +
+ {content} +
+ ) : ( + + {content} + + )} +
+ ); +} diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx index 0c55a9a0b4..fa6299e57a 100644 --- a/frontend/src/components/v1/chat/event-message.tsx +++ b/frontend/src/components/v1/chat/event-message.tsx @@ -126,7 +126,6 @@ const renderUserMessageWithSkillReady = ( ); } catch (error) { // If skill ready event creation fails, just render the user message - // Failed to create skill ready event, fallback to user message return ( , + }, + { + to: "/settings/org", + text: "Organization", + icon: , + }, ]; export const OSS_NAV_ITEMS: SettingsNavItem[] = [ diff --git a/frontend/src/context/use-selected-organization.ts b/frontend/src/context/use-selected-organization.ts new file mode 100644 index 0000000000..28c58ec4c4 --- /dev/null +++ b/frontend/src/context/use-selected-organization.ts @@ -0,0 +1,28 @@ +import { useRevalidator } from "react-router"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +interface SetOrganizationIdOptions { + /** Skip route revalidation. Useful for initial auto-selection to avoid duplicate API calls. */ + skipRevalidation?: boolean; +} + +export const useSelectedOrganizationId = () => { + const revalidator = useRevalidator(); + const { organizationId, setOrganizationId: setOrganizationIdStore } = + useSelectedOrganizationStore(); + + const setOrganizationId = ( + newOrganizationId: string | null, + options?: SetOrganizationIdOptions, + ) => { + setOrganizationIdStore(newOrganizationId); + // Revalidate route to ensure the latest orgId is used. + // This is useful for redirecting the user away from admin-only org pages. + // Skip revalidation for initial auto-selection to avoid duplicate API calls. + if (!options?.skipRevalidation) { + revalidator.revalidate(); + } + }; + + return { organizationId, setOrganizationId }; +}; diff --git a/frontend/src/hooks/mutation/use-delete-organization.ts b/frontend/src/hooks/mutation/use-delete-organization.ts new file mode 100644 index 0000000000..e8d41da277 --- /dev/null +++ b/frontend/src/hooks/mutation/use-delete-organization.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useDeleteOrganization = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { organizationId, setOrganizationId } = useSelectedOrganizationId(); + + return useMutation({ + mutationFn: () => { + if (!organizationId) throw new Error("Organization ID is required"); + return organizationService.deleteOrganization({ orgId: organizationId }); + }, + onSuccess: () => { + // Remove stale cache BEFORE clearing the selected organization. + // This prevents useAutoSelectOrganization from using the old currentOrgId + // when it runs during the re-render triggered by setOrganizationId(null). + // Using removeQueries (not invalidateQueries) ensures stale data is gone immediately. + queryClient.removeQueries({ + queryKey: ["organizations"], + exact: true, + }); + queryClient.removeQueries({ + queryKey: ["organizations", organizationId], + }); + + // Now clear the selected organization - useAutoSelectOrganization will + // wait for fresh data since the cache is empty + setOrganizationId(null); + + navigate("/"); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-invite-members-batch.ts b/frontend/src/hooks/mutation/use-invite-members-batch.ts new file mode 100644 index 0000000000..d82287da2c --- /dev/null +++ b/frontend/src/hooks/mutation/use-invite-members-batch.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { I18nKey } from "#/i18n/declaration"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; + +export const useInviteMembersBatch = () => { + const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: ({ emails }: { emails: string[] }) => { + if (!organizationId) { + throw new Error("Organization ID is required"); + } + return organizationService.inviteMembers({ + orgId: organizationId, + emails, + }); + }, + onSuccess: () => { + displaySuccessToast(t(I18nKey.ORG$INVITE_MEMBERS_SUCCESS)); + queryClient.invalidateQueries({ + queryKey: ["organizations", "members", organizationId], + }); + }, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + displayErrorToast(errorMessage || t(I18nKey.ORG$INVITE_MEMBERS_ERROR)); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-remove-member.ts b/frontend/src/hooks/mutation/use-remove-member.ts new file mode 100644 index 0000000000..583fe5a415 --- /dev/null +++ b/frontend/src/hooks/mutation/use-remove-member.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { I18nKey } from "#/i18n/declaration"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; + +export const useRemoveMember = () => { + const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: ({ userId }: { userId: string }) => { + if (!organizationId) { + throw new Error("Organization ID is required"); + } + return organizationService.removeMember({ + orgId: organizationId, + userId, + }); + }, + onSuccess: () => { + displaySuccessToast(t(I18nKey.ORG$REMOVE_MEMBER_SUCCESS)); + queryClient.invalidateQueries({ + queryKey: ["organizations", "members", organizationId], + }); + }, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + displayErrorToast(errorMessage || t(I18nKey.ORG$REMOVE_MEMBER_ERROR)); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-switch-organization.ts b/frontend/src/hooks/mutation/use-switch-organization.ts new file mode 100644 index 0000000000..45fadedaf4 --- /dev/null +++ b/frontend/src/hooks/mutation/use-switch-organization.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMatch, useNavigate } from "react-router"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useSwitchOrganization = () => { + const queryClient = useQueryClient(); + const { setOrganizationId } = useSelectedOrganizationId(); + const navigate = useNavigate(); + const conversationMatch = useMatch("/conversations/:conversationId"); + + return useMutation({ + mutationFn: (orgId: string) => + organizationService.switchOrganization({ orgId }), + onSuccess: (_, orgId) => { + // Invalidate the target org's /me query to ensure fresh data on every switch + queryClient.invalidateQueries({ + queryKey: ["organizations", orgId, "me"], + }); + // Update local state + setOrganizationId(orgId); + // Invalidate settings for the new org context + queryClient.invalidateQueries({ queryKey: ["settings"] }); + // Invalidate conversations to fetch data for the new org context + queryClient.invalidateQueries({ queryKey: ["user", "conversations"] }); + // Remove all individual conversation queries to clear any stale/null data + // from the previous org context + queryClient.removeQueries({ queryKey: ["user", "conversation"] }); + + // Redirect to home if on a conversation page since org context has changed + if (conversationMatch) { + navigate("/"); + } + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-unified-start-conversation.ts b/frontend/src/hooks/mutation/use-unified-start-conversation.ts index 778ba25359..fafa0f98dd 100644 --- a/frontend/src/hooks/mutation/use-unified-start-conversation.ts +++ b/frontend/src/hooks/mutation/use-unified-start-conversation.ts @@ -33,6 +33,20 @@ export const useUnifiedResumeConversationSandbox = () => { providers?: Provider[]; version?: "V0" | "V1"; }) => { + // Guard: If conversation is no longer in cache and no explicit version provided, + // skip the mutation. This handles race conditions like org switching where cache + // is cleared before the mutation executes. + // We return undefined (not throw) to avoid triggering the global MutationCache.onError + // handler which would display an error toast to the user. + const cachedConversation = queryClient.getQueryData([ + "user", + "conversation", + variables.conversationId, + ]); + if (!cachedConversation && !variables.version) { + return undefined; + } + // Use provided version or fallback to cache lookup const version = variables.version || diff --git a/frontend/src/hooks/mutation/use-update-member-role.ts b/frontend/src/hooks/mutation/use-update-member-role.ts new file mode 100644 index 0000000000..cb398c6246 --- /dev/null +++ b/frontend/src/hooks/mutation/use-update-member-role.ts @@ -0,0 +1,46 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { OrganizationUserRole } from "#/types/org"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { I18nKey } from "#/i18n/declaration"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; + +export const useUpdateMemberRole = () => { + const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ + userId, + role, + }: { + userId: string; + role: OrganizationUserRole; + }) => { + if (!organizationId) { + throw new Error("Organization ID is required to update member role"); + } + return organizationService.updateMember({ + orgId: organizationId, + userId, + role, + }); + }, + onSuccess: () => { + displaySuccessToast(t(I18nKey.ORG$UPDATE_ROLE_SUCCESS)); + queryClient.invalidateQueries({ + queryKey: ["organizations", "members", organizationId], + }); + }, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + displayErrorToast(errorMessage || t(I18nKey.ORG$UPDATE_ROLE_ERROR)); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-update-organization.ts b/frontend/src/hooks/mutation/use-update-organization.ts new file mode 100644 index 0000000000..a7ba9f1dfc --- /dev/null +++ b/frontend/src/hooks/mutation/use-update-organization.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useUpdateOrganization = () => { + const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); + + return useMutation({ + mutationFn: (name: string) => { + if (!organizationId) throw new Error("Organization ID is required"); + return organizationService.updateOrganization({ + orgId: organizationId, + name, + }); + }, + onSuccess: () => { + // Invalidate the specific organization query + queryClient.invalidateQueries({ + queryKey: ["organizations", organizationId], + }); + // Invalidate the organizations list to refresh org-selector + queryClient.invalidateQueries({ + queryKey: ["organizations"], + }); + }, + }); +}; diff --git a/frontend/src/hooks/organizations/use-permissions.ts b/frontend/src/hooks/organizations/use-permissions.ts new file mode 100644 index 0000000000..f6f1e4f0c9 --- /dev/null +++ b/frontend/src/hooks/organizations/use-permissions.ts @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import { OrganizationUserRole } from "#/types/org"; +import { rolePermissions, PermissionKey } from "#/utils/org/permissions"; + +export const usePermission = (role: OrganizationUserRole) => { + /* Memoize permissions for the role */ + const currentPermissions = useMemo( + () => rolePermissions[role], + [role], + ); + + /* Check if the user has a specific permission */ + const hasPermission = (permission: PermissionKey): boolean => + currentPermissions.includes(permission); + + return { hasPermission }; +}; diff --git a/frontend/src/hooks/query/use-me.ts b/frontend/src/hooks/query/use-me.ts new file mode 100644 index 0000000000..137bb151ec --- /dev/null +++ b/frontend/src/hooks/query/use-me.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { useConfig } from "./use-config"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useMe = () => { + const { data: config } = useConfig(); + const { organizationId } = useSelectedOrganizationId(); + + const isSaas = config?.app_mode === "saas"; + + return useQuery({ + queryKey: ["organizations", organizationId, "me"], + queryFn: () => organizationService.getMe({ orgId: organizationId! }), + staleTime: 1000 * 60 * 5, // 5 minutes + enabled: isSaas && !!organizationId, + }); +}; diff --git a/frontend/src/hooks/query/use-organization-members-count.ts b/frontend/src/hooks/query/use-organization-members-count.ts new file mode 100644 index 0000000000..9917f390fe --- /dev/null +++ b/frontend/src/hooks/query/use-organization-members-count.ts @@ -0,0 +1,24 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +interface UseOrganizationMembersCountParams { + email?: string; +} + +export const useOrganizationMembersCount = ({ + email, +}: UseOrganizationMembersCountParams = {}) => { + const { organizationId } = useSelectedOrganizationId(); + + return useQuery({ + queryKey: ["organizations", "members", "count", organizationId, email], + queryFn: () => + organizationService.getOrganizationMembersCount({ + orgId: organizationId!, + email: email || undefined, + }), + enabled: !!organizationId, + placeholderData: keepPreviousData, + }); +}; diff --git a/frontend/src/hooks/query/use-organization-members.ts b/frontend/src/hooks/query/use-organization-members.ts new file mode 100644 index 0000000000..559f8598e2 --- /dev/null +++ b/frontend/src/hooks/query/use-organization-members.ts @@ -0,0 +1,30 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +interface UseOrganizationMembersParams { + page?: number; + limit?: number; + email?: string; +} + +export const useOrganizationMembers = ({ + page = 1, + limit = 10, + email, +}: UseOrganizationMembersParams = {}) => { + const { organizationId } = useSelectedOrganizationId(); + + return useQuery({ + queryKey: ["organizations", "members", organizationId, page, limit, email], + queryFn: () => + organizationService.getOrganizationMembers({ + orgId: organizationId!, + page, + limit, + email: email || undefined, + }), + enabled: !!organizationId, + placeholderData: keepPreviousData, + }); +}; diff --git a/frontend/src/hooks/query/use-organization-payment-info.tsx b/frontend/src/hooks/query/use-organization-payment-info.tsx new file mode 100644 index 0000000000..736673704d --- /dev/null +++ b/frontend/src/hooks/query/use-organization-payment-info.tsx @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useOrganizationPaymentInfo = () => { + const { organizationId } = useSelectedOrganizationId(); + + return useQuery({ + queryKey: ["organizations", organizationId, "payment"], + queryFn: () => + organizationService.getOrganizationPaymentInfo({ + orgId: organizationId!, + }), + enabled: !!organizationId, + }); +}; diff --git a/frontend/src/hooks/query/use-organization.ts b/frontend/src/hooks/query/use-organization.ts new file mode 100644 index 0000000000..337eb8601a --- /dev/null +++ b/frontend/src/hooks/query/use-organization.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useOrganization = () => { + const { organizationId } = useSelectedOrganizationId(); + + return useQuery({ + queryKey: ["organizations", organizationId], + queryFn: () => + organizationService.getOrganization({ orgId: organizationId! }), + enabled: !!organizationId, + }); +}; diff --git a/frontend/src/hooks/query/use-organizations.ts b/frontend/src/hooks/query/use-organizations.ts new file mode 100644 index 0000000000..33d2b82e1f --- /dev/null +++ b/frontend/src/hooks/query/use-organizations.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useIsAuthed } from "./use-is-authed"; +import { useConfig } from "./use-config"; + +export const useOrganizations = () => { + const { data: userIsAuthenticated } = useIsAuthed(); + const { data: config } = useConfig(); + + // Organizations are a SaaS-only feature - disable in OSS mode + const isOssMode = config?.app_mode === "oss"; + + return useQuery({ + queryKey: ["organizations"], + queryFn: organizationService.getOrganizations, + staleTime: 1000 * 60 * 5, // 5 minutes + enabled: !!userIsAuthenticated && !isOssMode, + select: (data) => ({ + // Sort organizations with personal workspace first, then alphabetically by name + organizations: [...data.items].sort((a, b) => { + const aIsPersonal = a.is_personal ?? false; + const bIsPersonal = b.is_personal ?? false; + if (aIsPersonal && !bIsPersonal) return -1; + if (!aIsPersonal && bIsPersonal) return 1; + return (a.name ?? "").localeCompare(b.name ?? "", undefined, { + sensitivity: "base", + }); + }), + currentOrgId: data.currentOrgId, + }), + }); +}; diff --git a/frontend/src/hooks/use-auto-select-organization.ts b/frontend/src/hooks/use-auto-select-organization.ts new file mode 100644 index 0000000000..fd226a8ba2 --- /dev/null +++ b/frontend/src/hooks/use-auto-select-organization.ts @@ -0,0 +1,33 @@ +import React from "react"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { useOrganizations } from "#/hooks/query/use-organizations"; + +/** + * Hook that automatically selects an organization when: + * - No organization is currently selected in the frontend store + * - Organizations data is available + * + * Selection priority: + * 1. Backend's current_org_id (user's last selected organization, persisted server-side) + * 2. First organization in the list (fallback for new users) + * + * This hook should be called from a component that always renders (e.g., root layout) + * to ensure organization selection happens even when the OrgSelector component is hidden. + */ +export function useAutoSelectOrganization() { + const { organizationId, setOrganizationId } = useSelectedOrganizationId(); + const { data } = useOrganizations(); + const organizations = data?.organizations; + const currentOrgId = data?.currentOrgId; + + React.useEffect(() => { + if (!organizationId && organizations && organizations.length > 0) { + // Prefer backend's current_org_id (last selected org), fall back to first org + const initialOrgId = currentOrgId ?? organizations[0].id; + // Skip revalidation for initial auto-selection to avoid duplicate API calls. + // Revalidation is only needed when user explicitly switches organizations + // to redirect away from admin-only pages they may no longer have access to. + setOrganizationId(initialOrgId, { skipRevalidation: true }); + } + }, [organizationId, organizations, currentOrgId, setOrganizationId]); +} diff --git a/frontend/src/hooks/use-org-type-and-access.ts b/frontend/src/hooks/use-org-type-and-access.ts new file mode 100644 index 0000000000..5f49b9220c --- /dev/null +++ b/frontend/src/hooks/use-org-type-and-access.ts @@ -0,0 +1,22 @@ +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { useOrganizations } from "#/hooks/query/use-organizations"; + +export const useOrgTypeAndAccess = () => { + const { organizationId } = useSelectedOrganizationId(); + const { data } = useOrganizations(); + const organizations = data?.organizations; + + const selectedOrg = organizations?.find((org) => org.id === organizationId); + const isPersonalOrg = selectedOrg?.is_personal === true; + // Team org = any org that is not explicitly marked as personal (includes undefined) + const isTeamOrg = !!selectedOrg && !selectedOrg.is_personal; + const canViewOrgRoutes = isTeamOrg && !!organizationId; + + return { + selectedOrg, + isPersonalOrg, + isTeamOrg, + canViewOrgRoutes, + organizationId, + }; +}; diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts index fa0187251d..236d086ff6 100644 --- a/frontend/src/hooks/use-settings-nav-items.ts +++ b/frontend/src/hooks/use-settings-nav-items.ts @@ -1,14 +1,60 @@ import { useConfig } from "#/hooks/query/use-config"; -import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; -import { isSettingsPageHidden } from "#/routes/settings"; +import { + SAAS_NAV_ITEMS, + OSS_NAV_ITEMS, + SettingsNavItem, +} from "#/constants/settings-nav"; +import { OrganizationUserRole } from "#/types/org"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { isSettingsPageHidden } from "#/utils/settings-utils"; +import { useMe } from "./query/use-me"; +import { usePermission } from "./organizations/use-permissions"; +import { useOrgTypeAndAccess } from "./use-org-type-and-access"; -export function useSettingsNavItems() { +/** + * Build Settings navigation items based on: + * - app mode (saas / oss) + * - feature flags + * - active user's role + * - org type (personal vs team) + * @returns Settings Nav Items [] + */ +export function useSettingsNavItems(): SettingsNavItem[] { const { data: config } = useConfig(); + const { data: user } = useMe(); + const userRole: OrganizationUserRole = user?.role ?? "member"; + const { hasPermission } = usePermission(userRole); + const { isPersonalOrg, isTeamOrg, organizationId } = useOrgTypeAndAccess(); + const shouldHideBilling = isBillingHidden( + config, + hasPermission("view_billing"), + ); const isSaasMode = config?.app_mode === "saas"; const featureFlags = config?.feature_flags; - const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; + let items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; - return items.filter((item) => !isSettingsPageHidden(item.to, featureFlags)); + // First apply feature flag-based hiding + items = items.filter((item) => !isSettingsPageHidden(item.to, featureFlags)); + + // Hide billing when billing is not accessible OR when in team org + if (shouldHideBilling || isTeamOrg) { + items = items.filter((item) => item.to !== "/settings/billing"); + } + + // Hide org routes for personal orgs, missing permissions, or no org selected + if (!hasPermission("view_billing") || !organizationId || isPersonalOrg) { + items = items.filter((item) => item.to !== "/settings/org"); + } + + if ( + !hasPermission("invite_user_to_organization") || + !organizationId || + isPersonalOrg + ) { + items = items.filter((item) => item.to !== "/settings/org-members"); + } + + return items; } diff --git a/frontend/src/hooks/use-should-hide-org-selector.ts b/frontend/src/hooks/use-should-hide-org-selector.ts new file mode 100644 index 0000000000..27a5c6009c --- /dev/null +++ b/frontend/src/hooks/use-should-hide-org-selector.ts @@ -0,0 +1,16 @@ +import { useOrganizations } from "#/hooks/query/use-organizations"; +import { useConfig } from "#/hooks/query/use-config"; + +export function useShouldHideOrgSelector() { + const { data: config } = useConfig(); + const { data } = useOrganizations(); + const organizations = data?.organizations; + + // Always hide in OSS mode - organizations are a SaaS feature + if (config?.app_mode === "oss") { + return true; + } + + // In SaaS mode, hide if user only has one personal org + return organizations?.length === 1 && organizations[0]?.is_personal === true; +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index b968be1ec9..aac6e1b0f6 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -213,6 +213,7 @@ export enum I18nKey { BUTTON$END_SESSION = "BUTTON$END_SESSION", BUTTON$LAUNCH = "BUTTON$LAUNCH", BUTTON$CANCEL = "BUTTON$CANCEL", + BUTTON$ADD = "BUTTON$ADD", EXIT_PROJECT$CONFIRM = "EXIT_PROJECT$CONFIRM", EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE", LANGUAGE$LABEL = "LANGUAGE$LABEL", @@ -734,6 +735,11 @@ export enum I18nKey { TASKS$TASK_SUGGESTIONS_INFO = "TASKS$TASK_SUGGESTIONS_INFO", TASKS$TASK_SUGGESTIONS_TOOLTIP = "TASKS$TASK_SUGGESTIONS_TOOLTIP", PAYMENT$SPECIFY_AMOUNT_USD = "PAYMENT$SPECIFY_AMOUNT_USD", + PAYMENT$ERROR_INVALID_NUMBER = "PAYMENT$ERROR_INVALID_NUMBER", + PAYMENT$ERROR_NEGATIVE_AMOUNT = "PAYMENT$ERROR_NEGATIVE_AMOUNT", + PAYMENT$ERROR_MINIMUM_AMOUNT = "PAYMENT$ERROR_MINIMUM_AMOUNT", + PAYMENT$ERROR_MAXIMUM_AMOUNT = "PAYMENT$ERROR_MAXIMUM_AMOUNT", + PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER = "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER", GIT$BITBUCKET_TOKEN_HELP_LINK = "GIT$BITBUCKET_TOKEN_HELP_LINK", GIT$BITBUCKET_TOKEN_SEE_MORE_LINK = "GIT$BITBUCKET_TOKEN_SEE_MORE_LINK", GIT$BITBUCKET_DC_TOKEN_HELP_LINK = "GIT$BITBUCKET_DC_TOKEN_HELP_LINK", @@ -780,6 +786,7 @@ export enum I18nKey { COMMON$PERSONAL = "COMMON$PERSONAL", COMMON$REPOSITORIES = "COMMON$REPOSITORIES", COMMON$ORGANIZATIONS = "COMMON$ORGANIZATIONS", + COMMON$ORGANIZATION = "COMMON$ORGANIZATION", COMMON$ADD_MICROAGENT = "COMMON$ADD_MICROAGENT", COMMON$CREATED_ON = "COMMON$CREATED_ON", MICROAGENT_MANAGEMENT$LEARN_THIS_REPO = "MICROAGENT_MANAGEMENT$LEARN_THIS_REPO", @@ -906,6 +913,7 @@ export enum I18nKey { ACTION$CONFIRM_DELETE = "ACTION$CONFIRM_DELETE", ACTION$CONFIRM_STOP = "ACTION$CONFIRM_STOP", ACTION$CONFIRM_CLOSE = "ACTION$CONFIRM_CLOSE", + ACTION$CONFIRM_UPDATE = "ACTION$CONFIRM_UPDATE", AGENT_STATUS$AGENT_STOPPED = "AGENT_STATUS$AGENT_STOPPED", AGENT_STATUS$ERROR_OCCURRED = "AGENT_STATUS$ERROR_OCCURRED", AGENT_STATUS$INITIALIZING = "AGENT_STATUS$INITIALIZING", @@ -1005,6 +1013,46 @@ export enum I18nKey { COMMON$PLAN_AGENT_DESCRIPTION = "COMMON$PLAN_AGENT_DESCRIPTION", PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED = "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED", OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY", + ORG$ORGANIZATION_NAME = "ORG$ORGANIZATION_NAME", + ORG$NEXT = "ORG$NEXT", + ORG$INVITE_USERS_DESCRIPTION = "ORG$INVITE_USERS_DESCRIPTION", + ORG$EMAILS = "ORG$EMAILS", + ORG$STATUS_INVITED = "ORG$STATUS_INVITED", + ORG$ROLE_ADMIN = "ORG$ROLE_ADMIN", + ORG$ROLE_MEMBER = "ORG$ROLE_MEMBER", + ORG$ROLE_OWNER = "ORG$ROLE_OWNER", + ORG$REMOVE = "ORG$REMOVE", + ORG$CONFIRM_REMOVE_MEMBER = "ORG$CONFIRM_REMOVE_MEMBER", + ORG$REMOVE_MEMBER_WARNING = "ORG$REMOVE_MEMBER_WARNING", + ORG$REMOVE_MEMBER_ERROR = "ORG$REMOVE_MEMBER_ERROR", + ORG$REMOVE_MEMBER_SUCCESS = "ORG$REMOVE_MEMBER_SUCCESS", + ORG$CONFIRM_UPDATE_ROLE = "ORG$CONFIRM_UPDATE_ROLE", + ORG$UPDATE_ROLE_WARNING = "ORG$UPDATE_ROLE_WARNING", + ORG$UPDATE_ROLE_SUCCESS = "ORG$UPDATE_ROLE_SUCCESS", + ORG$UPDATE_ROLE_ERROR = "ORG$UPDATE_ROLE_ERROR", + ORG$INVITE_MEMBERS_SUCCESS = "ORG$INVITE_MEMBERS_SUCCESS", + ORG$INVITE_MEMBERS_ERROR = "ORG$INVITE_MEMBERS_ERROR", + ORG$DUPLICATE_EMAILS_ERROR = "ORG$DUPLICATE_EMAILS_ERROR", + ORG$NO_EMAILS_ADDED_HINT = "ORG$NO_EMAILS_ADDED_HINT", + ORG$ACCOUNT = "ORG$ACCOUNT", + ORG$INVITE_TEAM = "ORG$INVITE_TEAM", + ORG$MANAGE_TEAM = "ORG$MANAGE_TEAM", + ORG$CHANGE_ORG_NAME = "ORG$CHANGE_ORG_NAME", + ORG$MODIFY_ORG_NAME_DESCRIPTION = "ORG$MODIFY_ORG_NAME_DESCRIPTION", + ORG$ADD_CREDITS = "ORG$ADD_CREDITS", + ORG$CREDITS = "ORG$CREDITS", + ORG$ADD = "ORG$ADD", + ORG$BILLING_INFORMATION = "ORG$BILLING_INFORMATION", + ORG$CHANGE = "ORG$CHANGE", + ORG$DELETE_ORGANIZATION = "ORG$DELETE_ORGANIZATION", + ORG$DELETE_ORGANIZATION_WARNING = "ORG$DELETE_ORGANIZATION_WARNING", + ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME = "ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME", + ORG$DELETE_ORGANIZATION_ERROR = "ORG$DELETE_ORGANIZATION_ERROR", + ACCOUNT_SETTINGS$SETTINGS = "ACCOUNT_SETTINGS$SETTINGS", + ORG$MANAGE_ORGANIZATION_MEMBERS = "ORG$MANAGE_ORGANIZATION_MEMBERS", + ORG$SELECT_ORGANIZATION_PLACEHOLDER = "ORG$SELECT_ORGANIZATION_PLACEHOLDER", + ORG$PERSONAL_WORKSPACE = "ORG$PERSONAL_WORKSPACE", + ORG$ENTER_NEW_ORGANIZATION_NAME = "ORG$ENTER_NEW_ORGANIZATION_NAME", CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS", SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE", CONVERSATION$SHARE_PUBLICLY = "CONVERSATION$SHARE_PUBLICLY", @@ -1022,6 +1070,15 @@ export enum I18nKey { CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE", CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION", CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED", + COMMON$TYPE_EMAIL_AND_PRESS_SPACE = "COMMON$TYPE_EMAIL_AND_PRESS_SPACE", + ORG$INVITE_ORG_MEMBERS = "ORG$INVITE_ORG_MEMBERS", + ORG$MANAGE_ORGANIZATION = "ORG$MANAGE_ORGANIZATION", + ORG$ORGANIZATION_MEMBERS = "ORG$ORGANIZATION_MEMBERS", + ORG$ALL_ORGANIZATION_MEMBERS = "ORG$ALL_ORGANIZATION_MEMBERS", + ORG$SEARCH_BY_EMAIL = "ORG$SEARCH_BY_EMAIL", + ORG$NO_MEMBERS_FOUND = "ORG$NO_MEMBERS_FOUND", + ORG$NO_MEMBERS_MATCHING_FILTER = "ORG$NO_MEMBERS_MATCHING_FILTER", + ORG$FAILED_TO_LOAD_MEMBERS = "ORG$FAILED_TO_LOAD_MEMBERS", ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE", ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE", ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 1817da31f7..129784a357 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -3407,6 +3407,22 @@ "de": "Abbrechen", "uk": "Скасувати" }, + "BUTTON$ADD": { + "en": "Add", + "ja": "追加", + "zh-CN": "添加", + "zh-TW": "新增", + "ko-KR": "추가", + "no": "Legg til", + "it": "Aggiungi", + "pt": "Adicionar", + "es": "Añadir", + "ar": "إضافة", + "fr": "Ajouter", + "tr": "Ekle", + "de": "Hinzufügen", + "uk": "Додати" + }, "EXIT_PROJECT$CONFIRM": { "en": "Exit Project", "ja": "プロジェクトを終了", @@ -11747,6 +11763,86 @@ "de": "Geben Sie einen USD-Betrag zum Hinzufügen an - min $10", "uk": "Вкажіть суму в доларах США для додавання - мін $10" }, + "PAYMENT$ERROR_INVALID_NUMBER": { + "en": "Please enter a valid number", + "ja": "有効な数値を入力してください", + "zh-CN": "请输入有效数字", + "zh-TW": "請輸入有效數字", + "ko-KR": "유효한 숫자를 입력하세요", + "no": "Vennligst skriv inn et gyldig tall", + "it": "Inserisci un numero valido", + "pt": "Por favor, insira um número válido", + "es": "Por favor, ingrese un número válido", + "ar": "يرجى إدخال رقم صحيح", + "fr": "Veuillez entrer un nombre valide", + "tr": "Lütfen geçerli bir sayı girin", + "de": "Bitte geben Sie eine gültige Zahl ein", + "uk": "Будь ласка, введіть дійсне число" + }, + "PAYMENT$ERROR_NEGATIVE_AMOUNT": { + "en": "Amount cannot be negative", + "ja": "金額は負の値にできません", + "zh-CN": "金额不能为负数", + "zh-TW": "金額不能為負數", + "ko-KR": "금액은 음수일 수 없습니다", + "no": "Beløpet kan ikke være negativt", + "it": "L'importo non può essere negativo", + "pt": "O valor não pode ser negativo", + "es": "El monto no puede ser negativo", + "ar": "لا يمكن أن يكون المبلغ سالبًا", + "fr": "Le montant ne peut pas être négatif", + "tr": "Tutar negatif olamaz", + "de": "Der Betrag darf nicht negativ sein", + "uk": "Сума не може бути від'ємною" + }, + "PAYMENT$ERROR_MINIMUM_AMOUNT": { + "en": "Minimum amount is $10", + "ja": "最小金額は$10です", + "zh-CN": "最低金额为$10", + "zh-TW": "最低金額為$10", + "ko-KR": "최소 금액은 $10입니다", + "no": "Minimumsbeløpet er $10", + "it": "L'importo minimo è $10", + "pt": "O valor mínimo é $10", + "es": "El monto mínimo es $10", + "ar": "الحد الأدنى للمبلغ هو 10 دولارات", + "fr": "Le montant minimum est de 10 $", + "tr": "Minimum tutar $10'dur", + "de": "Der Mindestbetrag beträgt 10 $", + "uk": "Мінімальна сума становить $10" + }, + "PAYMENT$ERROR_MAXIMUM_AMOUNT": { + "en": "Maximum amount is $25,000", + "ja": "最大金額は$25,000です", + "zh-CN": "最高金额为$25,000", + "zh-TW": "最高金額為$25,000", + "ko-KR": "최대 금액은 $25,000입니다", + "no": "Maksimalbeløpet er $25,000", + "it": "L'importo massimo è $25,000", + "pt": "O valor máximo é $25,000", + "es": "El monto máximo es $25,000", + "ar": "الحد الأقصى للمبلغ هو 25,000 دولار", + "fr": "Le montant maximum est de 25 000 $", + "tr": "Maksimum tutar $25,000'dur", + "de": "Der Höchstbetrag beträgt 25.000 $", + "uk": "Максимальна сума становить $25,000" + }, + "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER": { + "en": "Amount must be a whole number", + "ja": "金額は整数である必要があります", + "zh-CN": "金额必须是整数", + "zh-TW": "金額必須是整數", + "ko-KR": "금액은 정수여야 합니다", + "no": "Beløpet må være et heltall", + "it": "L'importo deve essere un numero intero", + "pt": "O valor deve ser um número inteiro", + "es": "El monto debe ser un número entero", + "ar": "يجب أن يكون المبلغ رقمًا صحيحًا", + "fr": "Le montant doit être un nombre entier", + "tr": "Tutar tam sayı olmalıdır", + "de": "Der Betrag muss eine ganze Zahl sein", + "uk": "Сума повинна бути цілим числом" + }, "GIT$BITBUCKET_TOKEN_HELP_LINK": { "en": "Bitbucket token help link", "ja": "Bitbucketトークンヘルプリンク", @@ -12483,6 +12579,22 @@ "de": "Organisationen", "uk": "Організації" }, + "COMMON$ORGANIZATION": { + "en": "Organization", + "ja": "組織", + "zh-CN": "组织", + "zh-TW": "組織", + "ko-KR": "조직", + "no": "Organisasjon", + "it": "Organizzazione", + "pt": "Organização", + "es": "Organización", + "ar": "المؤسسة", + "fr": "Organisation", + "tr": "Organizasyon", + "de": "Organisation", + "uk": "Організація" + }, "COMMON$ADD_MICROAGENT": { "en": "Add Microagent", "ja": "マイクロエージェントを追加", @@ -14499,6 +14611,22 @@ "tr": "Kapatmayı Onayla", "uk": "Підтвердити закриття" }, + "ACTION$CONFIRM_UPDATE": { + "en": "Confirm Update", + "ja": "更新を確認", + "zh-CN": "确认更新", + "zh-TW": "確認更新", + "ko-KR": "업데이트 확인", + "fr": "Confirmer la mise à jour", + "es": "Confirmar actualización", + "de": "Aktualisierung bestätigen", + "it": "Conferma aggiornamento", + "pt": "Confirmar atualização", + "ar": "تأكيد التحديث", + "no": "Bekreft oppdatering", + "tr": "Güncellemeyi Onayla", + "uk": "Підтвердити оновлення" + }, "AGENT_STATUS$AGENT_STOPPED": { "en": "Agent stopped", "ja": "エージェントが停止しました。", @@ -16083,6 +16211,646 @@ "de": "Fähigkeit bereit", "uk": "Навичка готова" }, + "ORG$ORGANIZATION_NAME": { + "en": "Organization Name", + "ja": "組織名", + "zh-CN": "组织名称", + "zh-TW": "組織名稱", + "ko-KR": "조직 이름", + "no": "Organisasjonsnavn", + "it": "Nome organizzazione", + "pt": "Nome da organização", + "es": "Nombre de la organización", + "ar": "اسم المنظمة", + "fr": "Nom de l'organisation", + "tr": "Organizasyon Adı", + "de": "Organisationsname", + "uk": "Назва організації" + }, + "ORG$NEXT": { + "en": "Next", + "ja": "次へ", + "zh-CN": "下一步", + "zh-TW": "下一步", + "ko-KR": "다음", + "no": "Neste", + "it": "Avanti", + "pt": "Próximo", + "es": "Siguiente", + "ar": "التالي", + "fr": "Suivant", + "tr": "İleri", + "de": "Weiter", + "uk": "Далі" + }, + "ORG$INVITE_USERS_DESCRIPTION": { + "en": "Invite colleagues using their email address", + "ja": "メールアドレスを使用して同僚を招待", + "zh-CN": "使用电子邮件地址邀请同事", + "zh-TW": "使用電子郵件地址邀請同事", + "ko-KR": "이메일 주소로 동료 초대", + "no": "Inviter kolleger med e-postadresse", + "it": "Invita colleghi usando il loro indirizzo email", + "pt": "Convide colegas usando o endereço de email", + "es": "Invita a colegas usando su dirección de correo", + "ar": "دعوة الزملاء باستخدام عنوان بريدهم الإلكتروني", + "fr": "Invitez des collègues en utilisant leur adresse email", + "tr": "E-posta adresi kullanarak meslektaşlarını davet et", + "de": "Laden Sie Kollegen per E-Mail-Adresse ein", + "uk": "Запросіть колег за їхньою електронною адресою" + }, + "ORG$EMAILS": { + "en": "Emails", + "ja": "メール", + "zh-CN": "电子邮件", + "zh-TW": "電子郵件", + "ko-KR": "이메일", + "no": "E-poster", + "it": "Email", + "pt": "E-mails", + "es": "Correos electrónicos", + "ar": "رسائل البريد الإلكتروني", + "fr": "E-mails", + "tr": "E-postalar", + "de": "E-Mails", + "uk": "Електронні листи" + }, + "ORG$STATUS_INVITED": { + "en": "invited", + "ja": "招待済み", + "zh-CN": "已邀请", + "zh-TW": "已邀請", + "ko-KR": "초대됨", + "no": "invitert", + "it": "invitato", + "pt": "convidado", + "es": "invitado", + "ar": "تمت الدعوة", + "fr": "invité", + "tr": "davet edildi", + "de": "eingeladen", + "uk": "запрошений" + }, + "ORG$ROLE_ADMIN": { + "en": "admin", + "ja": "管理者", + "zh-CN": "管理员", + "zh-TW": "管理員", + "ko-KR": "관리자", + "no": "admin", + "it": "admin", + "pt": "admin", + "es": "admin", + "ar": "مدير", + "fr": "admin", + "tr": "yönetici", + "de": "Admin", + "uk": "адміністратор" + }, + "ORG$ROLE_MEMBER": { + "en": "member", + "ja": "メンバー", + "zh-CN": "成员", + "zh-TW": "成員", + "ko-KR": "멤버", + "no": "medlem", + "it": "membro", + "pt": "membro", + "es": "miembro", + "ar": "عضو", + "fr": "membre", + "tr": "üye", + "de": "Mitglied", + "uk": "учасник" + }, + "ORG$ROLE_OWNER": { + "en": "owner", + "ja": "所有者", + "zh-CN": "所有者", + "zh-TW": "所有者", + "ko-KR": "소유자", + "no": "eier", + "it": "proprietario", + "pt": "proprietário", + "es": "propietario", + "ar": "المالك", + "fr": "propriétaire", + "tr": "sahip", + "de": "Eigentümer", + "uk": "власник" + }, + "ORG$REMOVE": { + "en": "remove", + "ja": "削除", + "zh-CN": "移除", + "zh-TW": "移除", + "ko-KR": "제거", + "no": "fjern", + "it": "rimuovi", + "pt": "remover", + "es": "eliminar", + "ar": "إزالة", + "fr": "supprimer", + "tr": "kaldır", + "de": "entfernen", + "uk": "видалити" + }, + "ORG$CONFIRM_REMOVE_MEMBER": { + "en": "Confirm Remove Member", + "ja": "メンバー削除の確認", + "zh-CN": "确认移除成员", + "zh-TW": "確認移除成員", + "ko-KR": "멤버 제거 확인", + "no": "Bekreft fjerning av medlem", + "it": "Conferma rimozione membro", + "pt": "Confirmar remoção de membro", + "es": "Confirmar eliminación de miembro", + "ar": "تأكيد إزالة العضو", + "fr": "Confirmer la suppression du membre", + "tr": "Üye kaldırma onayı", + "de": "Mitglied entfernen bestätigen", + "uk": "Підтвердити видалення учасника" + }, + "ORG$REMOVE_MEMBER_WARNING": { + "en": "Are you sure you want to remove {{email}} from this organization? This action cannot be undone.", + "ja": "{{email}} をこの組織から削除してもよろしいですか?この操作は元に戻せません。", + "zh-CN": "您确定要将 {{email}} 从此组织中移除吗?此操作无法撤消。", + "zh-TW": "您確定要將 {{email}} 從此組織中移除嗎?此操作無法撤消。", + "ko-KR": "이 조직에서 {{email}}을(를) 제거하시겠습니까? 이 작업은 취소할 수 없습니다.", + "no": "Er du sikker på at du vil fjerne {{email}} fra denne organisasjonen? Denne handlingen kan ikke angres.", + "it": "Sei sicuro di voler rimuovere {{email}} da questa organizzazione? Questa azione non può essere annullata.", + "pt": "Tem certeza de que deseja remover {{email}} desta organização? Esta ação não pode ser desfeita.", + "es": "¿Está seguro de que desea eliminar a {{email}} de esta organización? Esta acción no se puede deshacer.", + "ar": "هل أنت متأكد من أنك تريد إزالة {{email}} من هذه المنظمة؟ لا يمكن التراجع عن هذا الإجراء.", + "fr": "Êtes-vous sûr de vouloir supprimer {{email}} de cette organisation ? Cette action ne peut pas être annulée.", + "tr": "{{email}} kullanıcısını bu organizasyondan kaldırmak istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "de": "Sind Sie sicher, dass Sie {{email}} aus dieser Organisation entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "uk": "Ви впевнені, що хочете видалити {{email}} з цієї організації? Цю дію неможливо скасувати." + }, + "ORG$REMOVE_MEMBER_ERROR": { + "en": "Failed to remove member from organization. Please try again.", + "ja": "組織からメンバーを削除できませんでした。もう一度お試しください。", + "zh-CN": "无法从组织中移除成员。请重试。", + "zh-TW": "無法從組織中移除成員。請重試。", + "ko-KR": "조직에서 멤버를 제거하지 못했습니다. 다시 시도해 주세요.", + "no": "Kunne ikke fjerne medlem fra organisasjonen. Vennligst prøv igjen.", + "it": "Impossibile rimuovere il membro dall'organizzazione. Riprova.", + "pt": "Falha ao remover membro da organização. Por favor, tente novamente.", + "es": "No se pudo eliminar el miembro de la organización. Por favor, inténtelo de nuevo.", + "ar": "فشل في إزالة العضو من المنظمة. يرجى المحاولة مرة أخرى.", + "fr": "Échec de la suppression du membre de l'organisation. Veuillez réessayer.", + "tr": "Üye organizasyondan kaldırılamadı. Lütfen tekrar deneyin.", + "de": "Mitglied konnte nicht aus der Organisation entfernt werden. Bitte versuchen Sie es erneut.", + "uk": "Не вдалося видалити учасника з організації. Будь ласка, спробуйте ще раз." + }, + "ORG$REMOVE_MEMBER_SUCCESS": { + "en": "Member removed successfully", + "ja": "メンバーを削除しました", + "zh-CN": "成员已成功移除", + "zh-TW": "成員已成功移除", + "ko-KR": "멤버가 성공적으로 제거되었습니다", + "no": "Medlem fjernet", + "it": "Membro rimosso con successo", + "pt": "Membro removido com sucesso", + "es": "Miembro eliminado correctamente", + "ar": "تمت إزالة العضو بنجاح", + "fr": "Membre supprimé avec succès", + "tr": "Üye başarıyla kaldırıldı", + "de": "Mitglied erfolgreich entfernt", + "uk": "Учасника успішно видалено" + }, + "ORG$CONFIRM_UPDATE_ROLE": { + "en": "Confirm Role Update", + "ja": "役割の更新を確認", + "zh-CN": "确认更新角色", + "zh-TW": "確認更新角色", + "ko-KR": "역할 업데이트 확인", + "no": "Bekreft rolleoppdatering", + "it": "Conferma aggiornamento ruolo", + "pt": "Confirmar atualização de função", + "es": "Confirmar actualización de rol", + "ar": "تأكيد تحديث الدور", + "fr": "Confirmer la mise à jour du rôle", + "tr": "Rol güncellemesini onayla", + "de": "Rollenaktualisierung bestätigen", + "uk": "Підтвердити оновлення ролі" + }, + "ORG$UPDATE_ROLE_WARNING": { + "en": "Are you sure you want to change the role of {{email}} to {{role}}?", + "ja": "{{email}} の役割を {{role}} に変更してもよろしいですか?", + "zh-CN": "您确定要将 {{email}} 的角色更改为 {{role}} 吗?", + "zh-TW": "您確定要將 {{email}} 的角色更改為 {{role}} 嗎?", + "ko-KR": "{{email}}의 역할을 {{role}}(으)로 변경하시겠습니까?", + "no": "Er du sikker på at du vil endre rollen til {{email}} til {{role}}?", + "it": "Sei sicuro di voler cambiare il ruolo di {{email}} in {{role}}?", + "pt": "Tem certeza de que deseja alterar a função de {{email}} para {{role}}?", + "es": "¿Está seguro de que desea cambiar el rol de {{email}} a {{role}}?", + "ar": "هل أنت متأكد من أنك تريد تغيير دور {{email}} إلى {{role}}؟", + "fr": "Êtes-vous sûr de vouloir changer le rôle de {{email}} en {{role}} ?", + "tr": "{{email}} kullanıcısının rolünü {{role}} olarak değiştirmek istediğinizden emin misiniz?", + "de": "Sind Sie sicher, dass Sie die Rolle von {{email}} auf {{role}} ändern möchten?", + "uk": "Ви впевнені, що хочете змінити роль {{email}} на {{role}}?" + }, + "ORG$UPDATE_ROLE_SUCCESS": { + "en": "Role updated successfully", + "ja": "役割を更新しました", + "zh-CN": "角色已成功更新", + "zh-TW": "角色已成功更新", + "ko-KR": "역할이 성공적으로 업데이트되었습니다", + "no": "Rolle oppdatert", + "it": "Ruolo aggiornato con successo", + "pt": "Função atualizada com sucesso", + "es": "Rol actualizado correctamente", + "ar": "تم تحديث الدور بنجاح", + "fr": "Rôle mis à jour avec succès", + "tr": "Rol başarıyla güncellendi", + "de": "Rolle erfolgreich aktualisiert", + "uk": "Роль успішно оновлено" + }, + "ORG$UPDATE_ROLE_ERROR": { + "en": "Failed to update role. Please try again.", + "ja": "役割の更新に失敗しました。もう一度お試しください。", + "zh-CN": "更新角色失败。请重试。", + "zh-TW": "更新角色失敗。請重試。", + "ko-KR": "역할 업데이트에 실패했습니다. 다시 시도해 주세요.", + "no": "Kunne ikke oppdatere rolle. Vennligst prøv igjen.", + "it": "Impossibile aggiornare il ruolo. Riprova.", + "pt": "Falha ao atualizar função. Por favor, tente novamente.", + "es": "No se pudo actualizar el rol. Por favor, inténtelo de nuevo.", + "ar": "فشل في تحديث الدور. يرجى المحاولة مرة أخرى.", + "fr": "Échec de la mise à jour du rôle. Veuillez réessayer.", + "tr": "Rol güncellenemedi. Lütfen tekrar deneyin.", + "de": "Rolle konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.", + "uk": "Не вдалося оновити роль. Будь ласка, спробуйте ще раз." + }, + "ORG$INVITE_MEMBERS_SUCCESS": { + "en": "Invitations sent successfully", + "ja": "招待を送信しました", + "zh-CN": "邀请发送成功", + "zh-TW": "邀請發送成功", + "ko-KR": "초대가 성공적으로 전송되었습니다", + "no": "Invitasjoner sendt", + "it": "Inviti inviati con successo", + "pt": "Convites enviados com sucesso", + "es": "Invitaciones enviadas correctamente", + "ar": "تم إرسال الدعوات بنجاح", + "fr": "Invitations envoyées avec succès", + "tr": "Davetler başarıyla gönderildi", + "de": "Einladungen erfolgreich gesendet", + "uk": "Запрошення успішно надіслано" + }, + "ORG$INVITE_MEMBERS_ERROR": { + "en": "Failed to send invitations. Please try again.", + "ja": "招待の送信に失敗しました。もう一度お試しください。", + "zh-CN": "发送邀请失败。请重试。", + "zh-TW": "發送邀請失敗。請重試。", + "ko-KR": "초대 전송에 실패했습니다. 다시 시도해 주세요.", + "no": "Kunne ikke sende invitasjoner. Vennligst prøv igjen.", + "it": "Impossibile inviare gli inviti. Riprova.", + "pt": "Falha ao enviar convites. Por favor, tente novamente.", + "es": "No se pudieron enviar las invitaciones. Por favor, inténtelo de nuevo.", + "ar": "فشل في إرسال الدعوات. يرجى المحاولة مرة أخرى.", + "fr": "Échec de l'envoi des invitations. Veuillez réessayer.", + "tr": "Davetler gönderilemedi. Lütfen tekrar deneyin.", + "de": "Einladungen konnten nicht gesendet werden. Bitte versuchen Sie es erneut.", + "uk": "Не вдалося надіслати запрошення. Будь ласка, спробуйте ще раз." + }, + "ORG$DUPLICATE_EMAILS_ERROR": { + "en": "Duplicate email addresses are not allowed", + "ja": "重複するメールアドレスは許可されていません", + "zh-CN": "不允许重复的电子邮件地址", + "zh-TW": "不允許重複的電子郵件地址", + "ko-KR": "중복된 이메일 주소는 허용되지 않습니다", + "no": "Dupliserte e-postadresser er ikke tillatt", + "it": "Gli indirizzi email duplicati non sono consentiti", + "pt": "Endereços de e-mail duplicados não são permitidos", + "es": "No se permiten direcciones de correo electrónico duplicadas", + "ar": "لا يُسمح بعناوين البريد الإلكتروني المكررة", + "fr": "Les adresses e-mail en double ne sont pas autorisées", + "tr": "Yinelenen e-posta adreslerine izin verilmiyor", + "de": "Doppelte E-Mail-Adressen sind nicht erlaubt", + "uk": "Дублікати електронних адрес не допускаються" + }, + "ORG$NO_EMAILS_ADDED_HINT": { + "en": "Please type emails and then press space.", + "ja": "メールアドレスを入力してからスペースを押してください。", + "zh-CN": "请输入邮箱然后按空格键。", + "zh-TW": "請輸入電子郵件然後按空白鍵。", + "ko-KR": "이메일을 입력한 후 스페이스바를 눌러주세요.", + "no": "Skriv inn e-post og trykk mellomrom.", + "it": "Digita le email e poi premi spazio.", + "pt": "Digite os e-mails e pressione espaço.", + "es": "Escriba los correos electrónicos y luego presione espacio.", + "ar": "يرجى كتابة البريد الإلكتروني ثم الضغط على مفتاح المسافة.", + "fr": "Veuillez saisir les e-mails puis appuyer sur espace.", + "tr": "Lütfen e-postaları yazın ve ardından boşluk tuşuna basın.", + "de": "Bitte geben Sie E-Mails ein und drücken Sie dann die Leertaste.", + "uk": "Будь ласка, введіть електронні адреси та натисніть пробіл." + }, + "ORG$ACCOUNT": { + "en": "Account", + "ja": "アカウント", + "zh-CN": "账户", + "zh-TW": "帳戶", + "ko-KR": "계정", + "no": "Konto", + "it": "Account", + "pt": "Conta", + "es": "Cuenta", + "ar": "الحساب", + "fr": "Compte", + "tr": "Hesap", + "de": "Konto", + "uk": "Обліковий запис" + }, + "ORG$INVITE_TEAM": { + "en": "Invite Team", + "ja": "チームを招待", + "zh-CN": "邀请团队", + "zh-TW": "邀請團隊", + "ko-KR": "팀 초대", + "no": "Inviter team", + "it": "Invita team", + "pt": "Convidar equipe", + "es": "Invitar equipo", + "ar": "دعوة الفريق", + "fr": "Inviter l'équipe", + "tr": "Takım Davet Et", + "de": "Team einladen", + "uk": "Запросити команду" + }, + "ORG$MANAGE_TEAM": { + "en": "Manage Team", + "ja": "チーム管理", + "zh-CN": "管理团队", + "zh-TW": "管理團隊", + "ko-KR": "팀 관리", + "no": "Administrer team", + "it": "Gestisci team", + "pt": "Gerenciar equipe", + "es": "Administrar equipo", + "ar": "إدارة الفريق", + "fr": "Gérer l'équipe", + "tr": "Takımı Yönet", + "de": "Team verwalten", + "uk": "Керувати командою" + }, + "ORG$CHANGE_ORG_NAME": { + "en": "Change Organization Name", + "ja": "組織名を変更", + "zh-CN": "更改组织名称", + "zh-TW": "更改組織名稱", + "ko-KR": "조직 이름 변경", + "no": "Endre organisasjonsnavn", + "it": "Cambia nome dell'organizzazione", + "pt": "Alterar nome da organização", + "es": "Cambiar nombre de la organización", + "ar": "تغيير اسم المنظمة", + "fr": "Changer le nom de l'organisation", + "tr": "Organizasyon Adını Değiştir", + "de": "Organisationsnamen ändern", + "uk": "Змінити назву організації" + }, + "ORG$MODIFY_ORG_NAME_DESCRIPTION": { + "en": "Modify your Organization Name and Save", + "ja": "組織名を変更して保存します", + "zh-CN": "修改你的组织名称并保存", + "zh-TW": "修改您的組織名稱並儲存", + "ko-KR": "조직 이름을 수정하고 저장하세요", + "no": "Endre organisasjonsnavnet ditt og lagre", + "it": "Modifica il nome della tua organizzazione e salva", + "pt": "Modifique o nome da sua organização e salve", + "es": "Modifica el nombre de tu organización y guarda", + "ar": "قم بتعديل اسم المنظمة الخاصة بك وحفظه", + "fr": "Modifiez le nom de votre organisation et enregistrez", + "tr": "Organizasyon adınızı değiştirin ve kaydedin", + "de": "Ändern Sie den Namen Ihrer Organisation und speichern Sie ihn", + "uk": "Змініть назву вашої організації та збережіть" + }, + "ORG$ADD_CREDITS": { + "en": "Add Credits", + "ja": "クレジットを追加", + "zh-CN": "添加积分", + "zh-TW": "新增點數", + "ko-KR": "크레딧 추가", + "no": "Legg til kreditter", + "it": "Aggiungi crediti", + "pt": "Adicionar créditos", + "es": "Añadir créditos", + "ar": "إضافة رصيد", + "fr": "Ajouter des crédits", + "tr": "Kredi Ekle", + "de": "Credits hinzufügen", + "uk": "Додати кредити" + }, + "ORG$CREDITS": { + "en": "Credits", + "ja": "クレジット", + "zh-CN": "积分", + "zh-TW": "點數", + "ko-KR": "크레딧", + "no": "Kreditter", + "it": "Crediti", + "pt": "Créditos", + "es": "Créditos", + "ar": "الرصيد", + "fr": "Crédits", + "tr": "Krediler", + "de": "Credits", + "uk": "Кредити" + }, + "ORG$ADD": { + "en": "+ Add", + "ja": "+ 追加", + "zh-CN": "+ 添加", + "zh-TW": "+ 新增", + "ko-KR": "+ 추가", + "no": "+ Legg til", + "it": "+ Aggiungi", + "pt": "+ Adicionar", + "es": "+ Añadir", + "ar": "+ إضافة", + "fr": "+ Ajouter", + "tr": "+ Ekle", + "de": "+ Hinzufügen", + "uk": "+ Додати" + }, + "ORG$BILLING_INFORMATION": { + "en": "Billing Information", + "ja": "請求情報", + "zh-CN": "账单信息", + "zh-TW": "帳單資訊", + "ko-KR": "결제 정보", + "no": "Faktureringsinformasjon", + "it": "Informazioni di fatturazione", + "pt": "Informações de cobrança", + "es": "Información de facturación", + "ar": "معلومات الفوترة", + "fr": "Informations de facturation", + "tr": "Fatura Bilgisi", + "de": "Rechnungsinformationen", + "uk": "Платіжна інформація" + }, + "ORG$CHANGE": { + "en": "Change", + "ja": "変更", + "zh-CN": "更改", + "zh-TW": "變更", + "ko-KR": "변경", + "no": "Endre", + "it": "Modifica", + "pt": "Alterar", + "es": "Cambiar", + "ar": "تغيير", + "fr": "Modifier", + "tr": "Değiştir", + "de": "Ändern", + "uk": "Змінити" + }, + "ORG$DELETE_ORGANIZATION": { + "en": "Delete Organization", + "ja": "組織を削除", + "zh-CN": "删除组织", + "zh-TW": "刪除組織", + "ko-KR": "조직 삭제", + "no": "Slett organisasjon", + "it": "Elimina organizzazione", + "pt": "Excluir organização", + "es": "Eliminar organización", + "ar": "حذف المنظمة", + "fr": "Supprimer l'organisation", + "tr": "Organizasyonu Sil", + "de": "Organisation löschen", + "uk": "Видалити організацію" + }, + "ORG$DELETE_ORGANIZATION_WARNING": { + "en": "Are you sure you want to delete this organization? This action cannot be undone.", + "ja": "この組織を削除してもよろしいですか?この操作は元に戻せません。", + "zh-CN": "您确定要删除此组织吗?此操作无法撤消。", + "zh-TW": "您確定要刪除此組織嗎?此操作無法撤銷。", + "ko-KR": "이 조직을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "no": "Er du sikker på at du vil slette denne organisasjonen? Denne handlingen kan ikke angres.", + "it": "Sei sicuro di voler eliminare questa organizzazione? Questa azione non può essere annullata.", + "pt": "Tem certeza de que deseja excluir esta organização? Esta ação não pode ser desfeita.", + "es": "¿Está seguro de que desea eliminar esta organización? Esta acción no se puede deshacer.", + "ar": "هل أنت متأكد من أنك تريد حذف هذه المنظمة؟ لا يمكن التراجع عن هذا الإجراء.", + "fr": "Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action ne peut pas être annulée.", + "tr": "Bu organizasyonu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "de": "Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "uk": "Ви впевнені, що хочете видалити цю організацію? Цю дію не можна скасувати." + }, + "ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME": { + "en": "Are you sure you want to delete the \"{{name}}\" organization? This action cannot be undone.", + "ja": "「{{name}}」組織を削除してもよろしいですか?この操作は元に戻せません。", + "zh-CN": "您确定要删除\"{{name}}\"组织吗?此操作无法撤消。", + "zh-TW": "您確定要刪除「{{name}}」組織嗎?此操作無法撤銷。", + "ko-KR": "\"{{name}}\" 조직을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "no": "Er du sikker på at du vil slette organisasjonen \"{{name}}\"? Denne handlingen kan ikke angres.", + "it": "Sei sicuro di voler eliminare l'organizzazione \"{{name}}\"? Questa azione non può essere annullata.", + "pt": "Tem certeza de que deseja excluir a organização \"{{name}}\"? Esta ação não pode ser desfeita.", + "es": "¿Está seguro de que desea eliminar la organización \"{{name}}\"? Esta acción no se puede deshacer.", + "ar": "هل أنت متأكد من أنك تريد حذف المنظمة \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.", + "fr": "Êtes-vous sûr de vouloir supprimer l'organisation \\u00AB {{name}} \\u00BB ? Cette action ne peut pas être annulée.", + "tr": "\"{{name}}\" organizasyonunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "de": "Sind Sie sicher, dass Sie die Organisation \\u201E{{name}}\\u201C löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "uk": "Ви впевнені, що хочете видалити організацію \\u00AB{{name}}\\u00BB? Цю дію не можна скасувати." + }, + "ORG$DELETE_ORGANIZATION_ERROR": { + "en": "Failed to delete organization", + "ja": "組織の削除に失敗しました", + "zh-CN": "删除组织失败", + "zh-TW": "刪除組織失敗", + "ko-KR": "조직 삭제에 실패했습니다", + "no": "Kunne ikke slette organisasjonen", + "it": "Impossibile eliminare l'organizzazione", + "pt": "Falha ao excluir a organização", + "es": "Error al eliminar la organización", + "ar": "فشل في حذف المنظمة", + "fr": "Échec de la suppression de l'organisation", + "tr": "Organizasyon silinemedi", + "de": "Organisation konnte nicht gelöscht werden", + "uk": "Не вдалося видалити організацію" + }, + "ACCOUNT_SETTINGS$SETTINGS": { + "en": "Settings", + "ja": "設定", + "zh-CN": "设置", + "zh-TW": "設定", + "ko-KR": "설정", + "no": "Innstillinger", + "it": "Impostazioni", + "pt": "Configurações", + "es": "Configuración", + "ar": "الإعدادات", + "fr": "Paramètres", + "tr": "Ayarlar", + "de": "Einstellungen", + "uk": "Налаштування" + }, + "ORG$MANAGE_ORGANIZATION_MEMBERS": { + "en": "Manage Organization Members", + "ja": "組織メンバーの管理", + "zh-CN": "管理组织成员", + "zh-TW": "管理組織成員", + "ko-KR": "조직 구성원 관리", + "no": "Administrer organisasjonsmedlemmer", + "it": "Gestisci membri dell'organizzazione", + "pt": "Gerenciar membros da organização", + "es": "Gestionar miembros de la organización", + "ar": "إدارة أعضاء المنظمة", + "fr": "Gérer les membres de l'organisation", + "tr": "Organizasyon Üyelerini Yönet", + "de": "Organisationsmitglieder verwalten", + "uk": "Керувати учасниками організації" + }, + "ORG$SELECT_ORGANIZATION_PLACEHOLDER": { + "en": "Please select an organization", + "ja": "組織を選択してください", + "zh-CN": "请选择一个组织", + "zh-TW": "請選擇一個組織", + "ko-KR": "조직을 선택해 주세요", + "no": "Vennligst velg en organisasjon", + "it": "Seleziona un'organizzazione", + "pt": "Por favor, selecione uma organização", + "es": "Por favor, seleccione una organización", + "ar": "يرجى اختيار منظمة", + "fr": "Veuillez sélectionner une organisation", + "tr": "Lütfen bir organizasyon seçin", + "de": "Bitte wählen Sie eine Organisation", + "uk": "Будь ласка, виберіть організацію" + }, + "ORG$PERSONAL_WORKSPACE": { + "en": "Personal Workspace", + "ja": "個人ワークスペース", + "zh-CN": "个人工作区", + "zh-TW": "個人工作區", + "ko-KR": "개인 워크스페이스", + "no": "Personlig arbeidsområde", + "it": "Area di lavoro personale", + "pt": "Área de trabalho pessoal", + "es": "Espacio de trabajo personal", + "ar": "مساحة العمل الشخصية", + "fr": "Espace de travail personnel", + "tr": "Kişisel çalışma alanı", + "de": "Persönlicher Arbeitsbereich", + "uk": "Особистий робочий простір" + }, + "ORG$ENTER_NEW_ORGANIZATION_NAME": { + "en": "Enter new organization name", + "ja": "新しい組織名を入力してください", + "zh-CN": "请输入新的组织名称", + "zh-TW": "請輸入新的組織名稱", + "ko-KR": "새 조직 이름을 입력하세요", + "no": "Skriv inn nytt organisasjonsnavn", + "it": "Inserisci il nuovo nome dell'organizzazione", + "pt": "Digite o novo nome da organização", + "es": "Ingrese el nuevo nombre de la organización", + "ar": "أدخل اسم المنظمة الجديد", + "fr": "Entrez le nouveau nom de l'organisation", + "tr": "Yeni organizasyon adını girin", + "de": "Geben Sie den neuen Organisationsnamen ein", + "uk": "Введіть нову назву організації" + }, "CONVERSATION$SHOW_SKILLS": { "en": "Show Available Skills", "ja": "利用可能なスキルを表示", @@ -16355,6 +17123,150 @@ "de": "Link in die Zwischenablage kopiert", "uk": "Посилання скопійовано в буфер обміну" }, + "COMMON$TYPE_EMAIL_AND_PRESS_SPACE": { + "en": "Type email and press Space", + "ja": "メールアドレスを入力してスペースキーを押してください", + "zh-CN": "输入邮箱并按空格键", + "zh-TW": "輸入電子郵件並按空白鍵", + "ko-KR": "이메일을 입력하고 스페이스바를 누르세요", + "no": "Skriv inn e-post og trykk på mellomromstasten", + "it": "Digita l'e-mail e premi Spazio", + "pt": "Digite o e-mail e pressione Espaço", + "es": "Escribe el correo electrónico y pulsa Espacio", + "ar": "اكتب البريد الإلكتروني واضغط على مفتاح المسافة", + "fr": "Tapez l'e-mail et appuyez sur Espace", + "tr": "E-postu yazıp Boşluk tuşuna basın", + "de": "E-Mail eingeben und Leertaste drücken", + "uk": "Введіть e-mail і натисніть Пробіл" + }, + "ORG$INVITE_ORG_MEMBERS": { + "en": "Invite Organization Members", + "ja": "組織メンバーを招待", + "zh-CN": "邀请组织成员", + "zh-TW": "邀請組織成員", + "ko-KR": "조직 구성원 초대", + "no": "Inviter organisasjonsmedlemmer", + "it": "Invita membri dell'organizzazione", + "pt": "Convidar membros da organização", + "es": "Invitar a miembros de la organización", + "ar": "دعوة أعضاء المنظمة", + "fr": "Inviter des membres de l'organisation", + "tr": "Organizasyon üyelerini davet et", + "de": "Organisationsmitglieder einladen", + "uk": "Запросити членів організації" + }, + "ORG$MANAGE_ORGANIZATION": { + "en": "Manage Organization", + "ja": "組織を管理", + "zh-CN": "管理组织", + "zh-TW": "管理組織", + "ko-KR": "조직 관리", + "no": "Administrer organisasjon", + "it": "Gestisci organizzazione", + "pt": "Gerenciar organização", + "es": "Gestionar organización", + "ar": "إدارة المنظمة", + "fr": "Gérer l'organisation", + "tr": "Organizasyonu yönet", + "de": "Organisation verwalten", + "uk": "Керувати організацією" + }, + "ORG$ORGANIZATION_MEMBERS": { + "en": "Organization Members", + "ja": "組織メンバー", + "zh-CN": "组织成员", + "zh-TW": "組織成員", + "ko-KR": "조직 구성원", + "no": "Organisasjonsmedlemmer", + "it": "Membri dell'organizzazione", + "pt": "Membros da organização", + "es": "Miembros de la organización", + "ar": "أعضاء المنظمة", + "fr": "Membres de l'organisation", + "tr": "Organizasyon Üyeleri", + "de": "Organisationsmitglieder", + "uk": "Члени організації" + }, + "ORG$ALL_ORGANIZATION_MEMBERS": { + "en": "All Organization Members", + "ja": "全ての組織メンバー", + "zh-CN": "所有组织成员", + "zh-TW": "所有組織成員", + "ko-KR": "모든 조직 구성원", + "no": "Alle organisasjonsmedlemmer", + "it": "Tutti i membri dell'organizzazione", + "pt": "Todos os membros da organização", + "es": "Todos los miembros de la organización", + "ar": "جميع أعضاء المنظمة", + "fr": "Tous les membres de l'organisation", + "tr": "Tüm organizasyon üyeleri", + "de": "Alle Organisationsmitglieder", + "uk": "Усі члени організації" + }, + "ORG$SEARCH_BY_EMAIL": { + "en": "Search by email...", + "ja": "メールで検索...", + "zh-CN": "按邮箱搜索...", + "zh-TW": "按電郵搜尋...", + "ko-KR": "이메일로 검색...", + "no": "Søk etter e-post...", + "it": "Cerca per email...", + "pt": "Pesquisar por email...", + "es": "Buscar por correo electrónico...", + "ar": "البحث بالبريد الإلكتروني...", + "fr": "Rechercher par e-mail...", + "tr": "E-posta ile ara...", + "de": "Nach E-Mail suchen...", + "uk": "Пошук за електронною поштою..." + }, + "ORG$NO_MEMBERS_FOUND": { + "en": "No members found", + "ja": "メンバーが見つかりません", + "zh-CN": "未找到成员", + "zh-TW": "未找到成員", + "ko-KR": "멤버를 찾을 수 없습니다", + "no": "Ingen medlemmer funnet", + "it": "Nessun membro trovato", + "pt": "Nenhum membro encontrado", + "es": "No se encontraron miembros", + "ar": "لم يتم العثور على أعضاء", + "fr": "Aucun membre trouvé", + "tr": "Üye bulunamadı", + "de": "Keine Mitglieder gefunden", + "uk": "Членів не знайдено" + }, + "ORG$NO_MEMBERS_MATCHING_FILTER": { + "en": "No members match your search", + "ja": "検索に一致するメンバーはいません", + "zh-CN": "没有符合搜索条件的成员", + "zh-TW": "沒有符合搜尋條件的成員", + "ko-KR": "검색과 일치하는 멤버가 없습니다", + "no": "Ingen medlemmer samsvarer med søket ditt", + "it": "Nessun membro corrisponde alla tua ricerca", + "pt": "Nenhum membro corresponde à sua pesquisa", + "es": "Ningún miembro coincide con tu búsqueda", + "ar": "لا يوجد أعضاء يطابقون بحثك", + "fr": "Aucun membre ne correspond à votre recherche", + "tr": "Aramanızla eşleşen üye bulunamadı", + "de": "Keine Mitglieder entsprechen Ihrer Suche", + "uk": "Жодний член не відповідає вашому пошуку" + }, + "ORG$FAILED_TO_LOAD_MEMBERS": { + "en": "Failed to load members", + "ja": "メンバーの読み込みに失敗しました", + "zh-CN": "加载成员失败", + "zh-TW": "載入成員失敗", + "ko-KR": "멤버를 불러오지 못했습니다", + "no": "Kunne ikke laste medlemmer", + "it": "Impossibile caricare i membri", + "pt": "Falha ao carregar membros", + "es": "Error al cargar miembros", + "ar": "فشل تحميل الأعضاء", + "fr": "Échec du chargement des membres", + "tr": "Üyeler yüklenemedi", + "de": "Mitglieder konnten nicht geladen werden", + "uk": "Не вдалося завантажити членів" + }, "ONBOARDING$STEP1_TITLE": { "en": "What's your role?", "ja": "あなたの役割は?", diff --git a/frontend/src/icons/admin.svg b/frontend/src/icons/admin.svg new file mode 100644 index 0000000000..89004b4913 --- /dev/null +++ b/frontend/src/icons/admin.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/icons/loading-outer.svg b/frontend/src/icons/loading-outer.svg index aebe42c8e5..4c2d56aff0 100644 --- a/frontend/src/icons/loading-outer.svg +++ b/frontend/src/icons/loading-outer.svg @@ -1,4 +1,4 @@ + stroke="currentColor" stroke-width="6" stroke-linecap="round" /> diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 6936b283e9..64a8574c14 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -3,6 +3,7 @@ import { BILLING_HANDLERS } from "./billing-handlers"; import { FILE_SERVICE_HANDLERS } from "./file-service-handlers"; import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers"; import { SECRETS_HANDLERS } from "./secrets-handlers"; +import { ORG_HANDLERS } from "./org-handlers"; import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers"; import { SETTINGS_HANDLERS, @@ -15,6 +16,7 @@ import { FEEDBACK_HANDLERS } from "./feedback-handlers"; import { ANALYTICS_HANDLERS } from "./analytics-handlers"; export const handlers = [ + ...ORG_HANDLERS, ...API_KEYS_HANDLERS, ...BILLING_HANDLERS, ...FILE_SERVICE_HANDLERS, diff --git a/frontend/src/mocks/org-handlers.ts b/frontend/src/mocks/org-handlers.ts new file mode 100644 index 0000000000..9b7cd930ae --- /dev/null +++ b/frontend/src/mocks/org-handlers.ts @@ -0,0 +1,556 @@ +import { http, HttpResponse } from "msw"; +import { + Organization, + OrganizationMember, + OrganizationUserRole, + UpdateOrganizationMemberParams, +} from "#/types/org"; + +const MOCK_ME: Omit = { + user_id: "99", + email: "me@acme.org", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", +}; + +export const createMockOrganization = ( + id: string, + name: string, + credits: number, + is_personal?: boolean, +): Organization => ({ + id, + name, + contact_name: "Contact Name", + contact_email: "contact@example.com", + conversation_expiration: 86400, + agent: "default-agent", + default_max_iterations: 20, + security_analyzer: "standard", + confirmation_mode: false, + default_llm_model: "gpt-5-1", + default_llm_api_key_for_byor: "*********", + default_llm_base_url: "https://api.example-llm.com", + remote_runtime_resource_factor: 2, + enable_default_condenser: true, + billing_margin: 0.15, + enable_proactive_conversation_starters: true, + sandbox_base_container_image: "ghcr.io/example/sandbox-base:latest", + sandbox_runtime_container_image: "ghcr.io/example/sandbox-runtime:latest", + org_version: 0, + mcp_config: { + tools: [], + settings: {}, + }, + search_api_key: null, + sandbox_api_key: null, + max_budget_per_task: 25.0, + enable_solvability_analysis: false, + v1_enabled: true, + credits, + is_personal, +}); + +// Named mock organizations for test convenience +export const MOCK_PERSONAL_ORG = createMockOrganization( + "1", + "Personal Workspace", + 100, + true, +); +export const MOCK_TEAM_ORG_ACME = createMockOrganization( + "2", + "Acme Corp", + 1000, +); +export const MOCK_TEAM_ORG_BETA = createMockOrganization("3", "Beta LLC", 500); +export const MOCK_TEAM_ORG_ALLHANDS = createMockOrganization( + "4", + "All Hands AI", + 750, +); + +export const INITIAL_MOCK_ORGS: Organization[] = [ + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, + MOCK_TEAM_ORG_BETA, + MOCK_TEAM_ORG_ALLHANDS, +]; + +const INITIAL_MOCK_MEMBERS: Record = { + "1": [ + { + org_id: "1", + user_id: "99", + email: "me@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + ], + "2": [ + { + org_id: "2", + user_id: "1", + email: "alice@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "1", + user_id: "2", + email: "bob@acme.org", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "1", + user_id: "3", + email: "charlie@acme.org", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + ], + "3": [ + { + org_id: "2", + user_id: "4", + email: "tony@gamma.org", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "2", + user_id: "5", + email: "evan@gamma.org", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + ], + "4": [ + { + org_id: "3", + user_id: "6", + email: "robert@all-hands.dev", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "3", + user_id: "7", + email: "ray@all-hands.dev", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "3", + user_id: "8", + email: "chuck@all-hands.dev", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "3", + user_id: "9", + email: "stephan@all-hands.dev", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "3", + user_id: "10", + email: "tim@all-hands.dev", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "invited", + }, + ], +}; + +export const ORGS_AND_MEMBERS: Record = { + "1": INITIAL_MOCK_MEMBERS["1"].map((member) => ({ ...member })), + "2": INITIAL_MOCK_MEMBERS["2"].map((member) => ({ ...member })), + "3": INITIAL_MOCK_MEMBERS["3"].map((member) => ({ ...member })), + "4": INITIAL_MOCK_MEMBERS["4"].map((member) => ({ ...member })), +}; + +const orgs = new Map(INITIAL_MOCK_ORGS.map((org) => [org.id, org])); + +export const resetOrgMockData = () => { + // Reset organizations to initial state + orgs.clear(); + INITIAL_MOCK_ORGS.forEach((org) => { + orgs.set(org.id, { ...org }); + }); +}; + +export const resetOrgsAndMembersMockData = () => { + // Reset ORGS_AND_MEMBERS to initial state + // Note: This is needed since ORGS_AND_MEMBERS is mutated by updateMember + Object.keys(INITIAL_MOCK_MEMBERS).forEach((orgId) => { + ORGS_AND_MEMBERS[orgId] = INITIAL_MOCK_MEMBERS[orgId].map((member) => ({ + ...member, + })); + }); +}; + +export const ORG_HANDLERS = [ + http.get("/api/organizations/:orgId/me", ({ params }) => { + const orgId = params.orgId?.toString(); + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + let role: OrganizationUserRole = "member"; + switch (orgId) { + case "1": // Personal Workspace + role = "owner"; + break; + case "2": // Acme Corp + role = "owner"; + break; + case "3": // Beta LLC + role = "member"; + break; + case "4": // All Hands AI + role = "admin"; + break; + default: + role = "member"; + } + + const me: OrganizationMember = { + ...MOCK_ME, + org_id: orgId, + role, + }; + return HttpResponse.json(me); + }), + + http.get("/api/organizations/:orgId/members", ({ params, request }) => { + const orgId = params.orgId?.toString(); + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + // Parse query parameters + const url = new URL(request.url); + const pageIdParam = url.searchParams.get("page_id"); + const limitParam = url.searchParams.get("limit"); + const emailFilter = url.searchParams.get("email"); + + const offset = pageIdParam ? parseInt(pageIdParam, 10) : 0; + const limit = limitParam ? parseInt(limitParam, 10) : 10; + + let members = ORGS_AND_MEMBERS[orgId]; + + // Apply email filter if provided + if (emailFilter) { + members = members.filter((member) => + member.email.toLowerCase().includes(emailFilter.toLowerCase()), + ); + } + + const paginatedMembers = members.slice(offset, offset + limit); + const currentPage = Math.floor(offset / limit) + 1; + + return HttpResponse.json({ + items: paginatedMembers, + current_page: currentPage, + per_page: limit, + }); + }), + + http.get("/api/organizations/:orgId/members/count", ({ params, request }) => { + const orgId = params.orgId?.toString(); + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + // Parse query parameters + const url = new URL(request.url); + const emailFilter = url.searchParams.get("email"); + + let members = ORGS_AND_MEMBERS[orgId]; + + // Apply email filter if provided + if (emailFilter) { + members = members.filter((member) => + member.email.toLowerCase().includes(emailFilter.toLowerCase()), + ); + } + + return HttpResponse.json(members.length); + }), + + http.get("/api/organizations", () => { + const organizations = Array.from(orgs.values()); + // Return the first org as the current org for mock purposes + const currentOrgId = organizations.length > 0 ? organizations[0].id : null; + return HttpResponse.json({ + items: organizations, + current_org_id: currentOrgId, + }); + }), + + http.patch("/api/organizations/:orgId", async ({ request, params }) => { + const { name } = (await request.json()) as { + name: string; + }; + const orgId = params.orgId?.toString(); + + if (!name) { + return HttpResponse.json({ error: "Name is required" }, { status: 400 }); + } + + if (!orgId) { + return HttpResponse.json( + { error: "Organization ID is required" }, + { status: 400 }, + ); + } + + const existingOrg = orgs.get(orgId); + if (!existingOrg) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + const updatedOrg: Organization = { + ...existingOrg, + name, + }; + orgs.set(orgId, updatedOrg); + + return HttpResponse.json(updatedOrg, { status: 201 }); + }), + + http.get("/api/organizations/:orgId", ({ params }) => { + const orgId = params.orgId?.toString(); + + if (orgId) { + const org = orgs.get(orgId); + if (org) return HttpResponse.json(org); + } + + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + }), + + http.delete("/api/organizations/:orgId", ({ params }) => { + const orgId = params.orgId?.toString(); + + if (orgId && orgs.has(orgId) && ORGS_AND_MEMBERS[orgId]) { + orgs.delete(orgId); + delete ORGS_AND_MEMBERS[orgId]; + return HttpResponse.json( + { message: "Organization deleted" }, + { status: 204 }, + ); + } + + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + }), + + http.get("/api/organizations/:orgId/payment", ({ params }) => { + const orgId = params.orgId?.toString(); + + if (orgId) { + const org = orgs.get(orgId); + if (org) { + return HttpResponse.json({ + cardNumber: "**** **** **** 1234", // Mocked payment info + }); + } + } + + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + }), + + http.patch( + "/api/organizations/:orgId/members/:userId", + async ({ request, params }) => { + const updateData = + (await request.json()) as UpdateOrganizationMemberParams; + const orgId = params.orgId?.toString(); + const userId = params.userId?.toString(); + + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + const member = ORGS_AND_MEMBERS[orgId].find((m) => m.user_id === userId); + if (!member) { + return HttpResponse.json( + { error: "Member not found" }, + { status: 404 }, + ); + } + + // Update member with any provided fields + const newMember: OrganizationMember = { + ...member, + ...updateData, + }; + const newMembers = ORGS_AND_MEMBERS[orgId].map((m) => + m.user_id === userId ? newMember : m, + ); + ORGS_AND_MEMBERS[orgId] = newMembers; + + return HttpResponse.json(newMember, { status: 200 }); + }, + ), + + http.delete("/api/organizations/:orgId/members/:userId", ({ params }) => { + const { orgId, userId } = params; + + if (!orgId || !userId || !ORGS_AND_MEMBERS[orgId as string]) { + return HttpResponse.json( + { error: "Organization or member not found" }, + { status: 404 }, + ); + } + + // Remove member from organization + const members = ORGS_AND_MEMBERS[orgId as string]; + const updatedMembers = members.filter( + (member) => member.user_id !== userId, + ); + ORGS_AND_MEMBERS[orgId as string] = updatedMembers; + + return HttpResponse.json({ message: "Member removed" }, { status: 200 }); + }), + + http.post("/api/organizations/:orgId/switch", ({ params }) => { + const orgId = params.orgId?.toString(); + + if (orgId) { + const org = orgs.get(orgId); + if (org) return HttpResponse.json(org); + } + + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + }), + + http.post( + "/api/organizations/:orgId/members/invite", + async ({ request, params }) => { + const { emails } = (await request.json()) as { emails: string[] }; + const orgId = params.orgId?.toString(); + + if (!emails || emails.length === 0) { + return HttpResponse.json( + { error: "Emails are required" }, + { status: 400 }, + ); + } + + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + const members = Array.from(ORGS_AND_MEMBERS[orgId]); + const newMembers: OrganizationMember[] = emails.map((email, index) => ({ + org_id: orgId, + user_id: String(members.length + index + 1), + email, + role: "member" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "invited" as const, + })); + + ORGS_AND_MEMBERS[orgId] = [...members, ...newMembers]; + + return HttpResponse.json(newMembers, { status: 201 }); + }, + ), +]; diff --git a/frontend/src/mocks/settings-handlers.ts b/frontend/src/mocks/settings-handlers.ts index 8534789831..19079e3655 100644 --- a/frontend/src/mocks/settings-handlers.ts +++ b/frontend/src/mocks/settings-handlers.ts @@ -3,6 +3,37 @@ import { WebClientConfig } from "#/api/option-service/option.types"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { Provider, Settings } from "#/types/settings"; +/** + * Creates a mock WebClientConfig with all required fields. + * Use this helper to create test config objects with sensible defaults. + */ +export const createMockWebClientConfig = ( + overrides: Partial = {}, +): WebClientConfig => ({ + app_mode: "oss", + posthog_client_key: "test-posthog-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + ...overrides.feature_flags, + }, + providers_configured: [], + maintenance_start_time: null, + auth_url: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: new Date().toISOString(), + github_app_slug: null, + ...overrides, +}); + export const MOCK_DEFAULT_USER_SETTINGS: Settings = { llm_model: DEFAULT_SETTINGS.llm_model, llm_base_url: DEFAULT_SETTINGS.llm_base_url, @@ -73,8 +104,8 @@ export const SETTINGS_HANDLERS = [ app_mode: mockSaas ? "saas" : "oss", posthog_client_key: "fake-posthog-client-key", feature_flags: { - enable_billing: false, - hide_llm_settings: mockSaas, + enable_billing: mockSaas, + hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, enable_linear: false, diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index b50091dc3c..ba401dae9d 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -20,6 +20,8 @@ export default [ route("billing", "routes/billing.tsx"), route("secrets", "routes/secrets-settings.tsx"), route("api-keys", "routes/api-keys.tsx"), + route("org-members", "routes/manage-organization-members.tsx"), + route("org", "routes/manage-org.tsx"), ]), route("conversations/:conversationId", "routes/conversation.tsx"), route("microagent-management", "routes/microagent-management.tsx"), diff --git a/frontend/src/routes/api-keys.tsx b/frontend/src/routes/api-keys.tsx index e5d733ecb7..2bae9a4a9f 100644 --- a/frontend/src/routes/api-keys.tsx +++ b/frontend/src/routes/api-keys.tsx @@ -1,5 +1,8 @@ import React from "react"; import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; + +export const clientLoader = createPermissionGuard("manage_api_keys"); function ApiKeysScreen() { return ( diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index eafd225bca..8226488468 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -19,6 +19,11 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message" import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton"; import { useConfig } from "#/hooks/query/use-config"; import { parseMaxBudgetPerTask } from "#/utils/settings-utils"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; + +export const clientLoader = createPermissionGuard( + "manage_application_settings", +); function AppSettingsScreen() { const posthog = usePostHog(); diff --git a/frontend/src/routes/billing.tsx b/frontend/src/routes/billing.tsx index 05d23fe276..195d8eb0c1 100644 --- a/frontend/src/routes/billing.tsx +++ b/frontend/src/routes/billing.tsx @@ -1,4 +1,4 @@ -import { useSearchParams } from "react-router"; +import { redirect, useSearchParams } from "react-router"; import React from "react"; import { useTranslation } from "react-i18next"; import { PaymentForm } from "#/components/features/payment/payment-form"; @@ -8,11 +8,53 @@ import { } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; import { useTracking } from "#/hooks/use-tracking"; +import { useMe } from "#/hooks/query/use-me"; +import { usePermission } from "#/hooks/organizations/use-permissions"; +import { getActiveOrganizationUser } from "#/utils/org/permission-checks"; +import { rolePermissions } from "#/utils/org/permissions"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { queryClient } from "#/query-client-config"; +import OptionService from "#/api/option-service/option-service.api"; +import { WebClientConfig } from "#/api/option-service/option.types"; +import { getFirstAvailablePath } from "#/utils/settings-utils"; + +export const clientLoader = async () => { + let config = queryClient.getQueryData(["web-client-config"]); + if (!config) { + config = await OptionService.getConfig(); + queryClient.setQueryData(["web-client-config"], config); + } + + const isSaas = config?.app_mode === "saas"; + const featureFlags = config?.feature_flags; + + const getFallbackPath = () => + getFirstAvailablePath(isSaas, featureFlags) ?? "/settings"; + + const user = await getActiveOrganizationUser(); + + if (!user) { + return redirect(getFallbackPath()); + } + + const userRole = user.role ?? "member"; + + if ( + isBillingHidden(config, rolePermissions[userRole].includes("view_billing")) + ) { + return redirect(getFallbackPath()); + } + + return null; +}; function BillingSettingsScreen() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const { trackCreditsPurchased } = useTracking(); + const { data: me } = useMe(); + const { hasPermission } = usePermission(me?.role ?? "member"); + const canAddCredits = !!me && hasPermission("add_credits"); const checkoutStatus = searchParams.get("checkout"); React.useEffect(() => { @@ -38,7 +80,7 @@ function BillingSettingsScreen() { } }, [checkoutStatus, searchParams, setSearchParams, t, trackCreditsPurchased]); - return ; + return ; } export default BillingSettingsScreen; diff --git a/frontend/src/routes/git-settings.tsx b/frontend/src/routes/git-settings.tsx index 1b07e081dc..7061dbe303 100644 --- a/frontend/src/routes/git-settings.tsx +++ b/frontend/src/routes/git-settings.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { useConfig } from "#/hooks/query/use-config"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; import { useSettings } from "#/hooks/query/use-settings"; import { BrandButton } from "#/components/features/settings/brand-button"; import { useLogout } from "#/hooks/mutation/use-logout"; @@ -26,6 +27,8 @@ import { useUserProviders } from "#/hooks/use-user-providers"; import { ProjectManagementIntegration } from "#/components/features/settings/project-management/project-management-integration"; import { Typography } from "#/ui/typography"; +export const clientLoader = createPermissionGuard("manage_integrations"); + function GitSettingsScreen() { const { t } = useTranslation(); diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index d28bfa661b..d9489ec35a 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { AxiosError } from "axios"; import { useSearchParams } from "react-router"; import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers"; import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options"; import { useSettings } from "#/hooks/query/use-settings"; @@ -28,6 +29,8 @@ import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { getProviderId } from "#/utils/map-provider"; import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models"; +import { useMe } from "#/hooks/query/use-me"; +import { usePermission } from "#/hooks/organizations/use-permissions"; interface OpenHandsApiKeyHelpProps { testId: string; @@ -69,6 +72,13 @@ function LlmSettingsScreen() { const { data: resources } = useAIConfigOptions(); const { data: settings, isLoading, isFetching } = useSettings(); const { data: config } = useConfig(); + const { data: me } = useMe(); + const { hasPermission } = usePermission(me?.role ?? "member"); + + // In OSS mode, user has full access (no permission restrictions) + // In SaaS mode, check role-based permissions (members can only view, owners and admins can edit) + const isOssMode = config?.app_mode === "oss"; + const isReadOnly = isOssMode ? false : !hasPermission("edit_llm_settings"); const [view, setView] = React.useState<"basic" | "advanced">("basic"); @@ -499,6 +509,7 @@ function LlmSettingsScreen() { defaultIsToggled={view === "advanced"} onToggle={handleToggleAdvancedSettings} isToggled={view === "advanced"} + isDisabled={isReadOnly} > {t(I18nKey.SETTINGS$ADVANCED)} @@ -516,6 +527,7 @@ function LlmSettingsScreen() { onChange={handleModelIsDirty} onDefaultValuesChanged={onDefaultValuesChanged} wrapperClassName="!flex-col !gap-6" + isDisabled={isReadOnly} /> {(settings.llm_model?.startsWith("openhands/") || currentSelectedModel?.startsWith("openhands/")) && ( @@ -534,6 +546,7 @@ function LlmSettingsScreen() { className="w-full max-w-[680px]" placeholder={settings.llm_api_key_set ? "" : ""} onChange={handleApiKeyIsDirty} + isDisabled={isReadOnly} startContent={ settings.llm_api_key_set && ( @@ -566,6 +579,7 @@ function LlmSettingsScreen() { type="text" className="w-full max-w-[680px]" onChange={handleCustomModelIsDirty} + isDisabled={isReadOnly} /> {(settings.llm_model?.startsWith("openhands/") || currentSelectedModel?.startsWith("openhands/")) && ( @@ -581,6 +595,7 @@ function LlmSettingsScreen() { type="text" className="w-full max-w-[680px]" onChange={handleBaseUrlIsDirty} + isDisabled={isReadOnly} /> {!shouldUseOpenHandsKey && ( @@ -593,6 +608,7 @@ function LlmSettingsScreen() { className="w-full max-w-[680px]" placeholder={settings.llm_api_key_set ? "" : ""} onChange={handleApiKeyIsDirty} + isDisabled={isReadOnly} startContent={ settings.llm_api_key_set && ( @@ -647,6 +663,7 @@ function LlmSettingsScreen() { defaultSelectedKey={settings.agent} isClearable={false} onInputChange={handleAgentIsDirty} + isDisabled={isReadOnly} wrapperClassName="w-full max-w-[680px]" /> )} @@ -666,7 +683,7 @@ function LlmSettingsScreen() { DEFAULT_SETTINGS.condenser_max_size )?.toString()} onChange={(value) => handleCondenserMaxSizeIsDirty(value)} - isDisabled={!settings.enable_default_condenser} + isDisabled={isReadOnly || !settings.enable_default_condenser} className="w-full max-w-[680px] capitalize" />

@@ -679,6 +696,7 @@ function LlmSettingsScreen() { name="enable-memory-condenser-switch" defaultIsToggled={settings.enable_default_condenser} onToggle={handleEnableDefaultCondenserIsDirty} + isDisabled={isReadOnly} > {t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)} @@ -691,6 +709,7 @@ function LlmSettingsScreen() { onToggle={handleConfirmationModeIsDirty} defaultIsToggled={settings.confirmation_mode} isBeta + isDisabled={isReadOnly} > {t(I18nKey.SETTINGS$CONFIRMATION_MODE)} @@ -716,6 +735,7 @@ function LlmSettingsScreen() { )} selectedKey={selectedSecurityAnalyzer || "none"} isClearable={false} + isDisabled={isReadOnly} onSelectionChange={(key) => { const newValue = key?.toString() || ""; setSelectedSecurityAnalyzer(newValue); @@ -746,20 +766,26 @@ function LlmSettingsScreen() { )}

-
- - {!isPending && t("SETTINGS$SAVE_CHANGES")} - {isPending && t("SETTINGS$SAVING")} - -
+ {!isReadOnly && ( +
+ + {!isPending && t("SETTINGS$SAVE_CHANGES")} + {isPending && t("SETTINGS$SAVING")} + +
+ )}
); } +// Route protection: all roles have view_llm_settings, but this guard ensures +// consistency with other routes and allows future restrictions if needed +export const clientLoader = createPermissionGuard("view_llm_settings"); + export default LlmSettingsScreen; diff --git a/frontend/src/routes/manage-org.tsx b/frontend/src/routes/manage-org.tsx new file mode 100644 index 0000000000..cff5429344 --- /dev/null +++ b/frontend/src/routes/manage-org.tsx @@ -0,0 +1,219 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session"; +import { useOrganization } from "#/hooks/query/use-organization"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { useMe } from "#/hooks/query/use-me"; +import { useConfig } from "#/hooks/query/use-config"; +import { I18nKey } from "#/i18n/declaration"; +import { amountIsValid } from "#/utils/amount-is-valid"; +import { CreditsChip } from "#/ui/credits-chip"; +import { InteractiveChip } from "#/ui/interactive-chip"; +import { usePermission } from "#/hooks/organizations/use-permissions"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { DeleteOrgConfirmationModal } from "#/components/features/org/delete-org-confirmation-modal"; +import { ChangeOrgNameModal } from "#/components/features/org/change-org-name-modal"; +import { useBalance } from "#/hooks/query/use-balance"; +import { cn } from "#/utils/utils"; + +interface AddCreditsModalProps { + onClose: () => void; +} + +function AddCreditsModal({ onClose }: AddCreditsModalProps) { + const { t } = useTranslation(); + const { mutate: addBalance } = useCreateStripeCheckoutSession(); + + const [inputValue, setInputValue] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(null); + + const getErrorMessage = (value: string): string | null => { + if (!value.trim()) return null; + + const numValue = parseInt(value, 10); + if (Number.isNaN(numValue)) { + return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER); + } + if (numValue < 0) { + return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT); + } + if (numValue < 10) { + return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT); + } + if (numValue > 25000) { + return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT); + } + if (numValue !== parseFloat(value)) { + return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER); + } + return null; + }; + + const formAction = (formData: FormData) => { + const amount = formData.get("amount")?.toString(); + + if (amount?.trim()) { + if (!amountIsValid(amount)) { + const error = getErrorMessage(amount); + setErrorMessage(error || "Invalid amount"); + return; + } + + const intValue = parseInt(amount, 10); + + addBalance({ amount: intValue }, { onSuccess: onClose }); + + setErrorMessage(null); + } + }; + + const handleAmountInputChange = (value: string) => { + setInputValue(value); + setErrorMessage(null); + }; + + return ( + +
+

{t(I18nKey.ORG$ADD_CREDITS)}

+
+ handleAmountInputChange(value)} + className="w-full" + /> + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ + + +
+ ); +} + +export const clientLoader = createPermissionGuard("view_billing"); + +function ManageOrg() { + const { t } = useTranslation(); + const { data: me } = useMe(); + const { data: organization } = useOrganization(); + const { data: balance } = useBalance(); + const { data: config } = useConfig(); + + const role = me?.role ?? "member"; + const { hasPermission } = usePermission(role); + + const [addCreditsFormVisible, setAddCreditsFormVisible] = + React.useState(false); + const [changeOrgNameFormVisible, setChangeOrgNameFormVisible] = + React.useState(false); + const [deleteOrgConfirmationVisible, setDeleteOrgConfirmationVisible] = + React.useState(false); + + const canChangeOrgName = !!me && hasPermission("change_organization_name"); + const canDeleteOrg = !!me && hasPermission("delete_organization"); + const canAddCredits = !!me && hasPermission("add_credits"); + const shouldHideBilling = isBillingHidden( + config, + hasPermission("view_billing"), + ); + + return ( +
+ {changeOrgNameFormVisible && ( + setChangeOrgNameFormVisible(false)} + /> + )} + {deleteOrgConfirmationVisible && ( + setDeleteOrgConfirmationVisible(false)} + /> + )} + + {!shouldHideBilling && ( +
+ + {t(I18nKey.ORG$CREDITS)} + +
+ + ${Number(balance ?? 0).toFixed(2)} + + {canAddCredits && ( + setAddCreditsFormVisible(true)}> + {t(I18nKey.ORG$ADD)} + + )} +
+
+ )} + + {addCreditsFormVisible && !shouldHideBilling && ( + setAddCreditsFormVisible(false)} /> + )} + +
+ + {t(I18nKey.ORG$ORGANIZATION_NAME)} + + +
+ {organization?.name} + {canChangeOrgName && ( + + )} +
+
+ + {canDeleteOrg && ( + + )} +
+ ); +} + +export default ManageOrg; diff --git a/frontend/src/routes/manage-organization-members.tsx b/frontend/src/routes/manage-organization-members.tsx new file mode 100644 index 0000000000..1561c289ba --- /dev/null +++ b/frontend/src/routes/manage-organization-members.tsx @@ -0,0 +1,269 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { useTranslation } from "react-i18next"; +import { LoaderCircle, Plus, Search } from "lucide-react"; +import { InviteOrganizationMemberModal } from "#/components/features/org/invite-organization-member-modal"; +import { ConfirmRemoveMemberModal } from "#/components/features/org/confirm-remove-member-modal"; +import { ConfirmUpdateRoleModal } from "#/components/features/org/confirm-update-role-modal"; +import { useOrganizationMembers } from "#/hooks/query/use-organization-members"; +import { useOrganizationMembersCount } from "#/hooks/query/use-organization-members-count"; +import { OrganizationMember, OrganizationUserRole } from "#/types/org"; +import { OrganizationMemberListItem } from "#/components/features/org/organization-member-list-item"; +import { useUpdateMemberRole } from "#/hooks/mutation/use-update-member-role"; +import { useRemoveMember } from "#/hooks/mutation/use-remove-member"; +import { useMe } from "#/hooks/query/use-me"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { rolePermissions } from "#/utils/org/permissions"; +import { I18nKey } from "#/i18n/declaration"; +import { usePermission } from "#/hooks/organizations/use-permissions"; +import { getAvailableRolesAUserCanAssign } from "#/utils/org/permission-checks"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; +import { Typography } from "#/ui/typography"; +import { Pagination } from "#/ui/pagination"; +import { useDebounce } from "#/hooks/use-debounce"; + +export const clientLoader = createPermissionGuard( + "invite_user_to_organization", +); + +export const handle = { hideTitle: true }; + +function ManageOrganizationMembers() { + const { t } = useTranslation(); + + // Pagination and filtering state + const [page, setPage] = React.useState(1); + const [emailFilter, setEmailFilter] = React.useState(""); + const debouncedEmailFilter = useDebounce(emailFilter, 300); + + // Reset to page 1 when filter changes + React.useEffect(() => { + setPage(1); + }, [debouncedEmailFilter]); + + const limit = 10; + + const { + data: membersData, + isLoading, + isFetching, + error: membersError, + } = useOrganizationMembers({ + page, + limit, + email: debouncedEmailFilter, + }); + + const { data: totalCount, error: countError } = useOrganizationMembersCount({ + email: debouncedEmailFilter, + }); + + const hasError = membersError || countError; + + const { data: user } = useMe(); + const { mutate: updateMemberRole, isPending: isUpdatingRole } = + useUpdateMemberRole(); + const { mutate: removeMember, isPending: isRemovingMember } = + useRemoveMember(); + const [inviteModalOpen, setInviteModalOpen] = React.useState(false); + const [memberToRemove, setMemberToRemove] = + React.useState(null); + const [memberToUpdateRole, setMemberToUpdateRole] = React.useState<{ + member: OrganizationMember; + newRole: OrganizationUserRole; + } | null>(null); + + const currentUserRole = user?.role ?? "member"; + + const { hasPermission } = usePermission(currentUserRole); + const hasPermissionToInvite = hasPermission("invite_user_to_organization"); + + // Calculate total pages + const totalPages = + totalCount !== undefined ? Math.ceil(totalCount / limit) : 0; + + const handleRoleSelectionClick = ( + member: OrganizationMember, + role: OrganizationUserRole, + ) => { + // Don't show modal if the role is the same + if (member.role === role) { + return; + } + setMemberToUpdateRole({ member, newRole: role }); + }; + + const handleConfirmUpdateRole = () => { + if (memberToUpdateRole) { + updateMemberRole( + { + userId: memberToUpdateRole.member.user_id, + role: memberToUpdateRole.newRole, + }, + { onSettled: () => setMemberToUpdateRole(null) }, + ); + } + }; + + const handleRemoveMember = (member: OrganizationMember) => { + setMemberToRemove(member); + }; + + const handleConfirmRemoveMember = () => { + if (memberToRemove) { + removeMember( + { userId: memberToRemove.user_id }, + { onSettled: () => setMemberToRemove(null) }, + ); + } + }; + + const availableRolesToChangeTo = getAvailableRolesAUserCanAssign( + rolePermissions[currentUserRole], + ); + + const canAssignUserRole = (member: OrganizationMember) => + user != null && + user?.user_id !== member.user_id && + hasPermission(`change_user_role:${member.role}`); + + return ( +
+
+ {t(I18nKey.ORG$ORGANIZATION_MEMBERS)} + {hasPermissionToInvite && ( + setInviteModalOpen(true)} + startContent={} + > + {t(I18nKey.ORG$INVITE_ORG_MEMBERS)} + + )} +
+ + {/* Email Search Input */} +
+ + setEmailFilter(e.target.value)} + className="w-full leading-4 font-normal bg-transparent placeholder:italic placeholder:text-tertiary-alt outline-none" + /> + {isFetching && debouncedEmailFilter && ( + + )} +
+ + {inviteModalOpen && + ReactDOM.createPortal( + setInviteModalOpen(false)} + />, + document.getElementById("portal-root") || document.body, + )} + +
+
+ {t(I18nKey.ORG$ALL_ORGANIZATION_MEMBERS)} + {totalCount !== undefined && ( + + {totalCount} {totalCount === 1 ? "member" : "members"} + + )} +
+ + {isLoading && ( +
+ Loading... +
+ )} + + {!isLoading && hasError && ( +
+ {t(I18nKey.ORG$FAILED_TO_LOAD_MEMBERS)} +
+ )} + + {!isLoading && + !hasError && + membersData?.items && + membersData.items.length > 0 && ( +
    + {membersData.items.map((member) => ( +
  • + + handleRoleSelectionClick(member, role) + } + onRemove={() => handleRemoveMember(member)} + /> +
  • + ))} +
+ )} + + {!isLoading && + !hasError && + (!membersData?.items || membersData.items.length === 0) && ( +
+ {debouncedEmailFilter + ? t(I18nKey.ORG$NO_MEMBERS_MATCHING_FILTER) + : t(I18nKey.ORG$NO_MEMBERS_FOUND)} +
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( + + )} + + {memberToRemove && ( + setMemberToRemove(null)} + isLoading={isRemovingMember} + /> + )} + + {memberToUpdateRole && ( + setMemberToUpdateRole(null)} + isLoading={isUpdatingRole} + /> + )} +
+ ); +} + +export default ManageOrganizationMembers; diff --git a/frontend/src/routes/mcp-settings.tsx b/frontend/src/routes/mcp-settings.tsx index e308b45228..536e0f1c28 100644 --- a/frontend/src/routes/mcp-settings.tsx +++ b/frontend/src/routes/mcp-settings.tsx @@ -11,6 +11,9 @@ import { MCPServerForm } from "#/components/features/settings/mcp-settings/mcp-s import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal"; import { BrandButton } from "#/components/features/settings/brand-button"; import { MCPConfig } from "#/types/settings"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; + +export const clientLoader = createPermissionGuard("manage_mcp"); type MCPServerType = "sse" | "stdio" | "shttp"; diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 28cf7eee21..b61c281379 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -17,13 +17,13 @@ import { ReauthModal } from "#/components/features/waitlist/reauth-modal"; import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal"; import { useSettings } from "#/hooks/query/use-settings"; import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent"; -import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal"; import { displaySuccessToast } from "#/utils/custom-toast-handlers"; import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page"; import { useAutoLogin } from "#/hooks/use-auto-login"; import { useAuthCallback } from "#/hooks/use-auth-callback"; import { useReoTracking } from "#/hooks/use-reo-tracking"; import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent"; +import { useAutoSelectOrganization } from "#/hooks/use-auto-select-organization"; import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage"; import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard"; import { AlertBanner } from "#/components/features/alerts/alert-banner"; @@ -96,6 +96,9 @@ export default function MainApp() { // Sync PostHog opt-in/out state with backend setting on mount useSyncPostHogConsent(); + // Auto-select the first organization when none is selected + useAutoSelectOrganization(); + React.useEffect(() => { // Don't change language when on intermediate pages (TOS, profile questions) if (!isOnIntermediatePage && settings?.language) { @@ -259,10 +262,6 @@ export default function MainApp() { }} /> )} - - {config.data?.feature_flags.enable_billing && - config.data?.app_mode === "saas" && - settings?.is_new_user && }
); } diff --git a/frontend/src/routes/secrets-settings.tsx b/frontend/src/routes/secrets-settings.tsx index d6e81fbf97..ec6a9c3a28 100644 --- a/frontend/src/routes/secrets-settings.tsx +++ b/frontend/src/routes/secrets-settings.tsx @@ -12,6 +12,9 @@ import { BrandButton } from "#/components/features/settings/brand-button"; import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal"; import { GetSecretsResponse } from "#/api/secrets-service.types"; import { I18nKey } from "#/i18n/declaration"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; + +export const clientLoader = createPermissionGuard("manage_secrets"); function SecretsSettingsScreen() { const queryClient = useQueryClient(); diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index b617e43549..cc1c3563c6 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -1,84 +1,38 @@ import { useMemo } from "react"; -import { Outlet, redirect, useLocation } from "react-router"; +import { Outlet, redirect, useLocation, useMatches } from "react-router"; import { useTranslation } from "react-i18next"; import { Route } from "./+types/settings"; import OptionService from "#/api/option-service/option-service.api"; import { queryClient } from "#/query-client-config"; -import { - WebClientConfig, - WebClientFeatureFlags, -} from "#/api/option-service/option.types"; -import { SettingsLayout } from "#/components/features/settings/settings-layout"; +import { SettingsLayout } from "#/components/features/settings"; +import { WebClientConfig } from "#/api/option-service/option.types"; +import { Organization } from "#/types/org"; import { Typography } from "#/ui/typography"; import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; +import { getActiveOrganizationUser } from "#/utils/org/permission-checks"; +import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store"; +import { rolePermissions } from "#/utils/org/permissions"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { + isSettingsPageHidden, + getFirstAvailablePath, +} from "#/utils/settings-utils"; const SAAS_ONLY_PATHS = [ "/settings/user", "/settings/billing", "/settings/credits", "/settings/api-keys", + "/settings/team", + "/settings/org", ]; -/** - * Checks if a settings page should be hidden based on feature flags. - * Used by both the route loader and navigation hook to keep logic in sync. - */ -export function isSettingsPageHidden( - path: string, - featureFlags: WebClientFeatureFlags | undefined, -): boolean { - if (featureFlags?.hide_llm_settings && path === "/settings") return true; - if (featureFlags?.hide_users_page && path === "/settings/user") return true; - if (featureFlags?.hide_billing_page && path === "/settings/billing") - return true; - if (featureFlags?.hide_integrations_page && path === "/settings/integrations") - return true; - return false; -} - -/** - * Find the first available settings page that is not hidden. - * Returns null if no page is available (shouldn't happen in practice). - */ -export function getFirstAvailablePath( - isSaas: boolean, - featureFlags: WebClientFeatureFlags | undefined, -): string | null { - const saasFallbackOrder = [ - { path: "/settings/user", hidden: !!featureFlags?.hide_users_page }, - { - path: "/settings/integrations", - hidden: !!featureFlags?.hide_integrations_page, - }, - { path: "/settings/app", hidden: false }, - { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, - { path: "/settings/billing", hidden: !!featureFlags?.hide_billing_page }, - { path: "/settings/secrets", hidden: false }, - { path: "/settings/api-keys", hidden: false }, - { path: "/settings/mcp", hidden: false }, - ]; - - const ossFallbackOrder = [ - { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, - { path: "/settings/mcp", hidden: false }, - { - path: "/settings/integrations", - hidden: !!featureFlags?.hide_integrations_page, - }, - { path: "/settings/app", hidden: false }, - { path: "/settings/secrets", hidden: false }, - ]; - - const fallbackOrder = isSaas ? saasFallbackOrder : ossFallbackOrder; - const firstAvailable = fallbackOrder.find((item) => !item.hidden); - - return firstAvailable?.path ?? null; -} - export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { const url = new URL(request.url); const { pathname } = url; + console.log("clientLoader", { pathname }); + // Step 1: Get config first (needed for all checks, no user data required) let config = queryClient.getQueryData(["web-client-config"]); if (!config) { config = await OptionService.getConfig(); @@ -88,17 +42,75 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { const isSaas = config?.app_mode === "saas"; const featureFlags = config?.feature_flags; - // Check if current page should be hidden and redirect to first available page - const isHiddenPage = - (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) || - isSettingsPageHidden(pathname, featureFlags); + // Step 2: Check SAAS_ONLY_PATHS for OSS mode (no user data required) + if (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) { + return redirect("/settings"); + } - if (isHiddenPage) { + // Step 3: Check feature flag-based hiding and redirect IMMEDIATELY (no user data required) + // This handles hide_llm_settings, hide_users_page, hide_billing_page, hide_integrations_page + if (isSettingsPageHidden(pathname, featureFlags)) { const fallbackPath = getFirstAvailablePath(isSaas, featureFlags); + console.log("fallbackPath", fallbackPath); if (fallbackPath && fallbackPath !== pathname) { return redirect(fallbackPath); } - // If no fallback available or same as current, stay on current page + } + + // Step 4: For routes that need permission checks, get user data + // Only fetch user data for billing and org routes that need permission validation + if ( + pathname === "/settings/billing" || + pathname === "/settings/org" || + pathname === "/settings/org-members" + ) { + const user = await getActiveOrganizationUser(); + + // Org-type detection for route protection + const orgId = getSelectedOrganizationIdFromStore(); + const organizationsData = queryClient.getQueryData<{ + items: Organization[]; + currentOrgId: string | null; + }>(["organizations"]); + const selectedOrg = organizationsData?.items?.find( + (org) => org.id === orgId, + ); + const isPersonalOrg = selectedOrg?.is_personal === true; + const isTeamOrg = !!selectedOrg && !selectedOrg.is_personal; + + // Billing route protection + if (pathname === "/settings/billing") { + if ( + !user || + isBillingHidden( + config, + rolePermissions[user.role ?? "member"].includes("view_billing"), + ) || + isTeamOrg + ) { + if (isSaas) { + const fallbackPath = getFirstAvailablePath(isSaas, featureFlags); + return redirect(fallbackPath ?? "/settings"); + } + } + } + + // Org route protection: redirect if user lacks required permissions or personal org + if (pathname === "/settings/org" || pathname === "/settings/org-members") { + const role = user?.role ?? "member"; + const requiredPermission = + pathname === "/settings/org" + ? "view_billing" + : "invite_user_to_organization"; + + if ( + !user || + !rolePermissions[role].includes(requiredPermission) || + isPersonalOrg + ) { + return redirect("/settings"); + } + } } return null; @@ -107,7 +119,9 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { function SettingsScreen() { const { t } = useTranslation(); const location = useLocation(); + const matches = useMatches(); const navItems = useSettingsNavItems(); + // Current section title for the main content area const currentSectionTitle = useMemo(() => { const currentItem = navItems.find((item) => item.to === location.pathname); @@ -117,11 +131,17 @@ function SettingsScreen() { : (navItems[0]?.text ?? "SETTINGS$TITLE"); }, [navItems, location.pathname]); + const routeHandle = matches.find((m) => m.pathname === location.pathname) + ?.handle as { hideTitle?: boolean } | undefined; + const shouldHideTitle = routeHandle?.hideTitle === true; + return (
- {t(currentSectionTitle)} + {!shouldHideTitle && ( + {t(currentSectionTitle)} + )}
diff --git a/frontend/src/stores/selected-organization-store.ts b/frontend/src/stores/selected-organization-store.ts new file mode 100644 index 0000000000..54f1a5a756 --- /dev/null +++ b/frontend/src/stores/selected-organization-store.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +interface SelectedOrganizationState { + organizationId: string | null; +} + +interface SelectedOrganizationActions { + setOrganizationId: (orgId: string | null) => void; +} + +type SelectedOrganizationStore = SelectedOrganizationState & + SelectedOrganizationActions; + +const initialState: SelectedOrganizationState = { + organizationId: null, +}; + +export const useSelectedOrganizationStore = create()( + devtools( + (set) => ({ + ...initialState, + setOrganizationId: (organizationId) => set({ organizationId }), + }), + { name: "SelectedOrganizationStore" }, + ), +); + +export const getSelectedOrganizationIdFromStore = (): string | null => + useSelectedOrganizationStore.getState().organizationId; diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css index e5edcece15..eee31d1d16 100644 --- a/frontend/src/tailwind.css +++ b/frontend/src/tailwind.css @@ -343,6 +343,14 @@ 0 0 0 1px rgba(0, 0, 0, 0.05); } +.modal-box-shadow { + box-shadow: 0 183px 51px 0 rgba(0, 0, 0, 0.00), 0 117px 47px 0 rgba(0, 0, 0, 0.01), 0 66px 40px 0 rgba(0, 0, 0, 0.03), 0 29px 29px 0 rgba(0, 0, 0, 0.04), 0 7px 16px 0 rgba(0, 0, 0, 0.05); +} + +.table-box-shadow { + box-shadow: 0 26px 7px 0 rgba(0, 0, 0, 0.00), 0 17px 7px 0 rgba(0, 0, 0, 0.01), 0 9px 6px 0 rgba(0, 0, 0, 0.03), 0 4px 4px 0 rgba(0, 0, 0, 0.04), 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + .git-external-link-icon { background: linear-gradient(90deg, rgba(69, 69, 69, 0) 0.35%, #454545 41.39%); } diff --git a/frontend/src/types/org.ts b/frontend/src/types/org.ts new file mode 100644 index 0000000000..a527d49c47 --- /dev/null +++ b/frontend/src/types/org.ts @@ -0,0 +1,58 @@ +export type OrganizationUserRole = "member" | "admin" | "owner"; + +export interface Organization { + id: string; + name: string; + contact_name: string; + contact_email: string; + conversation_expiration: number; + agent: string; + default_max_iterations: number; + security_analyzer: string; + confirmation_mode: boolean; + default_llm_model: string; + default_llm_api_key_for_byor: string; + default_llm_base_url: string; + remote_runtime_resource_factor: number; + enable_default_condenser: boolean; + billing_margin: number; + enable_proactive_conversation_starters: boolean; + sandbox_base_container_image: string; + sandbox_runtime_container_image: string; + org_version: number; + mcp_config: { + tools: unknown[]; + settings: Record; + }; + search_api_key: string | null; + sandbox_api_key: string | null; + max_budget_per_task: number; + enable_solvability_analysis: boolean; + v1_enabled: boolean; + credits: number; + is_personal?: boolean; +} + +export interface OrganizationMember { + org_id: string; + user_id: string; + email: string; + role: OrganizationUserRole; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + status: "active" | "invited" | "inactive"; +} + +export interface OrganizationMembersPage { + items: OrganizationMember[]; + current_page: number; + per_page: number; +} + +/** org_id and user_id are provided via URL params */ +export type UpdateOrganizationMemberParams = Partial< + Omit +>; diff --git a/frontend/src/ui/context-menu-icon-text.tsx b/frontend/src/ui/context-menu-icon-text.tsx new file mode 100644 index 0000000000..9790ee03c2 --- /dev/null +++ b/frontend/src/ui/context-menu-icon-text.tsx @@ -0,0 +1,30 @@ +import { cn } from "#/utils/utils"; + +interface ContextMenuIconTextProps { + icon: React.ReactNode; + text: string; + rightIcon?: React.ReactNode; + className?: string; +} + +export function ContextMenuIconText({ + icon, + text, + rightIcon, + className, +}: ContextMenuIconTextProps) { + return ( +
+
+ {icon} + {text} +
+ {rightIcon &&
{rightIcon}
} +
+ ); +} diff --git a/frontend/src/ui/credits-chip.tsx b/frontend/src/ui/credits-chip.tsx new file mode 100644 index 0000000000..a27b6513b3 --- /dev/null +++ b/frontend/src/ui/credits-chip.tsx @@ -0,0 +1,30 @@ +import { cn } from "#/utils/utils"; + +interface CreditsChipProps { + testId?: string; + className?: string; +} + +/** + * Chip component for displaying credits amount + * Uses yellow background with black text for visibility + */ +export function CreditsChip({ + children, + testId, + className, +}: React.PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/ui/dropdown/clear-button.tsx b/frontend/src/ui/dropdown/clear-button.tsx new file mode 100644 index 0000000000..191e855a8d --- /dev/null +++ b/frontend/src/ui/dropdown/clear-button.tsx @@ -0,0 +1,19 @@ +import { X } from "lucide-react"; + +interface ClearButtonProps { + onClear: () => void; +} + +export function ClearButton({ onClear }: ClearButtonProps) { + return ( + + ); +} diff --git a/frontend/src/ui/dropdown/dropdown-input.tsx b/frontend/src/ui/dropdown/dropdown-input.tsx new file mode 100644 index 0000000000..26af0c3b94 --- /dev/null +++ b/frontend/src/ui/dropdown/dropdown-input.tsx @@ -0,0 +1,27 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { cn } from "#/utils/utils"; + +interface DropdownInputProps { + placeholder?: string; + isDisabled: boolean; + getInputProps: (props?: object) => object; +} + +export function DropdownInput({ + placeholder, + isDisabled, + getInputProps, +}: DropdownInputProps) { + return ( + + ); +} diff --git a/frontend/src/ui/dropdown/dropdown-menu.tsx b/frontend/src/ui/dropdown/dropdown-menu.tsx new file mode 100644 index 0000000000..7880089566 --- /dev/null +++ b/frontend/src/ui/dropdown/dropdown-menu.tsx @@ -0,0 +1,63 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { cn } from "#/utils/utils"; +import { DropdownOption } from "./types"; + +interface DropdownMenuProps { + isOpen: boolean; + filteredOptions: DropdownOption[]; + selectedItem: DropdownOption | null; + emptyMessage: string; + getMenuProps: (props?: object) => object; + getItemProps: (props: { + item: DropdownOption; + index: number; + className?: string; + }) => object; +} + +export function DropdownMenu({ + isOpen, + filteredOptions, + selectedItem, + emptyMessage, + getMenuProps, + getItemProps, +}: DropdownMenuProps) { + return ( +
+
    + {isOpen && filteredOptions.length === 0 && ( +
  • + {emptyMessage} +
  • + )} + {isOpen && + filteredOptions.map((option, index) => ( +
  • + {option.label} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/ui/dropdown/dropdown.tsx b/frontend/src/ui/dropdown/dropdown.tsx new file mode 100644 index 0000000000..229a821faa --- /dev/null +++ b/frontend/src/ui/dropdown/dropdown.tsx @@ -0,0 +1,128 @@ +import React, { useState } from "react"; +import { useCombobox } from "downshift"; +import { cn } from "#/utils/utils"; +import { DropdownOption } from "./types"; +import { LoadingSpinner } from "./loading-spinner"; +import { ClearButton } from "./clear-button"; +import { ToggleButton } from "./toggle-button"; +import { DropdownMenu } from "./dropdown-menu"; +import { DropdownInput } from "./dropdown-input"; + +interface DropdownProps { + options: DropdownOption[]; + emptyMessage?: string; + clearable?: boolean; + loading?: boolean; + disabled?: boolean; + placeholder?: string; + defaultValue?: DropdownOption; + onChange?: (item: DropdownOption | null) => void; + testId?: string; +} + +export function Dropdown({ + options, + emptyMessage = "No options", + clearable = false, + loading = false, + disabled = false, + placeholder, + defaultValue, + onChange, + testId, +}: DropdownProps) { + const [inputValue, setInputValue] = useState(defaultValue?.label ?? ""); + const [searchTerm, setSearchTerm] = useState(""); + + const filteredOptions = options.filter((option) => + option.label.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + const { + isOpen, + selectedItem, + selectItem, + getToggleButtonProps, + getMenuProps, + getItemProps, + getInputProps, + } = useCombobox({ + items: filteredOptions, + itemToString: (item) => item?.label ?? "", + inputValue, + stateReducer: (state, actionAndChanges) => + actionAndChanges.type === useCombobox.stateChangeTypes.InputClick && + state.isOpen + ? { ...actionAndChanges.changes, isOpen: true } + : actionAndChanges.changes, + onInputValueChange: ({ inputValue: newValue }) => { + setInputValue(newValue ?? ""); + setSearchTerm(newValue ?? ""); + }, + defaultSelectedItem: defaultValue, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { + onChange?.(newSelectedItem ?? null); + }, + onIsOpenChange: ({ + isOpen: newIsOpen, + selectedItem: currentSelectedItem, + }) => { + if (newIsOpen) { + setSearchTerm(""); + } else { + setInputValue(currentSelectedItem?.label ?? ""); + setSearchTerm(""); + } + }, + }); + + const isDisabled = loading || disabled; + + // Wrap getInputProps to inject a direct onChange handler that preserves + // cursor position. Downshift's default onInputValueChange resets cursor + // to end of input on every keystroke; reading from e.target.value keeps + // the browser's native cursor position intact. + const getInputPropsWithCursorFix = (props?: object) => + getInputProps({ + ...props, + onChange: (e: React.ChangeEvent) => { + setInputValue(e.target.value); + setSearchTerm(e.target.value); + }, + }); + + return ( +
+
+ + {loading && } + {clearable && selectedItem && ( + selectItem(null)} /> + )} + +
+ +
+ ); +} diff --git a/frontend/src/ui/dropdown/loading-spinner.tsx b/frontend/src/ui/dropdown/loading-spinner.tsx new file mode 100644 index 0000000000..18f5fe3aa1 --- /dev/null +++ b/frontend/src/ui/dropdown/loading-spinner.tsx @@ -0,0 +1,8 @@ +export function LoadingSpinner() { + return ( +
+ ); +} diff --git a/frontend/src/ui/dropdown/toggle-button.tsx b/frontend/src/ui/dropdown/toggle-button.tsx new file mode 100644 index 0000000000..59ab198510 --- /dev/null +++ b/frontend/src/ui/dropdown/toggle-button.tsx @@ -0,0 +1,31 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { ChevronDown } from "lucide-react"; +import { cn } from "#/utils/utils"; + +interface ToggleButtonProps { + isOpen: boolean; + isDisabled: boolean; + getToggleButtonProps: (props?: object) => object; +} + +export function ToggleButton({ + isOpen, + isDisabled, + getToggleButtonProps, +}: ToggleButtonProps) { + return ( + + ); +} diff --git a/frontend/src/ui/dropdown/types.ts b/frontend/src/ui/dropdown/types.ts new file mode 100644 index 0000000000..511e5d707d --- /dev/null +++ b/frontend/src/ui/dropdown/types.ts @@ -0,0 +1,4 @@ +export interface DropdownOption { + value: string; + label: string; +} diff --git a/frontend/src/ui/interactive-chip.tsx b/frontend/src/ui/interactive-chip.tsx new file mode 100644 index 0000000000..224e98d7d8 --- /dev/null +++ b/frontend/src/ui/interactive-chip.tsx @@ -0,0 +1,33 @@ +import { cn } from "#/utils/utils"; + +interface InteractiveChipProps { + onClick: () => void; + testId?: string; + className?: string; +} + +/** + * Small clickable chip component for actions like "Add" + * Uses gray background with black text + */ +export function InteractiveChip({ + children, + onClick, + testId, + className, +}: React.PropsWithChildren) { + return ( + + ); +} diff --git a/frontend/src/ui/pagination.tsx b/frontend/src/ui/pagination.tsx new file mode 100644 index 0000000000..4052f3ee7f --- /dev/null +++ b/frontend/src/ui/pagination.tsx @@ -0,0 +1,129 @@ +import { ChevronLeft } from "#/assets/chevron-left"; +import { ChevronRight } from "#/assets/chevron-right"; +import { cn } from "#/utils/utils"; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + className?: string; +} + +export function Pagination({ + currentPage, + totalPages, + onPageChange, + className, +}: PaginationProps) { + // Generate page numbers to display + const getPageNumbers = (): (number | "ellipsis")[] => { + const pages: (number | "ellipsis")[] = []; + const showEllipsis = totalPages > 7; + + if (!showEllipsis) { + // Show all pages if 7 or fewer + for (let i = 1; i <= totalPages; i += 1) { + pages.push(i); + } + } else { + // Always show first page + pages.push(1); + + if (currentPage > 3) { + pages.push("ellipsis"); + } + + // Show pages around current + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let i = start; i <= end; i += 1) { + pages.push(i); + } + + if (currentPage < totalPages - 2) { + pages.push("ellipsis"); + } + + // Always show last page + if (totalPages > 1) { + pages.push(totalPages); + } + } + + return pages; + }; + + const canGoPrevious = currentPage > 1; + const canGoNext = currentPage < totalPages; + + if (totalPages <= 1) { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/utils/get-component-prop-types.ts b/frontend/src/utils/get-component-prop-types.ts new file mode 100644 index 0000000000..8309441283 --- /dev/null +++ b/frontend/src/utils/get-component-prop-types.ts @@ -0,0 +1,2 @@ +export type GetComponentPropTypes = + T extends React.ComponentType ? P : never; diff --git a/frontend/src/utils/input-validation.ts b/frontend/src/utils/input-validation.ts new file mode 100644 index 0000000000..1c9df98021 --- /dev/null +++ b/frontend/src/utils/input-validation.ts @@ -0,0 +1,35 @@ +// Email validation regex pattern +const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + +/** + * Validates if a string is a valid email address format + * @param email The email string to validate + * @returns true if the email format is valid, false otherwise + */ +export const isValidEmail = (email: string): boolean => EMAIL_REGEX.test(email); + +/** + * Validates an array of email addresses and returns the invalid ones + * @param emails Array of email strings to validate + * @returns Array of invalid email addresses + */ +export const getInvalidEmails = (emails: string[]): string[] => + emails.filter((email) => !isValidEmail(email)); + +/** + * Checks if all emails in an array are valid + * @param emails Array of email strings to validate + * @returns true if all emails are valid, false otherwise + */ +export const areAllEmailsValid = (emails: string[]): boolean => + emails.every((email) => isValidEmail(email)); + +/** + * Checks if an array contains duplicate values (case-insensitive for emails) + * @param values Array of strings to check + * @returns true if duplicates exist, false otherwise + */ +export const hasDuplicates = (values: string[]): boolean => { + const lowercased = values.map((v) => v.toLowerCase()); + return new Set(lowercased).size !== lowercased.length; +}; diff --git a/frontend/src/utils/org/billing-visibility.ts b/frontend/src/utils/org/billing-visibility.ts new file mode 100644 index 0000000000..8bbe422602 --- /dev/null +++ b/frontend/src/utils/org/billing-visibility.ts @@ -0,0 +1,19 @@ +import { WebClientConfig } from "#/api/option-service/option.types"; + +/** + * Determines whether billing should be hidden based on feature flags and user permissions. + * + * Returns true when billing UI should NOT be shown. This is the single source of truth + * for billing visibility decisions across loaders and hooks. + * + * @param config - The application config. When undefined (not yet loaded), billing is + * hidden as a safe default to prevent unauthorized access during loading. + * @param hasViewBillingPermission - Whether the current user has the view_billing permission. + */ +export function isBillingHidden( + config: WebClientConfig | undefined, + hasViewBillingPermission: boolean, +): boolean { + if (!config) return true; + return !config.feature_flags?.enable_billing || !hasViewBillingPermission; +} diff --git a/frontend/src/utils/org/permission-checks.ts b/frontend/src/utils/org/permission-checks.ts new file mode 100644 index 0000000000..5bf9db857e --- /dev/null +++ b/frontend/src/utils/org/permission-checks.ts @@ -0,0 +1,50 @@ +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store"; +import { OrganizationMember, OrganizationUserRole } from "#/types/org"; +import { PermissionKey } from "./permissions"; +import { queryClient } from "#/query-client-config"; + +/** + * Get the active organization user. + * Uses React Query's fetchQuery to leverage request deduplication, + * preventing duplicate API calls when multiple consumers request the same data. + * @returns OrganizationMember + */ +export const getActiveOrganizationUser = async (): Promise< + OrganizationMember | undefined +> => { + const orgId = getSelectedOrganizationIdFromStore(); + if (!orgId) return undefined; + + try { + const user = await queryClient.fetchQuery({ + queryKey: ["organizations", orgId, "me"], + queryFn: () => organizationService.getMe({ orgId }), + staleTime: 1000 * 60 * 5, // 5 minutes - matches useMe hook + }); + return user; + } catch { + return undefined; + } +}; + +/** + * Get a list of roles that a user has permission to assign to other users + * @param userPermissions all permission for active user + * @returns an array of roles (strings) the user can change other users to + */ +export const getAvailableRolesAUserCanAssign = ( + userPermissions: PermissionKey[], +): OrganizationUserRole[] => { + const availableRoles: OrganizationUserRole[] = []; + if (userPermissions.includes("change_user_role:member")) { + availableRoles.push("member"); + } + if (userPermissions.includes("change_user_role:admin")) { + availableRoles.push("admin"); + } + if (userPermissions.includes("change_user_role:owner")) { + availableRoles.push("owner"); + } + return availableRoles; +}; diff --git a/frontend/src/utils/org/permission-guard.ts b/frontend/src/utils/org/permission-guard.ts new file mode 100644 index 0000000000..fe8e5c67fe --- /dev/null +++ b/frontend/src/utils/org/permission-guard.ts @@ -0,0 +1,84 @@ +import { redirect } from "react-router"; +import OptionService from "#/api/option-service/option-service.api"; +import { WebClientConfig } from "#/api/option-service/option.types"; +import { queryClient } from "#/query-client-config"; +import { getFirstAvailablePath } from "#/utils/settings-utils"; +import { getActiveOrganizationUser } from "./permission-checks"; +import { PermissionKey, rolePermissions } from "./permissions"; + +/** + * Helper to get config, using cache or fetching if needed. + */ +async function getConfig(): Promise { + let config = queryClient.getQueryData(["web-client-config"]); + if (!config) { + config = await OptionService.getConfig(); + queryClient.setQueryData(["web-client-config"], config); + } + return config; +} + +/** + * Gets the appropriate fallback path for permission denied scenarios. + * Respects feature flags to avoid redirecting to hidden pages. + */ +async function getPermissionDeniedFallback(): Promise { + const config = await getConfig(); + + const isSaas = config?.app_mode === "saas"; + const featureFlags = config?.feature_flags; + + // Get first available path that respects feature flags + const fallbackPath = getFirstAvailablePath(isSaas, featureFlags); + return fallbackPath ?? "/settings"; +} + +/** + * Creates a clientLoader guard that checks if the user has the required permission. + * Redirects to the first available settings page if permission is denied. + * + * In OSS mode, permission checks are bypassed since there are no user roles. + * + * @param requiredPermission - The permission key to check + * @param customRedirectPath - Optional custom path to redirect to (will still respect feature flags if not provided) + * @returns A clientLoader function that can be exported from route files + */ +export const createPermissionGuard = + (requiredPermission: PermissionKey, customRedirectPath?: string) => + async ({ request }: { request: Request }) => { + // Get config to check app_mode + const config = await getConfig(); + + // In OSS mode, skip permission checks - all settings are accessible + if (config?.app_mode === "oss") { + return null; + } + + const user = await getActiveOrganizationUser(); + + const url = new URL(request.url); + const currentPath = url.pathname; + + // Helper to get redirect response, avoiding infinite loops + const getRedirectResponse = async () => { + const redirectPath = + customRedirectPath ?? (await getPermissionDeniedFallback()); + // Don't redirect to the same path to avoid infinite loops + if (redirectPath === currentPath) { + return null; + } + return redirect(redirectPath); + }; + + if (!user) { + return getRedirectResponse(); + } + + const userRole = user.role ?? "member"; + + if (!rolePermissions[userRole].includes(requiredPermission)) { + return getRedirectResponse(); + } + + return null; + }; diff --git a/frontend/src/utils/org/permissions.ts b/frontend/src/utils/org/permissions.ts new file mode 100644 index 0000000000..ab17f5b5e4 --- /dev/null +++ b/frontend/src/utils/org/permissions.ts @@ -0,0 +1,69 @@ +import { OrganizationUserRole } from "#/types/org"; + +/* PERMISSION TYPES */ +type UserRoleChangePermissionKey = `change_user_role:${OrganizationUserRole}`; +type InviteUserToOrganizationKey = "invite_user_to_organization"; + +type ChangeOrganizationNamePermission = "change_organization_name"; +type DeleteOrganizationPermission = "delete_organization"; +type AddCreditsPermission = "add_credits"; +type ViewBillingPermission = "view_billing"; + +type ManageSecretsPermission = "manage_secrets"; +type ManageMCPPermission = "manage_mcp"; +type ManageIntegrationsPermission = "manage_integrations"; +type ManageApplicationSettingsPermission = "manage_application_settings"; +type ManageAPIKeysPermission = "manage_api_keys"; + +type ViewLLMSettingsPermission = "view_llm_settings"; +type EditLLMSettingsPermission = "edit_llm_settings"; + +// Union of all permission keys +export type PermissionKey = + | UserRoleChangePermissionKey + | InviteUserToOrganizationKey + | ChangeOrganizationNamePermission + | DeleteOrganizationPermission + | AddCreditsPermission + | ViewBillingPermission + | ManageSecretsPermission + | ManageMCPPermission + | ManageIntegrationsPermission + | ManageApplicationSettingsPermission + | ManageAPIKeysPermission + | ViewLLMSettingsPermission + | EditLLMSettingsPermission; + +/* PERMISSION ARRAYS */ +const memberPerms: PermissionKey[] = [ + "manage_secrets", + "manage_mcp", + "manage_integrations", + "manage_application_settings", + "manage_api_keys", + "view_llm_settings", +]; + +const adminOnly: PermissionKey[] = [ + "edit_llm_settings", + "view_billing", + "add_credits", + "invite_user_to_organization", + "change_user_role:member", + "change_user_role:admin", +]; + +const ownerOnly: PermissionKey[] = [ + "change_organization_name", + "delete_organization", + "change_user_role:owner", +]; + +const adminPerms: PermissionKey[] = [...memberPerms, ...adminOnly]; +const ownerPerms: PermissionKey[] = [...adminPerms, ...ownerOnly]; + +export const rolePermissions: Record = { + owner: ownerPerms, + admin: adminPerms, + member: memberPerms, +}; diff --git a/frontend/src/utils/query-client-getters.ts b/frontend/src/utils/query-client-getters.ts new file mode 100644 index 0000000000..4b9fbb800c --- /dev/null +++ b/frontend/src/utils/query-client-getters.ts @@ -0,0 +1,5 @@ +import { queryClient } from "#/query-client-config"; +import { OrganizationMember } from "#/types/org"; + +export const getMeFromQueryClient = (orgId: string | null) => + queryClient.getQueryData(["organizations", orgId, "me"]); diff --git a/frontend/src/utils/settings-utils.ts b/frontend/src/utils/settings-utils.ts index 4259226d77..caa03e9aa7 100644 --- a/frontend/src/utils/settings-utils.ts +++ b/frontend/src/utils/settings-utils.ts @@ -1,5 +1,6 @@ import { Settings } from "#/types/settings"; import { getProviderId } from "#/utils/map-provider"; +import { WebClientFeatureFlags } from "#/api/option-service/option.types"; const extractBasicFormData = (formData: FormData) => { const providerDisplay = formData.get("llm-provider-input")?.toString(); @@ -91,3 +92,59 @@ export const extractSettings = (formData: FormData): Partial => { llm_api_key: LLM_API_KEY, }; }; + +/** + * Checks if a settings page should be hidden based on feature flags. + * Used by both the route loader and navigation hook to keep logic in sync. + */ +export function isSettingsPageHidden( + path: string, + featureFlags: WebClientFeatureFlags | undefined, +): boolean { + if (featureFlags?.hide_llm_settings && path === "/settings") return true; + if (featureFlags?.hide_users_page && path === "/settings/user") return true; + if (featureFlags?.hide_billing_page && path === "/settings/billing") + return true; + if (featureFlags?.hide_integrations_page && path === "/settings/integrations") + return true; + return false; +} + +/** + * Find the first available settings page that is not hidden. + * Returns null if no page is available (shouldn't happen in practice). + */ +export function getFirstAvailablePath( + isSaas: boolean, + featureFlags: WebClientFeatureFlags | undefined, +): string | null { + const saasFallbackOrder = [ + { path: "/settings/user", hidden: !!featureFlags?.hide_users_page }, + { + path: "/settings/integrations", + hidden: !!featureFlags?.hide_integrations_page, + }, + { path: "/settings/app", hidden: false }, + { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, + { path: "/settings/billing", hidden: !!featureFlags?.hide_billing_page }, + { path: "/settings/secrets", hidden: false }, + { path: "/settings/api-keys", hidden: false }, + { path: "/settings/mcp", hidden: false }, + ]; + + const ossFallbackOrder = [ + { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, + { path: "/settings/mcp", hidden: false }, + { + path: "/settings/integrations", + hidden: !!featureFlags?.hide_integrations_page, + }, + { path: "/settings/app", hidden: false }, + { path: "/settings/secrets", hidden: false }, + ]; + + const fallbackOrder = isSaas ? saasFallbackOrder : ossFallbackOrder; + const firstAvailable = fallbackOrder.find((item) => !item.hidden); + + return firstAvailable?.path ?? null; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 5fd1ea2403..ce7b82c92a 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -3,5 +3,25 @@ import { heroui } from "@heroui/react"; import typography from "@tailwindcss/typography"; export default { darkMode: "class", + theme: { + extend: { + colors: { + modal: { + background: "#171717", + input: "#27272A", + primary: "#F3CE49", + secondary: "#737373", + muted: "#A3A3A3", + }, + org: { + border: "#171717", + background: "#262626", + divider: "#525252", + button: "#737373", + text: "#A3A3A3", + }, + }, + }, + }, plugins: [typography], }; diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx index fee6d71093..d47a86a372 100644 --- a/frontend/test-utils.tsx +++ b/frontend/test-utils.tsx @@ -1,12 +1,18 @@ -// Test utilities for React components - import React, { PropsWithChildren } from "react"; -import { RenderOptions, render } from "@testing-library/react"; +import { + act, + RenderOptions, + render, + screen, + waitFor, +} from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nextProvider, initReactI18next } from "react-i18next"; import i18n from "i18next"; -import { vi } from "vitest"; +import { expect, vi } from "vitest"; import { AxiosError } from "axios"; +import { INITIAL_MOCK_ORGS } from "#/mocks/org-handlers"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; import { ActionEvent, MessageEvent, @@ -26,6 +32,9 @@ vi.mock("react-router", async () => { return { ...actual, useParams: useParamsMock, + useRevalidator: () => ({ + revalidate: vi.fn(), + }), }; }); @@ -37,7 +46,10 @@ i18n.use(initReactI18next).init({ defaultNS: "translation", resources: { en: { - translation: {}, + translation: { + "ORG$PERSONAL_WORKSPACE": "Personal Workspace", + "ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization", + }, }, }, interpolation: { @@ -86,6 +98,47 @@ export const createAxiosNotFoundErrorObject = () => }, ); +export const selectOrganization = async ({ + orgIndex, +}: { + orgIndex: number; +}) => { + const targetOrg = INITIAL_MOCK_ORGS[orgIndex]; + if (!targetOrg) { + expect.fail(`No organization found at index ${orgIndex}`); + } + + // Wait for the settings navbar to render (which contains the org selector) + await screen.findByTestId("settings-navbar"); + + // Wait for orgs to load and org selector to be present + const organizationSelect = await screen.findByTestId("org-selector"); + expect(organizationSelect).toBeInTheDocument(); + + // Wait until the dropdown trigger is not disabled (orgs have loaded) + const trigger = await screen.findByTestId("dropdown-trigger"); + await waitFor(() => { + expect(trigger).not.toBeDisabled(); + }); + + // Set the organization ID directly in the Zustand store + // This is more reliable than UI interaction in router stub tests + // Use act() to ensure React processes the state update + act(() => { + useSelectedOrganizationStore.setState({ organizationId: targetOrg.id }); + }); + + // Get the combobox input and wait for it to reflect the selection + // For personal orgs, the display name is "Personal Workspace" (from i18n) + const expectedDisplayName = targetOrg.is_personal + ? "Personal Workspace" + : targetOrg.name; + const combobox = screen.getByRole("combobox"); + await waitFor(() => { + expect(combobox).toHaveValue(expectedDisplayName); + }); +}; + export const createAxiosError = ( status: number, statusText: string, diff --git a/frontend/tests/avatar-menu.spec.ts b/frontend/tests/avatar-menu.spec.ts index c7d49ac302..a8243dca9a 100644 --- a/frontend/tests/avatar-menu.spec.ts +++ b/frontend/tests/avatar-menu.spec.ts @@ -13,6 +13,39 @@ import test, { expect } from "@playwright/test"; test("avatar context menu stays open when moving cursor diagonally to menu", async ({ page, }) => { + // Intercept GET /api/settings to return settings with a configured provider. + // In OSS mode, the user context menu only renders when providers are configured. + await page.route("**/api/settings", async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + llm_model: "openhands/claude-opus-4-5-20251101", + llm_base_url: "", + agent: "CodeActAgent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: "llm", + remote_runtime_resource_factor: 1, + provider_tokens_set: { github: "" }, + enable_default_condenser: true, + condenser_max_size: 240, + enable_sound_notifications: false, + user_consents_to_analytics: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + max_budget_per_task: null, + }), + }); + } else { + await route.continue(); + } + }); + await page.goto("/"); // Wait for the page to be fully loaded and check for AI config modal @@ -36,7 +69,8 @@ test("avatar context menu stays open when moving cursor diagonally to menu", asy // intercept clicks when the mouse triggers group-hover state await userAvatar.click({ force: true }); - const contextMenu = page.getByTestId("account-settings-context-menu"); + // The context menu should appear via CSS group-hover + const contextMenu = page.getByTestId("user-context-menu"); await expect(contextMenu).toBeVisible(); const menuWrapper = contextMenu.locator(".."); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index aadbdd10b1..c43fa03553 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -10,7 +10,9 @@ window.scrollTo = vi.fn(); // Mock ResizeObserver for test environment class MockResizeObserver { observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); }