mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(frontend): Organizational support (#9496)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com> Co-authored-by: Abhay Mishra <grabhaymishra@gmail.com> Co-authored-by: Hyun Han <62870362+smosco@users.noreply.github.com> Co-authored-by: Nhan Nguyen <nhan13574@gmail.com> Co-authored-by: Bharath A V <avbharath1221@gmail.com> Co-authored-by: hieptl <hieptl.developer@gmail.com> Co-authored-by: Chloe <chloe@openhands.com> Co-authored-by: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -234,6 +234,8 @@ yarn-error.log*
|
||||
|
||||
logs
|
||||
|
||||
ralph/
|
||||
|
||||
# agent
|
||||
.envrc
|
||||
/workspace
|
||||
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -8,3 +8,4 @@ node_modules/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.react-router/
|
||||
ralph/
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWithOssConfig = (ui: React.ReactElement) => {
|
||||
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "oss" }));
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
onClickAccountSettingsMock.mockClear();
|
||||
onLogoutMock.mockClear();
|
||||
onCloseMock.mockClear();
|
||||
mockTrackAddTeamMembersButtonClick.mockClear();
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockClear();
|
||||
});
|
||||
|
||||
it("should always render the right options", () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("logout button is always enabled", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
{ 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(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const addTeamMembersButton = screen.getByTestId("add-team-members-button");
|
||||
await user.click(addTeamMembersButton);
|
||||
|
||||
expect(mockTrackAddTeamMembersButtonClick).toHaveBeenCalledOnce();
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("react-i18next")>()),
|
||||
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(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail={memberEmail}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={onConfirmMock}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancelMock}
|
||||
memberEmail="test@example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("confirm-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("react-i18next")>()),
|
||||
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(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail={memberEmail}
|
||||
newRole={newRole}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={onConfirmMock}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancelMock}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("confirm-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<InviteOrganizationMemberModal onClose={config?.onClose || vi.fn()} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
203
frontend/__tests__/components/features/org/org-selector.test.tsx
Normal file
203
frontend/__tests__/components/features/org/org-selector.test.tsx
Normal file
@@ -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<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization",
|
||||
"ORG$PERSONAL_WORKSPACE": "Personal Workspace",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderOrgSelector = () =>
|
||||
render(<OrgSelector />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<SettingsNavigation
|
||||
isMobileMenuOpen={false}
|
||||
onCloseMobileMenu={vi.fn()}
|
||||
navigationItems={items}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof UserContextMenu>;
|
||||
|
||||
function UserContextMenuWithRootOutlet({
|
||||
type,
|
||||
onClose,
|
||||
onOpenInviteModal,
|
||||
}: UserContextMenuProps) {
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="portal-root" id="portal-root" />
|
||||
<UserContextMenu
|
||||
type={type}
|
||||
onClose={onClose}
|
||||
onOpenInviteModal={onOpenInviteModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderUserContextMenu = ({
|
||||
type,
|
||||
onClose,
|
||||
onOpenInviteModal,
|
||||
}: UserContextMenuProps) =>
|
||||
render(
|
||||
<UserContextMenuWithRootOutlet
|
||||
type={type}
|
||||
onClose={onClose}
|
||||
onOpenInviteModal={onOpenInviteModal}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
});
|
||||
|
||||
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> = {},
|
||||
): 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<OrganizationMember>) => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser(user),
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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<string, Record<string, string>>
|
||||
)[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<string, any>) {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
|
||||
// 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<string, Record<string, string>>
|
||||
)[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 (
|
||||
<div>
|
||||
<UserAvatar onClick={() => {}} />
|
||||
<div data-testid="main-content">
|
||||
<h1>{t("LANDING$TITLE")}</h1>
|
||||
<button>{t("VSCODE$OPEN")}</button>
|
||||
<button>{t("SUGGESTIONS$INCREASE_TEST_COVERAGE")}</button>
|
||||
<button>{t("SUGGESTIONS$AUTO_MERGE_PRS")}</button>
|
||||
<button>{t("SUGGESTIONS$FIX_README")}</button>
|
||||
<button>{t("SUGGESTIONS$CLEAN_DEPENDENCIES")}</button>
|
||||
</div>
|
||||
<div data-testid="tabs">
|
||||
<span>{t("WORKSPACE$TERMINAL_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$BROWSER_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$JUPYTER_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$CODE_EDITOR_TAB_LABEL")}</span>
|
||||
</div>
|
||||
<div data-testid="workspace-label">{t("WORKSPACE$TITLE")}</div>
|
||||
<button data-testid="new-project">{t("PROJECT$NEW_PROJECT")}</button>
|
||||
<div data-testid="status">
|
||||
<span>{t("TERMINAL$WAITING_FOR_CLIENT")}</span>
|
||||
<span>{t("STATUS$CONNECTED")}</span>
|
||||
<span>{t("STATUS$CONNECTED_TO_SERVER")}</span>
|
||||
</div>
|
||||
<div data-testid="time">
|
||||
<span>{`5 ${t("TIME$MINUTES_AGO")}`}</span>
|
||||
<span>{`2 ${t("TIME$HOURS_AGO")}`}</span>
|
||||
<span>{`3 ${t("TIME$DAYS_AGO")}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
// 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<string, string[]>();
|
||||
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(", ")}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
429
frontend/__tests__/components/ui/dropdown.test.tsx
Normal file
429
frontend/__tests__/components/ui/dropdown.test.tsx
Normal file
@@ -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(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
|
||||
expect(trigger).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open dropdown on trigger click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={[]} />);
|
||||
|
||||
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(<Dropdown options={[]} emptyMessage="Nothing found" />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} clearable />);
|
||||
|
||||
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(<Dropdown options={mockOptions} clearable />);
|
||||
|
||||
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(<Dropdown options={mockOptions} clearable />);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show placeholder after clearing selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Dropdown
|
||||
options={mockOptions}
|
||||
clearable
|
||||
placeholder="Select an option"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-loading")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display loading indicator when loading prop is true", () => {
|
||||
render(<Dropdown options={mockOptions} loading />);
|
||||
|
||||
expect(screen.getByTestId("dropdown-loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable interaction while loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} loading />);
|
||||
|
||||
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(<Dropdown options={mockOptions} disabled />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("should have disabled attribute on trigger", () => {
|
||||
render(<Dropdown options={mockOptions} disabled />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
expect(trigger).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Placeholder", () => {
|
||||
it("should display placeholder text when no value selected", () => {
|
||||
render(<Dropdown options={mockOptions} placeholder="Select an option" />);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveAttribute("placeholder", "Select an option");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default value", () => {
|
||||
it("should display defaultValue in input on mount", () => {
|
||||
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} onChange={onChangeMock} />);
|
||||
|
||||
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(
|
||||
<Dropdown
|
||||
options={mockOptions}
|
||||
clearable
|
||||
defaultValue={mockOptions[0]}
|
||||
onChange={onChangeMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<Dropdown options={mockOptions} testId="org-dropdown" />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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(<Dropdown options={mockOptions} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
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(
|
||||
<UserActions
|
||||
user={
|
||||
props.hasAvatar
|
||||
? { avatar_url: "https://example.com/avatar.png" }
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// 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(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
const renderWithRouter = (ui: ReactElement) =>
|
||||
renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks to default values before each test
|
||||
@@ -61,29 +113,11 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
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(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<UserActions onLogout={onLogoutMock} />);
|
||||
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(
|
||||
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
|
||||
);
|
||||
|
||||
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(<UserActions onLogout={onLogoutMock} />);
|
||||
renderWithRouter(<UserActions />);
|
||||
|
||||
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(
|
||||
<UserActions onLogout={onLogoutMock} />,
|
||||
);
|
||||
const { unmount } = renderWithRouter(<UserActions />);
|
||||
|
||||
// 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(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<MemoryRouter>
|
||||
<UserActions onLogout={onLogoutMock} />
|
||||
<UserActions />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
isLoading={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<UserAvatar onClick={onClickMock} />);
|
||||
render(<UserAvatar />);
|
||||
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(<UserAvatar onClick={onClickMock} />);
|
||||
|
||||
const userAvatarContainer = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatarContainer);
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should display the user's avatar when available", () => {
|
||||
render(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
/>,
|
||||
);
|
||||
render(<UserAvatar avatarUrl="https://example.com/avatar.png" />);
|
||||
|
||||
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(<UserAvatar onClick={onClickMock} />);
|
||||
const { rerender } = render(<UserAvatar />);
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(<UserAvatar onClick={onClickMock} isLoading />);
|
||||
rerender(<UserAvatar isLoading />);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
isLoading
|
||||
/>,
|
||||
<UserAvatar avatarUrl="https://example.com/avatar.png" isLoading />,
|
||||
);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument();
|
||||
|
||||
@@ -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> = {},
|
||||
): 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,
|
||||
});
|
||||
@@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
45
frontend/__tests__/hooks/mutation/use-remove-member.test.tsx
Normal file
45
frontend/__tests__/hooks/mutation/use-remove-member.test.tsx
Normal file
@@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
174
frontend/__tests__/hooks/query/use-organizations.test.tsx
Normal file
174
frontend/__tests__/hooks/query/use-organizations.test.tsx
Normal file
@@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
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<string, unknown>).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");
|
||||
});
|
||||
});
|
||||
134
frontend/__tests__/hooks/use-org-type-and-access.test.tsx
Normal file
134
frontend/__tests__/hooks/use-org-type-and-access.test.tsx
Normal file
@@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
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<typeof useOrganizations>);
|
||||
|
||||
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<typeof useOrganizations>);
|
||||
|
||||
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<typeof useOrganizations>);
|
||||
|
||||
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<typeof useOrganizations>);
|
||||
|
||||
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<typeof useOrganizations>);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
frontend/__tests__/hooks/use-permission.test.tsx
Normal file
98
frontend/__tests__/hooks/use-permission.test.tsx
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
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<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockConfigWithFeatureFlags = (
|
||||
appMode: "saas" | "oss",
|
||||
featureFlags: Partial<WebClientFeatureFlags>,
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<AccountSettingsContextMenu onLogout={() => {}} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
10
frontend/__tests__/routes/api-keys.test.tsx
Normal file
10
frontend/__tests__/routes/api-keys.test.tsx
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
367
frontend/__tests__/routes/billing.test.tsx
Normal file
367
frontend/__tests__/routes/billing.test.tsx
Normal file
@@ -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<typeof import("react-i18next")>("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> = {},
|
||||
): 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<OrganizationMember>) => {
|
||||
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: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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(<PaymentForm isDisabled />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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(<PaymentForm isDisabled={false} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof import("react-router")>("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(<LlmSettingsScreen />, {
|
||||
// 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(<LlmSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
954
frontend/__tests__/routes/manage-org.test.tsx
Normal file
954
frontend/__tests__/routes/manage-org.test.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<ManageOrg />
|
||||
<div data-testid="portal-root" id="portal-root" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RouteStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <div data-testid="home-screen" />,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
// @ts-expect-error - type mismatch
|
||||
loader: clientLoader,
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
HydrateFallback: () => <div>Loading...</div>,
|
||||
children: [
|
||||
{
|
||||
Component: ManageOrgWithPortalRoot,
|
||||
path: "/settings/org",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const renderManageOrg = () =>
|
||||
render(<RouteStub initialEntries={["/settings/org"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1062
frontend/__tests__/routes/manage-organization-members.test.tsx
Normal file
1062
frontend/__tests__/routes/manage-organization-members.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
10
frontend/__tests__/routes/mcp-settings.test.tsx
Normal file
10
frontend/__tests__/routes/mcp-settings.test.tsx
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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> = {},
|
||||
): 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<OrganizationMember>) => {
|
||||
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: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/secrets"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/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(<RoutesStub initialEntries={["/settings/billing"]} />);
|
||||
render(<RoutesStub initialEntries={["/settings"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -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> = {},
|
||||
): 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<OrganizationMember>) => {
|
||||
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: () => <div data-testid="llm-settings-screen" />,
|
||||
path: "/settings",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/integrations",
|
||||
@@ -84,6 +114,15 @@ describe("Settings Screen", () => {
|
||||
Component: () => <div data-testid="api-keys-settings-screen" />,
|
||||
path: "/settings/api-keys",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="org-members-settings-screen" />,
|
||||
path: "/settings/org-members",
|
||||
handle: { hideTitle: true },
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="organization-settings-screen" />,
|
||||
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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
50
frontend/__tests__/utils/billing-visibility.test.ts
Normal file
50
frontend/__tests__/utils/billing-visibility.test.ts
Normal file
@@ -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["feature_flags"]> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
172
frontend/__tests__/utils/input-validation.test.ts
Normal file
172
frontend/__tests__/utils/input-validation.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
79
frontend/__tests__/utils/permission-checks.test.ts
Normal file
79
frontend/__tests__/utils/permission-checks.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
175
frontend/__tests__/utils/permission-guard.test.ts
Normal file
175
frontend/__tests__/utils/permission-guard.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<OrganizationMember>(
|
||||
`/api/organizations/${orgId}/me`,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
getOrganization: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.get<Organization>(
|
||||
`/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<Organization>(
|
||||
`/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<OrganizationMembersPage>(
|
||||
`/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<number>(
|
||||
`/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<OrganizationMember[]>(
|
||||
`/api/organizations/${orgId}/members/invite`,
|
||||
{
|
||||
emails,
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
switchOrganization: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.post<Organization>(
|
||||
`/api/organizations/${orgId}/switch`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -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<HTMLUListElement>(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<SVGSVGElement>),
|
||||
}));
|
||||
const handleNavigationClick = () => onClose();
|
||||
|
||||
const handleAddTeamMembers = () => {
|
||||
trackAddTeamMembersButtonClick();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
testId="account-settings-context-menu"
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
className="mt-0 md:right-full md:left-full md:bottom-0 ml-0 w-fit z-[9999]"
|
||||
>
|
||||
{showAddTeamMembers && (
|
||||
<ContextMenuListItem
|
||||
testId="add-team-members-button"
|
||||
onClick={handleAddTeamMembers}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
<PlusIcon width={16} height={16} />
|
||||
<span className="text-white text-sm">
|
||||
{t(I18nKey.SETTINGS$NAV_ADD_TEAM_MEMBERS)}
|
||||
</span>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{navItems.map(({ to, text, icon }) => (
|
||||
<Link key={to} to={to} className="text-decoration-none">
|
||||
<ContextMenuListItem
|
||||
onClick={handleNavigationClick}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
{icon}
|
||||
<span className="text-white text-sm">{t(text)}</span>
|
||||
</ContextMenuListItem>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
<a
|
||||
href="https://docs.openhands.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-decoration-none"
|
||||
>
|
||||
<ContextMenuListItem
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
<DocumentIcon width={16} height={16} />
|
||||
<span className="text-white text-sm">{t(I18nKey.SIDEBAR$DOCS)}</span>
|
||||
</ContextMenuListItem>
|
||||
</a>
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={onLogout}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
<LogOutIcon width={16} height={16} />
|
||||
<span className="text-white text-sm">
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</span>
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -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<string>("");
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (orgName?.trim()) {
|
||||
updateOrganization(orgName, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OrgModal
|
||||
testId="update-org-name-form"
|
||||
title={t(I18nKey.ORG$CHANGE_ORG_NAME)}
|
||||
description={t(I18nKey.ORG$MODIFY_ORG_NAME_DESCRIPTION)}
|
||||
primaryButtonText={t(I18nKey.BUTTON$SAVE)}
|
||||
onPrimaryClick={handleSubmit}
|
||||
onClose={onClose}
|
||||
isLoading={isPending}
|
||||
>
|
||||
<input
|
||||
data-testid="org-name"
|
||||
value={orgName}
|
||||
placeholder={t(I18nKey.ORG$ENTER_NEW_ORGANIZATION_NAME)}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
className="bg-tertiary border border-[#717888] h-10 w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt"
|
||||
/>
|
||||
</OrgModal>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<Trans
|
||||
i18nKey={I18nKey.ORG$REMOVE_MEMBER_WARNING}
|
||||
values={{ email: memberEmail }}
|
||||
components={{ email: <span className="text-white" /> }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<OrgModal
|
||||
title={t(I18nKey.ORG$CONFIRM_REMOVE_MEMBER)}
|
||||
description={confirmationMessage}
|
||||
primaryButtonText={t(I18nKey.BUTTON$CONFIRM)}
|
||||
secondaryButtonText={t(I18nKey.BUTTON$CANCEL)}
|
||||
onPrimaryClick={onConfirm}
|
||||
onClose={onCancel}
|
||||
isLoading={isLoading}
|
||||
primaryButtonTestId="confirm-button"
|
||||
secondaryButtonTestId="cancel-button"
|
||||
fullWidthButtons
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<Trans
|
||||
i18nKey={I18nKey.ORG$UPDATE_ROLE_WARNING}
|
||||
values={{ email: memberEmail, role: newRole }}
|
||||
components={{
|
||||
email: <span className="text-white" />,
|
||||
role: <span className="text-white capitalize" />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<OrgModal
|
||||
title={t(I18nKey.ORG$CONFIRM_UPDATE_ROLE)}
|
||||
description={confirmationMessage}
|
||||
primaryButtonText={t(I18nKey.BUTTON$CONFIRM)}
|
||||
onPrimaryClick={onConfirm}
|
||||
onClose={onCancel}
|
||||
isLoading={isLoading}
|
||||
primaryButtonTestId="confirm-button"
|
||||
secondaryButtonTestId="cancel-button"
|
||||
fullWidthButtons
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<Trans
|
||||
i18nKey={I18nKey.ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME}
|
||||
values={{ name: organization.name }}
|
||||
components={{ name: <span className="text-white" /> }}
|
||||
/>
|
||||
) : (
|
||||
t(I18nKey.ORG$DELETE_ORGANIZATION_WARNING)
|
||||
);
|
||||
|
||||
return (
|
||||
<OrgModal
|
||||
testId="delete-org-confirmation"
|
||||
title={t(I18nKey.ORG$DELETE_ORGANIZATION)}
|
||||
description={confirmationMessage}
|
||||
primaryButtonText={t(I18nKey.BUTTON$CONFIRM)}
|
||||
onPrimaryClick={handleConfirm}
|
||||
onClose={onClose}
|
||||
isLoading={isPending}
|
||||
secondaryButtonTestId="cancel-button"
|
||||
ariaLabel={t(I18nKey.ORG$DELETE_ORGANIZATION)}
|
||||
fullWidthButtons
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function InviteOrganizationMemberModal({
|
||||
onClose,
|
||||
}: InviteOrganizationMemberModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: inviteMembers, isPending } = useInviteMembersBatch();
|
||||
const [emails, setEmails] = React.useState<string[]>([]);
|
||||
|
||||
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 (
|
||||
<OrgModal
|
||||
testId="invite-modal"
|
||||
title={t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
|
||||
description={t(I18nKey.ORG$INVITE_USERS_DESCRIPTION)}
|
||||
primaryButtonText={t(I18nKey.BUTTON$ADD)}
|
||||
onPrimaryClick={handleSubmit}
|
||||
onClose={onClose}
|
||||
isLoading={isPending}
|
||||
>
|
||||
<BadgeInput
|
||||
name="emails-badge-input"
|
||||
value={emails}
|
||||
placeholder={t(I18nKey.COMMON$TYPE_EMAIL_AND_PRESS_SPACE)}
|
||||
onChange={handleEmailsChange}
|
||||
/>
|
||||
</OrgModal>
|
||||
);
|
||||
}
|
||||
61
frontend/src/components/features/org/org-selector.tsx
Normal file
61
frontend/src/components/features/org/org-selector.tsx
Normal file
@@ -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 (
|
||||
<Dropdown
|
||||
testId="org-selector"
|
||||
key={`${selectedOrg?.id}-${selectedOrg?.name}`}
|
||||
defaultValue={{
|
||||
label: selectedOrg ? getOrgDisplayName(selectedOrg) : "",
|
||||
value: selectedOrg?.id || "",
|
||||
}}
|
||||
onChange={(item) => {
|
||||
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),
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLSpanElement>) => {
|
||||
if (roleSelectionIsPermitted) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold leading-6",
|
||||
status === "invited" && "text-gray-400",
|
||||
)}
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
|
||||
{status === "invited" && (
|
||||
<span className="text-xs text-tertiary-light border border-tertiary px-2 py-1 rounded-lg">
|
||||
{t(I18nKey.ORG$STATUS_INVITED)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<span
|
||||
onClick={handleRoleClick}
|
||||
className={cn(
|
||||
"text-xs font-normal leading-4 text-org-text flex items-center gap-1 capitalize",
|
||||
roleSelectionIsPermitted ? "cursor-pointer" : "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
{hasPermissionToChangeRole && <ChevronDown size={14} />}
|
||||
</span>
|
||||
|
||||
{roleSelectionIsPermitted && contextMenuOpen && (
|
||||
<OrganizationMemberRoleContextMenu
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
onRoleChange={onRoleChange}
|
||||
onRemove={onRemove}
|
||||
availableRolesToChangeTo={availableRolesToChangeTo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLUListElement>(onClose);
|
||||
|
||||
const handleRoleChangeClick = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
role: OrganizationUserRole,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRoleChange(role);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRemoveClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRemove?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
testId="organization-member-role-context-menu"
|
||||
position="bottom"
|
||||
alignment="right"
|
||||
className="min-h-fit mb-2 min-w-[195px] max-w-[195px] gap-0"
|
||||
>
|
||||
{availableRolesToChangeTo.includes("owner") && (
|
||||
<ContextMenuListItem
|
||||
testId="owner-option"
|
||||
onClick={(event) => handleRoleChangeClick(event, "owner")}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={
|
||||
<AdminIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-white pl-[2px]"
|
||||
/>
|
||||
}
|
||||
text={t(I18nKey.ORG$ROLE_OWNER)}
|
||||
className="capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{availableRolesToChangeTo.includes("admin") && (
|
||||
<ContextMenuListItem
|
||||
testId="admin-option"
|
||||
onClick={(event) => handleRoleChangeClick(event, "admin")}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={
|
||||
<AdminIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-white pl-[2px]"
|
||||
/>
|
||||
}
|
||||
text={t(I18nKey.ORG$ROLE_ADMIN)}
|
||||
className="capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{availableRolesToChangeTo.includes("member") && (
|
||||
<ContextMenuListItem
|
||||
testId="member-option"
|
||||
onClick={(event) => handleRoleChangeClick(event, "member")}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={<UserIcon width={16} height={16} className="text-white" />}
|
||||
text={t(I18nKey.ORG$ROLE_MEMBER)}
|
||||
className="capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
<ContextMenuListItem
|
||||
testId="remove-option"
|
||||
onClick={handleRemoveClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={<DeleteIcon width={16} height={16} className="text-red-500" />}
|
||||
text={t(I18nKey.ORG$REMOVE)}
|
||||
className="text-red-500 capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<div className="flex items-center w-[680px] gap-2">
|
||||
<BrandButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isDisabled={isPending || buttonIsDisabled}
|
||||
isDisabled={isPending || buttonIsDisabled || isDisabled}
|
||||
>
|
||||
{t(I18nKey.PAYMENT$ADD_CREDIT)}
|
||||
</BrandButton>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SetupPaymentModal() {
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
isDisabled={isPending}
|
||||
onClick={mutate}
|
||||
onClick={() => mutate()}
|
||||
>
|
||||
{t(I18nKey.BILLING$PROCEED_TO_STRIPE)}
|
||||
</BrandButton>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface BrandButtonProps {
|
||||
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onClick?: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
startContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCloseMobileMenu}
|
||||
className="md:hidden p-0.5 hover:bg-[#454545] rounded-md transition-colors cursor-pointer"
|
||||
className="md:hidden p-0.5 hover:bg-tertiary rounded-md transition-colors cursor-pointer"
|
||||
aria-label="Close navigation menu"
|
||||
>
|
||||
<CloseIcon width={32} height={32} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!shouldHideSelector && <OrgSelector />}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{navigationItems.map(({ to, icon, text }) => (
|
||||
<NavLink
|
||||
@@ -66,14 +71,21 @@ export function SettingsNavigation({
|
||||
onClick={onCloseMobileMenu}
|
||||
className={({ isActive }) =>
|
||||
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}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Typography.Text className="text-[#A3A3A3] whitespace-nowrap">
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"block truncate whitespace-nowrap text-modal-muted transition-all duration-300",
|
||||
"group-hover:translate-x-1 group-hover:text-white",
|
||||
)}
|
||||
>
|
||||
{t(text as I18nKey)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid="user-actions"
|
||||
className="w-8 h-8 relative cursor-pointer group"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={user?.avatar_url}
|
||||
onClick={toggleAccountMenu}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
data-testid="user-actions"
|
||||
className="relative cursor-pointer group"
|
||||
onMouseEnter={showAccountMenu}
|
||||
onMouseLeave={hideAccountMenu}
|
||||
>
|
||||
<UserAvatar avatarUrl={user?.avatar_url} isLoading={isLoading} />
|
||||
|
||||
{(shouldShowUserActions || isOSS) && (
|
||||
<div
|
||||
className={cn(
|
||||
"opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
|
||||
showMenu && "opacity-100 pointer-events-auto",
|
||||
// Invisible hover bridge: extends hover zone to create a "safe corridor"
|
||||
// for diagonal mouse movement to the menu (only active when menu is visible)
|
||||
"group-hover:before:content-[''] group-hover:before:block group-hover:before:absolute group-hover:before:inset-[-320px] group-hover:before:z-9998",
|
||||
)}
|
||||
>
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={handleLogout}
|
||||
onClose={closeAccountMenu}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowUserActions && user && (
|
||||
<div
|
||||
className={cn(
|
||||
"opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
|
||||
accountContextMenuIsVisible && "opacity-100 pointer-events-auto",
|
||||
// Invisible hover bridge: extends hover zone to create a "safe corridor"
|
||||
// for diagonal mouse movement to the menu (only active when menu is visible)
|
||||
"group-hover:before:content-[''] group-hover:before:block group-hover:before:absolute group-hover:before:inset-[-320px] group-hover:before:z-50 before:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<UserContextMenu
|
||||
key={menuResetCount}
|
||||
type={me?.role ?? "member"}
|
||||
onClose={closeAccountMenu}
|
||||
onOpenInviteModal={openInviteMemberModal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inviteMemberModalIsOpen &&
|
||||
ReactDOM.createPortal(
|
||||
<InviteOrganizationMemberModal
|
||||
onClose={() => setInviteMemberModalIsOpen(false)}
|
||||
/>,
|
||||
document.getElementById("portal-root") || document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && <Avatar src={avatarUrl} />}
|
||||
{!isLoading && !avatarUrl && (
|
||||
|
||||
168
frontend/src/components/features/user/user-context-menu.tsx
Normal file
168
frontend/src/components/features/user/user-context-menu.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
data-testid="user-context-menu"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-72 flex flex-col gap-3 bg-tertiary border border-tertiary rounded-xl p-4 context-menu-box-shadow",
|
||||
"text-sm absolute left-full bottom-0 z-101",
|
||||
)}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{t(I18nKey.ORG$ACCOUNT)}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{!shouldHideSelector && (
|
||||
<div className="w-full relative">
|
||||
<OrgSelector />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMember && !isPersonalOrg && (
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<ContextMenuListItem
|
||||
onClick={handleInviteMemberClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoPersonAddOutline className="text-white" size={14} />
|
||||
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageAccountClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoCardOutline className="text-white" size={14} />
|
||||
{t(I18nKey.COMMON$ORGANIZATION)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageOrganizationMembersClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<FiUsers className="text-white shrink-0" size={14} />
|
||||
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
<Divider className="my-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
|
||||
>
|
||||
{React.cloneElement(item.icon, {
|
||||
className: "text-white",
|
||||
width: 14,
|
||||
height: 14,
|
||||
} as React.SVGProps<SVGSVGElement>)}
|
||||
{t(item.text)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<a
|
||||
href="https://docs.openhands.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
|
||||
>
|
||||
<DocumentIcon className="text-white" width={14} height={14} />
|
||||
{t(I18nKey.SIDEBAR$DOCS)}
|
||||
</a>
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleLogout}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoLogOutOutline className="text-white" size={14} />
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div data-testid="loading-spinner" className={cn("relative", sizeStyle)}>
|
||||
<div
|
||||
data-testid="loading-spinner"
|
||||
className={cn("relative", sizeStyle, className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full border-4 border-[#525252] absolute",
|
||||
sizeStyle,
|
||||
innerClassName,
|
||||
)}
|
||||
/>
|
||||
<LoadingSpinnerOuter className={cn("absolute animate-spin", sizeStyle)} />
|
||||
<LoadingSpinnerOuter
|
||||
className={cn("absolute animate-spin", sizeStyle, outerClassName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function BaseModalDescription({
|
||||
children,
|
||||
}: BaseModalDescriptionProps) {
|
||||
return (
|
||||
<span className="text-xs text-[#A3A3A3]">{children || description}</span>
|
||||
<span className="text-xs text-modal-muted">{children || description}</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-60">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed inset-0 flex items-center justify-center z-60"
|
||||
>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="fixed inset-0 bg-black opacity-60"
|
||||
|
||||
70
frontend/src/components/shared/modals/modal-button-group.tsx
Normal file
70
frontend/src/components/shared/modals/modal-button-group.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ModalButtonGroupProps {
|
||||
primaryText: string;
|
||||
secondaryText?: string;
|
||||
onPrimaryClick?: () => 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 (
|
||||
<div className="flex gap-2 w-full">
|
||||
<BrandButton
|
||||
type={primaryType}
|
||||
variant="primary"
|
||||
onClick={onPrimaryClick}
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
fullWidth ? "w-full" : "grow",
|
||||
)}
|
||||
testId={primaryTestId}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingSpinner
|
||||
size="small"
|
||||
className="w-5 h-5"
|
||||
innerClassName="hidden"
|
||||
outerClassName="w-5 h-5"
|
||||
/>
|
||||
) : (
|
||||
primaryText
|
||||
)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onSecondaryClick}
|
||||
className={cn(fullWidth ? "w-full" : "grow")}
|
||||
testId={secondaryTestId}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{closeText}
|
||||
</BrandButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/shared/modals/org-modal.tsx
Normal file
90
frontend/src/components/shared/modals/org-modal.tsx
Normal file
@@ -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 = (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<h3 className="text-xl font-bold">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-xs text-modal-muted">{description}</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<ModalButtonGroup
|
||||
primaryText={primaryButtonText}
|
||||
secondaryText={secondaryButtonText}
|
||||
onPrimaryClick={onPrimaryClick}
|
||||
onSecondaryClick={onClose}
|
||||
isLoading={isLoading}
|
||||
primaryType={primaryButtonType}
|
||||
primaryTestId={primaryButtonTestId}
|
||||
secondaryTestId={secondaryButtonTestId}
|
||||
fullWidth={fullWidthButtons}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const modalBodyClassName =
|
||||
"items-start rounded-xl p-6 w-sm flex flex-col gap-4 bg-base-secondary border border-tertiary";
|
||||
|
||||
return (
|
||||
<ModalBackdrop
|
||||
onClose={isLoading ? undefined : onClose}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{asForm ? (
|
||||
<form
|
||||
data-testid={testId}
|
||||
action={formAction}
|
||||
noValidate
|
||||
className={modalBodyClassName}
|
||||
>
|
||||
{content}
|
||||
</form>
|
||||
) : (
|
||||
<ModalBody testID={testId} className={modalBodyClassName}>
|
||||
{content}
|
||||
</ModalBody>
|
||||
)}
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<UserAssistantEventMessage
|
||||
event={messageEvent}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FiUsers, FiBriefcase } from "react-icons/fi";
|
||||
import CreditCardIcon from "#/icons/credit-card.svg?react";
|
||||
import KeyIcon from "#/icons/key.svg?react";
|
||||
import ServerProcessIcon from "#/icons/server-process.svg?react";
|
||||
@@ -53,6 +54,16 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
to: "/settings/mcp",
|
||||
text: "SETTINGS$NAV_MCP",
|
||||
},
|
||||
{
|
||||
to: "/settings/org-members",
|
||||
text: "Organization Members",
|
||||
icon: <FiUsers size={22} />,
|
||||
},
|
||||
{
|
||||
to: "/settings/org",
|
||||
text: "Organization",
|
||||
icon: <FiBriefcase size={22} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const OSS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
|
||||
28
frontend/src/context/use-selected-organization.ts
Normal file
28
frontend/src/context/use-selected-organization.ts
Normal file
@@ -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 };
|
||||
};
|
||||
36
frontend/src/hooks/mutation/use-delete-organization.ts
Normal file
36
frontend/src/hooks/mutation/use-delete-organization.ts
Normal file
@@ -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("/");
|
||||
},
|
||||
});
|
||||
};
|
||||
38
frontend/src/hooks/mutation/use-invite-members-batch.ts
Normal file
38
frontend/src/hooks/mutation/use-invite-members-batch.ts
Normal file
@@ -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));
|
||||
},
|
||||
});
|
||||
};
|
||||
38
frontend/src/hooks/mutation/use-remove-member.ts
Normal file
38
frontend/src/hooks/mutation/use-remove-member.ts
Normal file
@@ -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));
|
||||
},
|
||||
});
|
||||
};
|
||||
36
frontend/src/hooks/mutation/use-switch-organization.ts
Normal file
36
frontend/src/hooks/mutation/use-switch-organization.ts
Normal file
@@ -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("/");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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 ||
|
||||
|
||||
46
frontend/src/hooks/mutation/use-update-member-role.ts
Normal file
46
frontend/src/hooks/mutation/use-update-member-role.ts
Normal file
@@ -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));
|
||||
},
|
||||
});
|
||||
};
|
||||
28
frontend/src/hooks/mutation/use-update-organization.ts
Normal file
28
frontend/src/hooks/mutation/use-update-organization.ts
Normal file
@@ -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"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
17
frontend/src/hooks/organizations/use-permissions.ts
Normal file
17
frontend/src/hooks/organizations/use-permissions.ts
Normal file
@@ -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<PermissionKey[]>(
|
||||
() => rolePermissions[role],
|
||||
[role],
|
||||
);
|
||||
|
||||
/* Check if the user has a specific permission */
|
||||
const hasPermission = (permission: PermissionKey): boolean =>
|
||||
currentPermissions.includes(permission);
|
||||
|
||||
return { hasPermission };
|
||||
};
|
||||
18
frontend/src/hooks/query/use-me.ts
Normal file
18
frontend/src/hooks/query/use-me.ts
Normal file
@@ -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,
|
||||
});
|
||||
};
|
||||
24
frontend/src/hooks/query/use-organization-members-count.ts
Normal file
24
frontend/src/hooks/query/use-organization-members-count.ts
Normal file
@@ -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,
|
||||
});
|
||||
};
|
||||
30
frontend/src/hooks/query/use-organization-members.ts
Normal file
30
frontend/src/hooks/query/use-organization-members.ts
Normal file
@@ -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,
|
||||
});
|
||||
};
|
||||
16
frontend/src/hooks/query/use-organization-payment-info.tsx
Normal file
16
frontend/src/hooks/query/use-organization-payment-info.tsx
Normal file
@@ -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,
|
||||
});
|
||||
};
|
||||
14
frontend/src/hooks/query/use-organization.ts
Normal file
14
frontend/src/hooks/query/use-organization.ts
Normal file
@@ -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,
|
||||
});
|
||||
};
|
||||
32
frontend/src/hooks/query/use-organizations.ts
Normal file
32
frontend/src/hooks/query/use-organizations.ts
Normal file
@@ -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,
|
||||
}),
|
||||
});
|
||||
};
|
||||
33
frontend/src/hooks/use-auto-select-organization.ts
Normal file
33
frontend/src/hooks/use-auto-select-organization.ts
Normal file
@@ -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]);
|
||||
}
|
||||
22
frontend/src/hooks/use-org-type-and-access.ts
Normal file
22
frontend/src/hooks/use-org-type-and-access.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
frontend/src/hooks/use-should-hide-org-selector.ts
Normal file
16
frontend/src/hooks/use-should-hide-org-selector.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>{{email}}</email> from this organization? This action cannot be undone.",
|
||||
"ja": "<email>{{email}}</email> をこの組織から削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"zh-CN": "您确定要将 <email>{{email}}</email> 从此组织中移除吗?此操作无法撤消。",
|
||||
"zh-TW": "您確定要將 <email>{{email}}</email> 從此組織中移除嗎?此操作無法撤消。",
|
||||
"ko-KR": "이 조직에서 <email>{{email}}</email>을(를) 제거하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"no": "Er du sikker på at du vil fjerne <email>{{email}}</email> fra denne organisasjonen? Denne handlingen kan ikke angres.",
|
||||
"it": "Sei sicuro di voler rimuovere <email>{{email}}</email> da questa organizzazione? Questa azione non può essere annullata.",
|
||||
"pt": "Tem certeza de que deseja remover <email>{{email}}</email> desta organização? Esta ação não pode ser desfeita.",
|
||||
"es": "¿Está seguro de que desea eliminar a <email>{{email}}</email> de esta organización? Esta acción no se puede deshacer.",
|
||||
"ar": "هل أنت متأكد من أنك تريد إزالة <email>{{email}}</email> من هذه المنظمة؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"fr": "Êtes-vous sûr de vouloir supprimer <email>{{email}}</email> de cette organisation ? Cette action ne peut pas être annulée.",
|
||||
"tr": "<email>{{email}}</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>{{email}}</email> aus dieser Organisation entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"uk": "Ви впевнені, що хочете видалити <email>{{email}}</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>{{email}}</email> to <role>{{role}}</role>?",
|
||||
"ja": "<email>{{email}}</email> の役割を <role>{{role}}</role> に変更してもよろしいですか?",
|
||||
"zh-CN": "您确定要将 <email>{{email}}</email> 的角色更改为 <role>{{role}}</role> 吗?",
|
||||
"zh-TW": "您確定要將 <email>{{email}}</email> 的角色更改為 <role>{{role}}</role> 嗎?",
|
||||
"ko-KR": "<email>{{email}}</email>의 역할을 <role>{{role}}</role>(으)로 변경하시겠습니까?",
|
||||
"no": "Er du sikker på at du vil endre rollen til <email>{{email}}</email> til <role>{{role}}</role>?",
|
||||
"it": "Sei sicuro di voler cambiare il ruolo di <email>{{email}}</email> in <role>{{role}}</role>?",
|
||||
"pt": "Tem certeza de que deseja alterar a função de <email>{{email}}</email> para <role>{{role}}</role>?",
|
||||
"es": "¿Está seguro de que desea cambiar el rol de <email>{{email}}</email> a <role>{{role}}</role>?",
|
||||
"ar": "هل أنت متأكد من أنك تريد تغيير دور <email>{{email}}</email> إلى <role>{{role}}</role>؟",
|
||||
"fr": "Êtes-vous sûr de vouloir changer le rôle de <email>{{email}}</email> en <role>{{role}}</role> ?",
|
||||
"tr": "<email>{{email}}</email> kullanıcısının rolünü <role>{{role}}</role> olarak değiştirmek istediğinizden emin misiniz?",
|
||||
"de": "Sind Sie sicher, dass Sie die Rolle von <email>{{email}}</email> auf <role>{{role}}</role> ändern möchten?",
|
||||
"uk": "Ви впевнені, що хочете змінити роль <email>{{email}}</email> на <role>{{role}}</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>{{name}}</name>\" organization? This action cannot be undone.",
|
||||
"ja": "「<name>{{name}}</name>」組織を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"zh-CN": "您确定要删除\"<name>{{name}}</name>\"组织吗?此操作无法撤消。",
|
||||
"zh-TW": "您確定要刪除「<name>{{name}}</name>」組織嗎?此操作無法撤銷。",
|
||||
"ko-KR": "\"<name>{{name}}</name>\" 조직을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"no": "Er du sikker på at du vil slette organisasjonen \"<name>{{name}}</name>\"? Denne handlingen kan ikke angres.",
|
||||
"it": "Sei sicuro di voler eliminare l'organizzazione \"<name>{{name}}</name>\"? Questa azione non può essere annullata.",
|
||||
"pt": "Tem certeza de que deseja excluir a organização \"<name>{{name}}</name>\"? Esta ação não pode ser desfeita.",
|
||||
"es": "¿Está seguro de que desea eliminar la organización \"<name>{{name}}</name>\"? Esta acción no se puede deshacer.",
|
||||
"ar": "هل أنت متأكد من أنك تريد حذف المنظمة \"<name>{{name}}</name>\"؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"fr": "Êtes-vous sûr de vouloir supprimer l'organisation \\u00AB <name>{{name}}</name> \\u00BB ? Cette action ne peut pas être annulée.",
|
||||
"tr": "\"<name>{{name}}</name>\" organizasyonunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"de": "Sind Sie sicher, dass Sie die Organisation \\u201E<name>{{name}}</name>\\u201C löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"uk": "Ви впевнені, що хочете видалити організацію \\u00AB<name>{{name}}</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": "あなたの役割は?",
|
||||
|
||||
3
frontend/src/icons/admin.svg
Normal file
3
frontend/src/icons/admin.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 13 12" fill="none">
|
||||
<path d="M5.93266 9.60059H2.99809C2.0034 9.60059 1.19828 10.4057 1.19828 11.4004C1.19815 11.7727 0.860332 12.0653 0.473671 11.9873C0.187333 11.9299 -0.00314397 11.6658 3.8147e-05 11.373C0.0144066 9.72907 1.35287 8.40039 3.00004 8.40039H4.34476L5.93266 9.60059ZM8.99027 6.50586C9.0747 6.2466 9.44203 6.24656 9.52641 6.50586H9.52445L10.0977 8.26758H11.9502C12.2224 8.26758 12.3365 8.6166 12.1153 8.77734L10.6162 9.86523L11.1895 11.627C11.2738 11.8863 10.9765 12.103 10.7569 11.9424L9.25785 10.8535L7.75785 11.9424C7.53665 12.1028 7.24091 11.8863 7.32523 11.627L7.89848 9.86523L6.39945 8.77734C6.17827 8.61663 6.29243 8.26768 6.56449 8.26758H8.41703L8.99027 6.50586ZM5.39945 0C7.38708 0 8.99979 1.61204 9.00004 3.59961C9.00004 5.58739 7.38723 7.2002 5.39945 7.2002C3.41175 7.20011 1.79984 5.58734 1.79984 3.59961C1.80009 1.61209 3.4119 8.31798e-05 5.39945 0ZM5.39945 1.2002C4.07396 1.20028 3.00029 2.27416 3.00004 3.59961C3.00004 4.92527 4.07381 5.99992 5.39945 6C6.72517 6 7.79984 4.92532 7.79984 3.59961C7.79959 2.27411 6.72501 1.2002 5.39945 1.2002Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg width="66" height="66" viewBox="0 0 66 66" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63 33C63 16.4315 49.5685 3 33 3C16.4315 3 3 16.4315 3 33C3 49.5685 16.4315 63 33 63"
|
||||
stroke="#007AFF" stroke-width="6" stroke-linecap="round" />
|
||||
stroke="currentColor" stroke-width="6" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 264 B After Width: | Height: | Size: 269 B |
@@ -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,
|
||||
|
||||
556
frontend/src/mocks/org-handlers.ts
Normal file
556
frontend/src/mocks/org-handlers.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import {
|
||||
Organization,
|
||||
OrganizationMember,
|
||||
OrganizationUserRole,
|
||||
UpdateOrganizationMemberParams,
|
||||
} from "#/types/org";
|
||||
|
||||
const MOCK_ME: Omit<OrganizationMember, "role" | "org_id"> = {
|
||||
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<string, OrganizationMember[]> = {
|
||||
"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<string, OrganizationMember[]> = {
|
||||
"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 });
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -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> = {},
|
||||
): 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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<WebClientConfig>(["web-client-config"]);
|
||||
if (!config) {
|
||||
config = await OptionService.getConfig();
|
||||
queryClient.setQueryData<WebClientConfig>(["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 <PaymentForm />;
|
||||
return <PaymentForm isDisabled={!canAddCredits} />;
|
||||
}
|
||||
|
||||
export default BillingSettingsScreen;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)}
|
||||
</SettingsSwitch>
|
||||
@@ -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 ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
isDisabled={isReadOnly}
|
||||
startContent={
|
||||
settings.llm_api_key_set && (
|
||||
<KeyStatusIcon isSet={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 ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
isDisabled={isReadOnly}
|
||||
startContent={
|
||||
settings.llm_api_key_set && (
|
||||
<KeyStatusIcon isSet={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"
|
||||
/>
|
||||
<p className="text-xs text-tertiary-alt mt-6">
|
||||
@@ -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)}
|
||||
</SettingsSwitch>
|
||||
@@ -691,6 +709,7 @@ function LlmSettingsScreen() {
|
||||
onToggle={handleConfirmationModeIsDirty}
|
||||
defaultIsToggled={settings.confirmation_mode}
|
||||
isBeta
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
|
||||
</SettingsSwitch>
|
||||
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!formIsDirty || isPending}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className="flex gap-6 p-6 justify-end">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!formIsDirty || isPending}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
219
frontend/src/routes/manage-org.tsx
Normal file
219
frontend/src/routes/manage-org.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<form
|
||||
data-testid="add-credits-form"
|
||||
action={formAction}
|
||||
noValidate
|
||||
className="w-sm rounded-xl bg-base-secondary flex flex-col p-6 gap-4 border border-tertiary"
|
||||
>
|
||||
<h3 className="text-xl font-bold">{t(I18nKey.ORG$ADD_CREDITS)}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SettingsInput
|
||||
testId="amount-input"
|
||||
name="amount"
|
||||
label={t(I18nKey.PAYMENT$SPECIFY_AMOUNT_USD)}
|
||||
type="number"
|
||||
min={10}
|
||||
max={25000}
|
||||
step={1}
|
||||
value={inputValue}
|
||||
onChange={(value) => handleAmountInputChange(value)}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm mt-1" data-testid="amount-error">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalButtonGroup
|
||||
primaryText={t(I18nKey.ORG$NEXT)}
|
||||
onSecondaryClick={onClose}
|
||||
primaryType="submit"
|
||||
/>
|
||||
</form>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-testid="manage-org-screen"
|
||||
className="flex flex-col items-start gap-6"
|
||||
>
|
||||
{changeOrgNameFormVisible && (
|
||||
<ChangeOrgNameModal
|
||||
onClose={() => setChangeOrgNameFormVisible(false)}
|
||||
/>
|
||||
)}
|
||||
{deleteOrgConfirmationVisible && (
|
||||
<DeleteOrgConfirmationModal
|
||||
onClose={() => setDeleteOrgConfirmationVisible(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!shouldHideBilling && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-white text-xs font-semibold">
|
||||
{t(I18nKey.ORG$CREDITS)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditsChip testId="available-credits">
|
||||
${Number(balance ?? 0).toFixed(2)}
|
||||
</CreditsChip>
|
||||
{canAddCredits && (
|
||||
<InteractiveChip onClick={() => setAddCreditsFormVisible(true)}>
|
||||
{t(I18nKey.ORG$ADD)}
|
||||
</InteractiveChip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addCreditsFormVisible && !shouldHideBilling && (
|
||||
<AddCreditsModal onClose={() => setAddCreditsFormVisible(false)} />
|
||||
)}
|
||||
|
||||
<div data-testid="org-name" className="flex flex-col gap-2 w-sm">
|
||||
<span className="text-white text-xs font-semibold">
|
||||
{t(I18nKey.ORG$ORGANIZATION_NAME)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm p-3 bg-modal-input rounded",
|
||||
"flex items-center justify-between",
|
||||
)}
|
||||
>
|
||||
<span className="text-white">{organization?.name}</span>
|
||||
{canChangeOrgName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChangeOrgNameFormVisible(true)}
|
||||
className="text-sm text-org-text font-normal leading-5 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
{t(I18nKey.ORG$CHANGE)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canDeleteOrg && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOrgConfirmationVisible(true)}
|
||||
className="text-xs text-[#FF3B30] cursor-pointer font-semibold hover:underline"
|
||||
>
|
||||
{t(I18nKey.ORG$DELETE_ORGANIZATION)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageOrg;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user