Compare commits

...

92 Commits

Author SHA1 Message Date
Hyun Han
f78bdafc7e refactor(frontend): migrate selected org ID to Zustand (#12153) 2025-12-26 18:23:43 +04:00
Nhan Nguyen
89a9e73c8a refactor(frontend): replace Temp components with design system components (#12158) 2025-12-25 22:53:55 +04:00
Hyun Han
a51b285021 refactor(frontend): rename updateMemberRole to updateMember with extensible params (#12151) 2025-12-25 19:38:23 +04:00
amanape
d1a9731534 Merge remote-tracking branch 'origin/main' into ALL-2596/org-support 2025-12-23 17:55:14 +04:00
Hiep Le
5362391045 refactor(frontend): update organizationmember type (org support) (#12054) 2025-12-18 22:00:15 +07:00
Hiep Le
ef58494544 feat(frontend): hide billing section in organization settings page for self-hosted (org support) (#12019) 2025-12-18 21:18:59 +07:00
Hiep Le
87470f4cc6 refactor(frontend): update organization type to match actual return type (org support) (#12061) 2025-12-18 20:18:18 +07:00
Hiep Le
03acf76af4 refactor(frontend): update invitemembers endpoint to match actual and expected (org support) (#12062) 2025-12-18 20:17:50 +07:00
Abhay Mishra
37aa4ab9d6 refactor(frontend): Extracted useQuery and useMutation from the ALL-2596/org-support (#12032) 2025-12-15 20:20:06 +04:00
Hiep Le
33f3861d95 refactor(frontend): rename settings organization members route to settings org members for consistency (#11989) 2025-12-10 21:49:45 +07:00
Hiep Le
4b6288a5ac refactor(frontend): ensure proper validation in organization add credits modal (org support) (#11917) 2025-12-10 01:52:24 +07:00
sp.wack
fc3dd517df Merge branch 'main' into ALL-2596/org-support 2025-12-09 22:38:38 +04:00
Hiep Le
464f8ad56d refactor(frontend): update admin and role permissions (#11900) 2025-12-08 23:17:38 +07:00
Hiep Le
1678993235 refactor(frontend): update role dropdown styles in the team page (org support) (#11906)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-12-06 15:57:41 +07:00
amanape
10fcbbcafa Lint 2025-12-05 17:18:46 +04:00
amanape
872e1a362c Fix tests 2025-12-05 16:59:23 +04:00
amanape
6187b8834d Merge remote-tracking branch 'origin/main' into ALL-2596/org-support 2025-12-05 16:43:30 +04:00
Hiep Le
0dbb5b0d15 refactor(frontend): rename superadmin role to owner (org support) (#11896) 2025-12-04 00:59:56 +07:00
Hiep Le
15aefdfb0a refactor(frontend): invite modal buttons (org) (#11894) 2025-12-03 23:17:45 +07:00
Hiep Le
feeda18234 refactor(frontend): rename team to organization members (#11886) 2025-12-03 22:03:13 +07:00
sp.wack
b32c7a65b5 chore(orgs:frontend): Remove WIP org creation feature artifacts (#11891) 2025-12-03 18:27:18 +04:00
sp.wack
05e254a725 feat(frontend): Improve user context menu (#11879) 2025-12-03 17:36:21 +04:00
sp.wack
945cc12d4e chore(frontend): FIx CI in #9496 (#11870) 2025-12-02 19:17:58 +04:00
amanape
48b014f368 Merge branch 'main' into ALL-2596/org-support 2025-12-01 20:21:58 +04:00
amanape
b5cf283deb Merge remote-tracking branch 'origin/main' into ALL-2596/org-support 2025-11-13 19:28:54 +04:00
amanape
77523bd9e8 Merge branch 'main' into ALL-2596/org-support 2025-11-12 18:51:01 +04:00
amanape
967aa40918 cleanup 2025-11-10 17:22:38 +04:00
amanape
b335d61f4e move nav items 2025-11-10 17:00:50 +04:00
amanape
bbabc327e2 Merge branch 'main' into ALL-2596/org-support 2025-11-10 16:57:42 +04:00
amanape
bd6e96958d personal org 2025-07-31 22:26:42 +04:00
amanape
8da45989b6 change tet 2025-07-31 22:10:08 +04:00
amanape
2ca43a026f batch invite users 2025-07-31 21:19:20 +04:00
amanape
87cdfae255 seperator 2025-07-31 20:51:27 +04:00
amanape
8265a5e2d3 rm user 2025-07-31 20:48:45 +04:00
amanape
fb2453e74b auto select org after creating one 2025-07-31 20:28:19 +04:00
amanape
c8e51aa8ae users cant change their own roles 2025-07-31 20:15:27 +04:00
amanape
968566fb64 Merge branch 'main' into ALL-2596/org-support 2025-07-31 19:49:10 +04:00
amanape
5d4e72df5e Merge branch 'main' into ALL-2596/org-support 2025-07-29 22:15:45 +04:00
openhands
5ce558b6a0 Add tests and feat 2025-07-23 22:02:20 +04:00
openhands
e24bd87275 Update organization and team management UI components 2025-07-23 21:06:18 +04:00
openhands
65a165e2e3 style: remove borders from settings dropdown input and menu 2025-07-23 20:30:14 +04:00
openhands
25589d9cac feat: replace SVG icons with react-icons in settings navigation 2025-07-23 19:48:26 +04:00
openhands
30300a9192 style: change active navlink background to match dropdown input style 2025-07-23 19:42:12 +04:00
amanape
5a03b74487 Merge branch 'main' into ALL-2596/org-support 2025-07-23 19:31:34 +04:00
amanape
d68b2913b2 resolve conflicts 2025-07-22 22:53:19 +04:00
amanape
528d0132e7 Merge branch 'main' into ALL-2596/org-support 2025-07-21 18:26:25 +04:00
amanape
13f14e589a tiny fix 2025-07-21 18:24:58 +04:00
amanape
70aee4f940 fix dropdown tests and component consistency 2025-07-21 18:22:51 +04:00
amanape
9d59387fe7 move sidebar 2025-07-18 17:08:03 +04:00
amanape
b280c9236d Merge branch 'main' into ALL-2596/org-support 2025-07-18 16:42:24 +04:00
amanape
e1b39638a8 Merge branch 'main' into ALL-2596/org-support 2025-07-17 20:44:32 +04:00
amanape
5bde31d9bd create org fix 2025-07-17 20:20:47 +04:00
amanape
232932e707 small fix 2025-07-17 18:54:32 +04:00
amanape
7f5f771b2f conditional redirect and nav items 2025-07-17 18:25:06 +04:00
amanape
bfdcd8a35f user org api refactor 2025-07-17 16:49:55 +04:00
amanape
7ae7dd0af2 perms 2025-07-16 19:57:39 +04:00
amanape
6e4539058e style org page 2025-07-16 19:04:21 +04:00
amanape
8f47778237 delete org 2025-07-16 18:12:45 +04:00
amanape
d179c885c9 tests 2025-07-16 16:59:06 +04:00
amanape
d546194787 more refactor 2025-07-16 16:04:14 +04:00
amanape
6a3c90c1f4 some refactors 2025-07-16 15:43:36 +04:00
amanape
98aa68511a Merge branch 'main' into ALL-2596/org-support 2025-07-15 18:16:41 +04:00
amanape
2da07f5c3f Merge branch 'main' into ALL-2596/org-support 2025-07-14 21:33:40 +04:00
amanape
7f92df8664 delete org wip 2025-07-11 21:18:05 +04:00
amanape
7ba2c6d6e1 change name func 2025-07-11 19:33:23 +04:00
amanape
c5d3a9a7bc fix tests 2025-07-11 18:52:06 +04:00
amanape
df8b1d1a1c Merge branch 'main' into ALL-2596/org-support 2025-07-11 18:25:55 +04:00
amanape
7d8a3cc04c invite member, extend api 2025-07-10 22:52:16 +04:00
amanape
5001a06c5d refactor and invite 2025-07-10 19:33:14 +04:00
amanape
0cd1e80ba7 Some styles 2025-07-10 18:08:29 +04:00
amanape
f8e18781c9 load route 2025-07-10 17:10:08 +04:00
amanape
396c5c7d8a basic functionality 2025-07-10 17:06:54 +04:00
amanape
525e840d8d merge 2025-07-10 16:34:33 +04:00
amanape
8074fee5c8 Merge branch 'main' into ALL-2596/org-support 2025-07-09 20:05:28 +04:00
amanape
d08e5fe07c manage teams wip 2025-07-09 18:13:00 +04:00
amanape
c11da628f4 manage org wip 2025-07-08 20:31:27 +04:00
amanape
6d43a3b946 Merge branch 'main' into ALL-2596/org-support 2025-07-08 20:07:04 +04:00
amanape
3c409b9e55 create new tests 2025-07-07 21:45:38 +04:00
amanape
12f9f2f2ca perm upgrades 2025-07-07 21:08:29 +04:00
amanape
a491756a20 improve perms 2025-07-07 20:45:08 +04:00
amanape
fbcc7c650f improve perms 2025-07-07 20:22:40 +04:00
amanape
e77776440b Merge branch 'main' into ALL-2596/org-support 2025-07-07 19:09:16 +04:00
amanape
3a12d71bef manage team settings screen 2025-07-03 21:17:47 +04:00
amanape
4cc4fcf6ca small fix 2025-07-03 19:30:38 +04:00
amanape
fa3d5bbb32 fix mock mode 2025-07-03 19:23:25 +04:00
amanape
2c71e13004 Use new context menu 2025-07-03 19:03:32 +04:00
amanape
bce21c0906 Merge branch 'main' into ALL-2596/org-support 2025-07-03 17:19:05 +04:00
amanape
eee2f796c7 users cannot change other roles 2025-07-02 21:27:25 +04:00
amanape
f0f9fca965 feat - update member role 2025-07-02 21:15:59 +04:00
amanape
d4dcf9f82f Merge branch 'main' into ALL-2596/org-support 2025-07-02 20:05:08 +04:00
amanape
2f77fc542c Manage team page draft 2025-07-01 23:47:37 +04:00
amanape
b2e9167a0e create user context menu 2025-07-01 22:22:30 +04:00
67 changed files with 5087 additions and 677 deletions

View File

@@ -66,6 +66,7 @@ class SaaSServerConfig(ServerConfig):
github_client_id: str = os.environ.get('GITHUB_APP_CLIENT_ID', '')
enable_billing = os.environ.get('ENABLE_BILLING', 'false') == 'true'
hide_llm_settings = os.environ.get('HIDE_LLM_SETTINGS', 'false') == 'true'
hide_billing = os.environ.get('HIDE_BILLING', 'false') == 'true'
auth_url: str | None = os.environ.get('AUTH_URL')
settings_store_class: str = 'storage.saas_settings_store.SaasSettingsStore'
secret_store_class: str = 'storage.saas_secrets_store.SaasSecretsStore'
@@ -171,6 +172,7 @@ class SaaSServerConfig(ServerConfig):
'FEATURE_FLAGS': {
'ENABLE_BILLING': self.enable_billing,
'HIDE_LLM_SETTINGS': self.hide_llm_settings,
'HIDE_BILLING': self.hide_billing,
'ENABLE_JIRA': self.enable_jira,
'ENABLE_JIRA_DC': self.enable_jira_dc,
'ENABLE_LINEAR': self.enable_linear,

View File

@@ -113,12 +113,14 @@ 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
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,

View File

@@ -1,96 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, 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";
describe("AccountSettingsContextMenu", () => {
const user = userEvent.setup();
const onClickAccountSettingsMock = vi.fn();
const onLogoutMock = vi.fn();
const onCloseMock = vi.fn();
// Create a wrapper with MemoryRouter and renderWithProviders
const renderWithRouter = (ui: React.ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
};
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
onCloseMock.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();
});
});

View File

@@ -0,0 +1,119 @@
import { within, screen, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, 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";
const renderInviteOrganizationMemberModal = (config?: {
onClose: () => void;
}) =>
render(
<InviteOrganizationMemberModal onClose={config?.onClose || vi.fn()} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
},
);
vi.mock("#/context/use-selected-organization", () => ({
useSelectedOrganizationId: vi.fn(() => ({
orgId: "1",
setOrgId: vi.fn(),
})),
}));
describe("InviteOrganizationMemberModal", () => {
afterEach(() => {
vi.clearAllMocks();
});
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: /cancel/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();
});
});

View File

@@ -35,6 +35,7 @@ describe("PaymentForm", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,

View File

@@ -0,0 +1,403 @@
import { render, screen, waitFor, within } 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 } 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";
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
function UserContextMenuWithRootOutlet({
type,
onClose,
}: UserContextMenuProps) {
return (
<div>
<div data-testid="portal-root" id="portal-root" />
<UserContextMenu type={type} onClose={onClose} />
</div>
);
}
const renderUserContextMenu = ({ type, onClose }: UserContextMenuProps) =>
render(<UserContextMenuWithRootOutlet type={type} onClose={onClose} />, {
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(),
}),
}));
describe("UserContextMenu", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should render the default context items for a user", () => {
renderUserContextMenu({ type: "user", onClose: vi.fn });
screen.getByTestId("org-selector");
screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
expect(
screen.queryByText("ORG$INVITE_ORGANIZATION_MEMBER"),
).not.toBeInTheDocument();
expect(
screen.queryByText("ORG$MANAGE_ORGANIZATION_MEMBERS"),
).not.toBeInTheDocument();
expect(screen.queryByText("ORG$MANAGE_ACCOUNT")).not.toBeInTheDocument();
});
it("should render navigation items from SAAS_NAV_ITEMS (except organization-members/org)", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
renderUserContextMenu({ type: "user", onClose: 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: "user", onClose: 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: "user", onClose: 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({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
});
it("should render OSS_NAV_ITEMS when in OSS mode", async () => {
renderUserContextMenu({ type: "user", onClose: 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: "user", onClose: 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({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: true,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
renderUserContextMenu({ type: "user", onClose: 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({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
renderUserContextMenu({ type: "user", onClose: 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 });
screen.getByTestId("org-selector");
screen.getByText("ORG$INVITE_ORGANIZATION_MEMBER");
screen.getByText("ORG$MANAGE_ORGANIZATION_MEMBERS");
screen.getByText("ORG$MANAGE_ACCOUNT");
});
it("should render additional context items when user is an owner", () => {
renderUserContextMenu({ type: "owner", onClose: vi.fn });
screen.getByTestId("org-selector");
screen.getByText("ORG$INVITE_ORGANIZATION_MEMBER");
screen.getByText("ORG$MANAGE_ORGANIZATION_MEMBERS");
screen.getByText("ORG$MANAGE_ACCOUNT");
});
it("should call the logout handler when Logout is clicked", async () => {
const logoutSpy = vi.spyOn(AuthService, "logout");
renderUserContextMenu({ type: "user", onClose: 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({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
renderUserContextMenu({ type: "user", onClose: 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");
});
const billingLink = screen.getByText("SETTINGS$NAV_BILLING").closest("a");
expect(billingLink).toHaveAttribute("href", "/settings/billing");
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 () => {
renderUserContextMenu({ type: "admin", onClose: vi.fn });
const manageOrganizationMembersButton = screen.getByText(
"ORG$MANAGE_ORGANIZATION_MEMBERS",
);
await userEvent.click(manageOrganizationMembersButton);
expect(navigateMock).toHaveBeenCalledExactlyOnceWith(
"/settings/org-members",
);
});
it("should navigate to /settings/org when Manage Account is clicked", async () => {
renderUserContextMenu({ type: "admin", onClose: vi.fn });
const manageAccountButton = screen.getByText("ORG$MANAGE_ACCOUNT");
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: "user", onClose: onCloseMock });
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 () => {
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "owner", onClose: onCloseMock });
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await userEvent.click(logoutButton);
expect(onCloseMock).toHaveBeenCalledTimes(1);
const manageOrganizationMembersButton = screen.getByText(
"ORG$MANAGE_ORGANIZATION_MEMBERS",
);
await userEvent.click(manageOrganizationMembersButton);
expect(onCloseMock).toHaveBeenCalledTimes(2);
const manageAccountButton = screen.getByText("ORG$MANAGE_ACCOUNT");
await userEvent.click(manageAccountButton);
expect(onCloseMock).toHaveBeenCalledTimes(3);
});
it("should render the invite user modal when Invite Organization Member is clicked", async () => {
const inviteMembersBatchSpy = vi.spyOn(
organizationService,
"inviteMembers",
);
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "admin", onClose: onCloseMock });
const inviteButton = screen.getByText("ORG$INVITE_ORGANIZATION_MEMBER");
await userEvent.click(inviteButton);
const portalRoot = screen.getByTestId("portal-root");
expect(within(portalRoot).getByTestId("invite-modal")).toBeInTheDocument();
await userEvent.click(within(portalRoot).getByText("BUTTON$CANCEL"));
expect(inviteMembersBatchSpy).not.toHaveBeenCalled();
});
test("the user can change orgs", async () => {
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "user", onClose: onCloseMock });
const orgSelector = screen.getByTestId("org-selector");
expect(orgSelector).toBeInTheDocument();
// Simulate changing the organization
await userEvent.click(orgSelector);
const orgOption = screen.getByText(INITIAL_MOCK_ORGS[1].name);
await userEvent.click(orgOption);
expect(onCloseMock).not.toHaveBeenCalled();
// Verify that the dropdown shows the selected organization
// The dropdown should now display the selected org name
expect(orgSelector).toHaveValue(INITIAL_MOCK_ORGS[1].name);
});
it("should have Personal Account as the default selected option with null value", async () => {
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "user", onClose: onCloseMock });
const orgSelector = screen.getByTestId("org-selector");
// Should default to "Personal Account" when orgId is null
expect(orgSelector).toHaveValue("Personal Account");
// Click to open dropdown
await userEvent.click(orgSelector);
// Should have "Personal Account" as an option
const personalAccountOption = screen.getByText("Personal Account");
expect(personalAccountOption).toBeInTheDocument();
// Select an organization
const orgOption = screen.getByText(INITIAL_MOCK_ORGS[1].name);
await userEvent.click(orgOption);
// Should now show the selected organization
expect(orgSelector).toHaveValue(INITIAL_MOCK_ORGS[1].name);
// Click to open dropdown again
await userEvent.click(orgSelector);
// Click on Personal Account to go back
const personalAccountOptionAgain = screen.getByText("Personal Account");
await userEvent.click(personalAccountOptionAgain);
// Should show "Personal Account" after going back
expect(orgSelector).toHaveValue("Personal Account");
});
});

View File

@@ -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(", ")}`,
);
}
});
});

View File

@@ -1,11 +1,41 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest";
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 { renderWithProviders } from "../../test-utils";
vi.mock("react-router", async (importActual) => ({
...(await importActual()),
useNavigate: () => vi.fn(),
useRevalidator: () => ({
revalidate: 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 +68,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 +90,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 +107,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 +146,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 +172,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 +195,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 +203,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 +221,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 +243,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(
@@ -262,30 +257,58 @@ describe("UserActions", () => {
});
it("should work with loading state and user provided", async () => {
// Ensure authentication and providers are set correctly
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
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_ACCOUNT")).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();
});
});

View File

@@ -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();

View File

@@ -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");
});
});

View File

@@ -77,6 +77,7 @@ describe("frontend/routes/_oh", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
@@ -115,6 +116,7 @@ describe("frontend/routes/_oh", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
@@ -199,6 +201,7 @@ describe("frontend/routes/_oh", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,

View File

@@ -21,6 +21,7 @@ const VALID_OSS_CONFIG: GetConfigResponse = {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
@@ -34,6 +35,7 @@ const VALID_SAAS_CONFIG: GetConfigResponse = {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
@@ -353,7 +355,9 @@ describe("Form submission", () => {
renderGitSettingsScreen();
const azureDevOpsInput = await screen.findByTestId("azure-devops-token-input");
const azureDevOpsInput = await screen.findByTestId(
"azure-devops-token-input",
);
const submit = await screen.findByTestId("submit-button");
await userEvent.type(azureDevOpsInput, "test-token");

View File

@@ -395,12 +395,14 @@ describe("Settings 404", () => {
});
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
@@ -420,12 +422,14 @@ describe("Setup Payment modal", () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
it("should only render if SaaS mode and is new user", async () => {
// @ts-expect-error - we only need the APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,

View File

@@ -1017,6 +1017,7 @@ describe("View persistence after saving advanced settings", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,

View File

@@ -0,0 +1,862 @@
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 } 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";
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",
},
],
},
]);
const renderManageOrg = () =>
render(<RouteStub initialEntries={["/settings/org"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const { navigateMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
}));
vi.mock("react-router", async () => ({
...(await vi.importActual("react-router")),
useNavigate: () => navigateMock,
}));
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" | "user";
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(() => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
// 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();
vi.clearAllMocks();
});
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("1000");
});
});
it("should render account details", async () => {
renderManageOrg();
await selectOrganization({ orgIndex: 0 });
await waitFor(() => {
const orgName = screen.getByTestId("org-name");
expect(orgName).toHaveTextContent("Acme Corp");
const billingInfo = screen.getByTestId("billing-info");
expect(billingInfo).toHaveTextContent("**** **** **** 1234");
});
});
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
const addCreditsButton = 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
const addCreditsButton = 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: /cancel/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 = 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");
});
it("should display correct placeholder text", async () => {
await openAddCreditsModal();
const amountInput = screen.getByTestId("amount-input");
expect(amountInput).toHaveAttribute(
"placeholder",
"PAYMENT$SPECIFY_AMOUNT_USD",
);
});
});
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: 2 }); // user is admin in org 3
// 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");
// @ts-expect-error - only return the properties we need for this test
getConfigSpy.mockResolvedValue({
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("Acme Corp"));
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: 2 }); // user is admin in org 3
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");
// @ts-expect-error - only return the properties we need for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas", // required to enable getMe
});
renderManageOrg();
await screen.findByTestId("manage-org-screen");
await selectOrganization({ orgIndex: 2 }); // user is admin in org 3
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 = 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.each<{ role: "admin" | "user"; roleName: string }>([
{ role: "admin", roleName: "Admin" },
{ role: "user", roleName: "User" },
])(
"should not show delete organization button when user lacks canDeleteOrganization permission ($roleName role)",
async ({ role }) => {
setupUserMock({
org_id: "1",
user_id: "1",
email: "test@example.com",
role,
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
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 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("HIDE_BILLING feature flag", () => {
it("should hide credits section when HIDE_BILLING is true", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: true,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
// Act
renderManageOrg();
await screen.findByTestId("manage-org-screen");
await selectOrganization({ orgIndex: 0 });
// Assert
await waitFor(() => {
expect(
screen.queryByTestId("available-credits"),
).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should hide billing information section when HIDE_BILLING is true", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: true,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
// Act
renderManageOrg();
await screen.findByTestId("manage-org-screen");
await selectOrganization({ orgIndex: 0 });
// Assert
await waitFor(() => {
expect(screen.queryByTestId("billing-info")).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should hide Add Credits button when HIDE_BILLING is true", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: true,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
// Act
renderManageOrg();
await screen.findByTestId("manage-org-screen");
await selectOrganization({ orgIndex: 0 });
// Assert
await waitFor(() => {
const addButton = screen.queryByText(/add/i);
expect(addButton).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should show all billing-related elements when HIDE_BILLING is false", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
// Act
renderManageOrg();
await screen.findByTestId("manage-org-screen");
await selectOrganization({ orgIndex: 0 });
// Assert
await waitFor(() => {
expect(screen.getByTestId("available-credits")).toBeInTheDocument();
expect(screen.getByTestId("billing-info")).toBeInTheDocument();
expect(screen.getByText(/add/i)).toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,824 @@
import { describe, expect, it, vi, test, beforeEach, afterEach } from "vitest";
import { render, screen, within, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import { selectOrganization } from "test-utils";
import { organizationService } from "#/api/organization-service/organization-service.api";
import ManageOrganizationMembers from "#/routes/manage-organization-members";
import SettingsScreen, {
clientLoader as settingsClientLoader,
} from "#/routes/settings";
import {
ORGS_AND_MEMBERS,
resetOrgMockData,
resetOrgsAndMembersMockData,
} from "#/mocks/org-handlers";
import OptionService from "#/api/option-service/option-service.api";
function ManageOrganizationMembersWithPortalRoot() {
return (
<div>
<ManageOrganizationMembers />
<div data-testid="portal-root" id="portal-root" />
</div>
);
}
const RouteStub = createRoutesStub([
{
// @ts-expect-error - ignoreing error for test stub
loader: settingsClientLoader,
Component: SettingsScreen,
path: "/settings",
HydrateFallback: () => <div>Loading...</div>,
children: [
{
Component: ManageOrganizationMembersWithPortalRoot,
path: "/settings/org-members",
},
{
Component: () => <div data-testid="user-settings" />,
path: "/settings/user",
},
],
},
]);
let queryClient: QueryClient;
describe("Manage Organization Members Route", () => {
const getMeSpy = vi.spyOn(organizationService, "getMe");
beforeEach(() => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
queryClient = new QueryClient();
// Set default mock for user (admin role has invite permission)
getMeSpy.mockResolvedValue({
org_id: "1",
user_id: "1",
email: "test@example.com",
role: "admin",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
});
afterEach(() => {
vi.restoreAllMocks();
// Reset organization mock data to ensure clean state between tests
resetOrgMockData();
// Reset ORGS_AND_MEMBERS to initial state
resetOrgsAndMembersMockData();
// Clear queryClient cache to ensure fresh data for next test
queryClient.clear();
});
const renderManageOrganizationMembers = () =>
render(<RouteStub initialEntries={["/settings/org-members"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
// Helper function to find a member by email
const findMemberByEmail = async (email: string) => {
const memberListItems = await screen.findAllByTestId("member-item");
const member = memberListItems.find((item) =>
within(item).queryByText(email),
);
if (!member) {
throw new Error(`Could not find member with email: ${email}`);
}
return member;
};
// Helper function to open role dropdown for a member
const openRoleDropdown = async (
memberElement: HTMLElement,
roleText: string,
) => {
// Find the role text that's clickable (has cursor-pointer class or is the main role display)
// Use a more specific query to avoid matching dropdown options
const roleElement = within(memberElement).getByText(
new RegExp(`^${roleText}$`, "i"),
);
await userEvent.click(roleElement);
return within(memberElement).getByTestId(
"organization-member-role-context-menu",
);
};
// Helper function to change member role
const changeMemberRole = async (
memberElement: HTMLElement,
currentRole: string,
newRole: string,
) => {
const dropdown = await openRoleDropdown(memberElement, currentRole);
const roleOption = within(dropdown).getByText(new RegExp(newRole, "i"));
await userEvent.click(roleOption);
};
// Helper function to verify dropdown is not visible
const expectDropdownNotVisible = (memberElement: HTMLElement) => {
expect(
within(memberElement).queryByTestId(
"organization-member-role-context-menu",
),
).not.toBeInTheDocument();
};
// Helper function to setup test with user and organization
const setupTestWithUserAndOrg = async (
userData: {
org_id: string;
user_id: string;
email: string;
role: "owner" | "admin" | "user";
llm_api_key: string;
max_iterations: number;
llm_model: string;
llm_api_key_for_byor: string | null;
llm_base_url: string;
status: "active" | "invited" | "inactive";
},
orgIndex: number,
) => {
getMeSpy.mockResolvedValue(userData);
renderManageOrganizationMembers();
await screen.findByTestId("manage-organization-members-settings");
await selectOrganization({ orgIndex });
};
// Helper function to create updateMember spy
const createUpdateMemberRoleSpy = () =>
vi.spyOn(organizationService, "updateMember");
// Helper function to verify role change is not permitted
const verifyRoleChangeNotPermitted = async (
userData: {
org_id: string;
user_id: string;
email: string;
role: "owner" | "admin" | "user";
llm_api_key: string;
max_iterations: number;
llm_model: string;
llm_api_key_for_byor: string | null;
llm_base_url: string;
status: "active" | "invited" | "inactive";
},
orgIndex: number,
targetMemberIndex: number,
expectedRoleText: string,
) => {
await setupTestWithUserAndOrg(userData, orgIndex);
const memberListItems = await screen.findAllByTestId("member-item");
const targetMember = memberListItems[targetMemberIndex];
const roleText = within(targetMember).getByText(
new RegExp(`^${expectedRoleText}$`, "i"),
);
expect(roleText).toBeInTheDocument();
await userEvent.click(roleText);
// Verify that the dropdown does not open
expectDropdownNotVisible(targetMember);
};
// Helper function to setup invite test (render and select organization)
const setupInviteTest = async (orgIndex: number = 0) => {
renderManageOrganizationMembers();
await selectOrganization({ orgIndex });
};
// Helper function to setup test with organization (waits for settings screen)
const setupTestWithOrg = async (orgIndex: number = 0) => {
renderManageOrganizationMembers();
await screen.findByTestId("manage-organization-members-settings");
await selectOrganization({ orgIndex });
};
// Helper function to find invite button
const findInviteButton = async () =>
await screen.findByRole("button", {
name: /ORG\$INVITE_ORGANIZATION_MEMBER/i,
});
// Helper function to verify all three role options are present in dropdown
const expectAllRoleOptionsPresent = (dropdown: HTMLElement) => {
expect(within(dropdown).getByText(/owner/i)).toBeInTheDocument();
expect(within(dropdown).getByText(/admin/i)).toBeInTheDocument();
expect(within(dropdown).getByText(/user/i)).toBeInTheDocument();
};
// Helper function to close dropdown by clicking outside
const closeDropdown = async () => {
await userEvent.click(document.body);
};
// Helper function to verify owner option is not present in dropdown
const expectOwnerOptionNotPresent = (dropdown: HTMLElement) => {
expect(within(dropdown).queryByText(/owner/i)).not.toBeInTheDocument();
};
it("should render", async () => {
renderManageOrganizationMembers();
await screen.findByTestId("manage-organization-members-settings");
});
it("should navigate away from the page if not saas", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
renderManageOrganizationMembers();
expect(
screen.queryByTestId("manage-organization-members-settings"),
).not.toBeInTheDocument();
});
it("should allow the user to select an organization", async () => {
const getOrganizationMembersSpy = vi.spyOn(
organizationService,
"getOrganizationMembers",
);
renderManageOrganizationMembers();
await screen.findByTestId("manage-organization-members-settings");
expect(getOrganizationMembersSpy).not.toHaveBeenCalled();
await selectOrganization({ orgIndex: 0 });
expect(getOrganizationMembersSpy).toHaveBeenCalledExactlyOnceWith({
orgId: "1",
});
});
it("should render the list of organization members", async () => {
await setupTestWithOrg(0);
const members = ORGS_AND_MEMBERS["1"];
const memberListItems = await screen.findAllByTestId("member-item");
expect(memberListItems).toHaveLength(members.length);
members.forEach((member) => {
expect(screen.getByText(member.email)).toBeInTheDocument();
expect(screen.getByText(member.role)).toBeInTheDocument();
});
});
test("an admin should be able to change the role of a organization member", async () => {
await setupTestWithUserAndOrg(
{
org_id: "1",
user_id: "1",
email: "test@example.com",
role: "admin",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
0,
);
const updateMemberRoleSpy = createUpdateMemberRoleSpy();
const memberListItems = await screen.findAllByTestId("member-item");
const userRoleMember = memberListItems[2]; // third member is "user"
let userCombobox = within(userRoleMember).getByText(/^User$/i);
expect(userCombobox).toBeInTheDocument();
// Change role from user to admin
await changeMemberRole(userRoleMember, "user", "admin");
expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({
userId: "3", // assuming the third member is the one being updated
orgId: "1",
role: "admin",
});
expectDropdownNotVisible(userRoleMember);
// Verify the role has been updated in the UI
userCombobox = within(userRoleMember).getByText(/^Admin$/i);
expect(userCombobox).toBeInTheDocument();
// Revert the role back to user
await changeMemberRole(userRoleMember, "admin", "user");
expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(2, {
userId: "3",
orgId: "1",
role: "user",
});
// Verify the role has been reverted in the UI
userCombobox = within(userRoleMember).getByText(/^User$/i);
expect(userCombobox).toBeInTheDocument();
});
it("should not allow an admin to change the owner's role", async () => {
await verifyRoleChangeNotPermitted(
{
org_id: "3",
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",
},
2, // user is admin in org 3
0, // first member is "owner"
"Owner",
);
});
it("should not allow an admin to change another admin's role", async () => {
await verifyRoleChangeNotPermitted(
{
org_id: "3",
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",
},
2, // user is admin in org 3
1, // second member is "admin"
"Admin",
);
});
it("should not allow a user to change their own role", async () => {
// Mock the /me endpoint to return a user ID that matches one of the members
await verifyRoleChangeNotPermitted(
{
org_id: "1",
user_id: "1", // Same as first member from org 1
email: "alice@acme.org",
role: "owner",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
0,
0, // First member (user_id: "1")
"Owner",
);
});
it("should show a remove option in the role dropdown and remove the user from the list", async () => {
const removeMemberSpy = vi.spyOn(organizationService, "removeMember");
await setupTestWithOrg(0);
// Get initial member count
const memberListItems = await screen.findAllByTestId("member-item");
const initialMemberCount = memberListItems.length;
const userRoleMember = memberListItems[2]; // third member is "user"
const userEmail = within(userRoleMember).getByText("charlie@acme.org");
expect(userEmail).toBeInTheDocument();
const userCombobox = within(userRoleMember).getByText(/^User$/i);
await userEvent.click(userCombobox);
const dropdown = within(userRoleMember).getByTestId(
"organization-member-role-context-menu",
);
// Check that remove option exists
const removeOption = within(dropdown).getByTestId("remove-option");
expect(removeOption).toBeInTheDocument();
await userEvent.click(removeOption);
expect(removeMemberSpy).toHaveBeenCalledExactlyOnceWith({
orgId: "1",
userId: "3",
});
// Verify the user is no longer in the list
await waitFor(() => {
const updatedMemberListItems = screen.getAllByTestId("member-item");
expect(updatedMemberListItems).toHaveLength(initialMemberCount - 1);
});
// Verify the specific user email is no longer present
expect(screen.queryByText("charlie@acme.org")).not.toBeInTheDocument();
});
it.todo(
"should not allow a user to change another user's role if they are the same role",
);
describe("Inviting Organization Members", () => {
it("should render an invite organization member button", async () => {
await setupInviteTest();
const inviteButton = await findInviteButton();
expect(inviteButton).toBeInTheDocument();
});
it("should render a modal when the invite button is clicked", async () => {
await setupInviteTest();
expect(screen.queryByTestId("invite-modal")).not.toBeInTheDocument();
const inviteButton = await findInviteButton();
await userEvent.click(inviteButton);
const portalRoot = screen.getByTestId("portal-root");
expect(
within(portalRoot).getByTestId("invite-modal"),
).toBeInTheDocument();
});
it("should close the modal when the close button is clicked", async () => {
await setupInviteTest();
const inviteButton = await findInviteButton();
await userEvent.click(inviteButton);
const modal = screen.getByTestId("invite-modal");
const closeButton = within(modal).getByText("BUTTON$CANCEL");
await userEvent.click(closeButton);
expect(screen.queryByTestId("invite-modal")).not.toBeInTheDocument();
});
it("should render a list item in an invited state when a the user is is invited", async () => {
const getOrganizationMembersSpy = vi.spyOn(
organizationService,
"getOrganizationMembers",
);
getOrganizationMembersSpy.mockResolvedValue([
{
org_id: "1",
user_id: "4",
email: "tom@acme.org",
role: "user",
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",
},
]);
await setupInviteTest();
const members = await screen.findAllByTestId("member-item");
expect(members).toHaveLength(1);
const invitedMember = members[0];
expect(invitedMember).toBeInTheDocument();
// should have an "invited" badge
const invitedBadge = within(invitedMember).getByText(/invited/i);
expect(invitedBadge).toBeInTheDocument();
// should not have a role combobox
await userEvent.click(within(invitedMember).getByText(/^User$/i));
expect(
within(invitedMember).queryByTestId(
"organization-member-role-context-menu",
),
).not.toBeInTheDocument();
});
});
describe("Role-based invite permission behavior", () => {
it.each([
{ role: "owner" as const, roleName: "Owner" },
{ role: "admin" as const, roleName: "Admin" },
])(
"should show invite button when user has canInviteUsers permission ($roleName role)",
async ({ role }) => {
getMeSpy.mockResolvedValue({
org_id: "1",
user_id: "1",
email: "test@example.com",
role,
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
await setupTestWithOrg(0);
const inviteButton = await findInviteButton();
expect(inviteButton).toBeInTheDocument();
expect(inviteButton).not.toBeDisabled();
},
);
it("should not show invite button when user lacks canInviteUsers permission (User role)", async () => {
const userData = {
org_id: "1",
user_id: "1",
email: "test@example.com",
role: "user" as const,
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
};
// Set mock and remove cached query before rendering
getMeSpy.mockResolvedValue(userData);
// Remove any cached "me" queries so fresh data is fetched
queryClient.removeQueries({ queryKey: ["organizations"] });
await setupTestWithOrg(0);
// Directly set the query data to force component re-render with user role
// This ensures the component uses the user role data instead of cached admin data
queryClient.setQueryData(["organizations", "1", "me"], userData);
// Wait for the component to update with the new query data
await waitFor(
() => {
const inviteButton = screen.queryByRole("button", {
name: /ORG\$INVITE_ORGANIZATION_MEMBER/i,
});
expect(inviteButton).not.toBeInTheDocument();
},
{ timeout: 3000 },
);
});
});
describe("Role-based role change permission behavior", () => {
it("should not allow an owner to change another owner's role", async () => {
await verifyRoleChangeNotPermitted(
{
org_id: "1",
user_id: "1", // First member is owner in org 1
email: "alice@acme.org",
role: "owner",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
0,
0, // First member is owner
"owner",
);
});
it("Owner should see all three role options (owner, admin, user) in dropdown regardless of target member's role", async () => {
await setupTestWithUserAndOrg(
{
org_id: "1",
user_id: "1", // First member is owner in org 1
email: "alice@acme.org",
role: "owner",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
0,
);
const memberListItems = await screen.findAllByTestId("member-item");
// Test with admin member
const adminMember = memberListItems[1]; // Second member is admin (user_id: "2")
const adminDropdown = await openRoleDropdown(adminMember, "admin");
// Verify all three role options are present for admin member
expectAllRoleOptionsPresent(adminDropdown);
// Close dropdown by clicking outside
await closeDropdown();
// Test with user member
const userMember = await findMemberByEmail("charlie@acme.org");
const userDropdown = await openRoleDropdown(userMember, "user");
// Verify all three role options are present for user member
expectAllRoleOptionsPresent(userDropdown);
});
it("Admin should not see owner option in role dropdown for any member", async () => {
await setupTestWithUserAndOrg(
{
org_id: "3",
user_id: "7", // Ray is admin in org 3
email: "ray@all-hands.dev",
role: "admin",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
2, // org 3
);
const memberListItems = await screen.findAllByTestId("member-item");
// Check user member dropdown
const userMember = memberListItems[2]; // user member
const userDropdown = await openRoleDropdown(userMember, "user");
expectOwnerOptionNotPresent(userDropdown);
await closeDropdown();
// Check another user member dropdown if exists
if (memberListItems.length > 3) {
const anotherUserMember = memberListItems[3]; // another user member
const anotherUserDropdown = await openRoleDropdown(
anotherUserMember,
"user",
);
expectOwnerOptionNotPresent(anotherUserDropdown);
}
});
it("Owner should be able to change any member's role to owner", async () => {
await setupTestWithUserAndOrg(
{
org_id: "1",
user_id: "1", // First member is owner in org 1
email: "alice@acme.org",
role: "owner",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
0,
);
const updateMemberRoleSpy = createUpdateMemberRoleSpy();
const memberListItems = await screen.findAllByTestId("member-item");
// Test changing admin to owner
const adminMember = memberListItems[1]; // Second member is admin (user_id: "2")
await changeMemberRole(adminMember, "admin", "owner");
expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(1, {
userId: "2",
orgId: "1",
role: "owner",
});
// Test changing user to owner
const userMember = await findMemberByEmail("charlie@acme.org");
await changeMemberRole(userMember, "user", "owner");
expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(2, {
userId: "3",
orgId: "1",
role: "owner",
});
});
it.each([
{
description:
"Owner should be able to change admin's role to admin (no change)",
userData: {
org_id: "1",
user_id: "1", // First member is owner in org 1
email: "alice@acme.org",
role: "owner" as const,
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
},
orgIndex: 0,
memberEmail: "bob@acme.org",
currentRole: "admin",
newRole: "admin",
expectedApiCall: {
userId: "2",
orgId: "1",
role: "admin" as const,
},
},
{
description:
"Admin should be able to change user's role to user (no change)",
userData: {
org_id: "3",
user_id: "7", // Ray is admin in org 3
email: "ray@all-hands.dev",
role: "admin" as const,
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
},
orgIndex: 2, // org 3
memberEmail: "stephan@all-hands.dev",
currentRole: "user",
newRole: "user",
expectedApiCall: {
userId: "9",
orgId: "3",
role: "user" as const,
},
},
{
description: "Admin should be able to change user's role to admin",
userData: {
org_id: "3",
user_id: "7", // Ray is admin in org 3
email: "ray@all-hands.dev",
role: "admin" as const,
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
},
orgIndex: 2, // org 3
memberEmail: "stephan@all-hands.dev",
currentRole: "user",
newRole: "admin",
expectedApiCall: {
userId: "9",
orgId: "3",
role: "admin" as const,
},
},
])(
"$description",
async ({
userData,
orgIndex,
memberEmail,
currentRole,
newRole,
expectedApiCall,
}) => {
await setupTestWithUserAndOrg(userData, orgIndex);
const updateMemberRoleSpy = createUpdateMemberRoleSpy();
const member = await findMemberByEmail(memberEmail);
await changeMemberRole(member, currentRole, newRole);
expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith(
expectedApiCall,
);
},
);
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen, within } from "@testing-library/react";
import { render, screen, within, waitFor } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -22,6 +22,7 @@ vi.mock("react-i18next", async () => {
SETTINGS$NAV_SECRETS: "Secrets",
SETTINGS$NAV_MCP: "MCP",
SETTINGS$NAV_USER: "User",
SETTINGS$NAV_BILLING: "Billing",
SETTINGS$TITLE: "Settings",
};
return translations[key] || key;
@@ -61,6 +62,10 @@ describe("Settings Screen", () => {
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",
@@ -192,4 +197,66 @@ describe("Settings Screen", () => {
});
it.todo("should not be able to access oss-only routes in saas mode");
describe("HIDE_BILLING feature flag", () => {
it("should hide billing navigation item when HIDE_BILLING is true", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: true,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
mockQueryClient.clear();
// Act
renderSettingsScreen();
// Assert
const navbar = await screen.findByTestId("settings-navbar");
expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument();
getConfigSpy.mockRestore();
});
it("should show billing navigation item when HIDE_BILLING is false", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test",
POSTHOG_CLIENT_KEY: "test",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
mockQueryClient.clear();
// Act
renderSettingsScreen();
// Assert
const navbar = await screen.findByTestId("settings-navbar");
await waitFor(() => {
expect(within(navbar).getByText("Billing")).toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
});
});

View File

@@ -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 orgId", () => {
const { result } = renderHook(() => useSelectedOrganizationStore());
expect(result.current.organizationId).toBeNull();
});
it("should update orgId when setOrgId is called", () => {
const { result } = renderHook(() => useSelectedOrganizationStore());
act(() => {
result.current.setOrganizationId("org-123");
});
expect(result.current.organizationId).toBe("org-123");
});
it("should allow setting orgId 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-org");
});
expect(result2.current.organizationId).toBe("shared-org");
});
});

View File

@@ -10,6 +10,7 @@ export interface GetConfigResponse {
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
HIDE_BILLING: boolean;
ENABLE_JIRA: boolean;
ENABLE_JIRA_DC: boolean;
ENABLE_LINEAR: boolean;

View File

@@ -0,0 +1,103 @@
import {
Organization,
OrganizationMember,
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<Organization[]>("/api/organizations");
return data;
},
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 }: { orgId: string }) => {
const { data } = await openHands.get<OrganizationMember[]>(
`/api/organizations/${orgId}/members`,
);
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;
},
};

View File

@@ -1,83 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
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 { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
interface AccountSettingsContextMenuProps {
onLogout: () => void;
onClose: () => void;
}
export function AccountSettingsContextMenu({
onLogout,
onClose,
}: AccountSettingsContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
// Get navigation items and filter out LLM settings if the feature flag is enabled
const items = useSettingsNavItems();
const navItems = items.map((item) => ({
...item,
icon: React.cloneElement(item.icon, {
width: 16,
height: 16,
} as React.SVGProps<SVGSVGElement>),
}));
const handleNavigationClick = () => 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]"
>
{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>
);
}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { useInviteMembersBatch } from "#/hooks/mutation/use-invite-members-batch";
import { BrandButton } from "../settings/brand-button";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { I18nKey } from "#/i18n/declaration";
interface InviteOrganizationMemberModalProps {
onClose: (event?: React.MouseEvent<HTMLButtonElement>) => void;
}
export function InviteOrganizationMemberModal({
onClose,
}: InviteOrganizationMemberModalProps) {
const { t } = useTranslation();
const { mutate: inviteMembers } = useInviteMembersBatch();
const [emails, setEmails] = React.useState<string[]>([]);
const formAction = () => {
if (emails.length > 0) {
inviteMembers({ emails });
onClose();
}
};
return (
<ModalBackdrop onClose={onClose}>
<div
data-testid="invite-modal"
className="bg-base rounded-xl p-4 border w-sm border-tertiary items-start"
onClick={(e) => e.stopPropagation()}
>
<div className="w-full flex flex-col gap-2">
<h3 className="text-lg font-semibold">
{t(I18nKey.ORG$INVITE_USERS)}
</h3>
<p className="text-xs text-gray-400">
{t(I18nKey.ORG$INVITE_USERS_DESCRIPTION)}
</p>
<div className="flex flex-col gap-2">
<span className="text-sm">{t(I18nKey.ORG$EMAILS)}</span>
<BadgeInput
name="emails-badge-input"
value={emails}
placeholder="Type email and press space"
onChange={setEmails}
/>
</div>
<div className="flex gap-2">
<BrandButton
type="button"
variant="primary"
className="flex-1"
onClick={formAction}
>
{t(I18nKey.BUTTON$ADD)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</div>
</div>
</div>
</ModalBackdrop>
);
}

View File

@@ -0,0 +1,82 @@
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",
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 text-gray-400 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>
);
}

View File

@@ -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("user") && (
<ContextMenuListItem
testId="user-option"
onClick={(event) => handleRoleChangeClick(event, "user")}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
icon={<UserIcon width={16} height={16} className="text-white" />}
text={t(I18nKey.ORG$ROLE_USER)}
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>
);
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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: {

View File

@@ -5,6 +5,10 @@ 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 { SettingsDropdownInput } from "./settings-dropdown-input";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { useOrganizations } from "#/hooks/query/use-organizations";
import { useMe } from "#/hooks/query/use-me";
import { SettingsNavItem } from "#/constants/settings-nav";
interface SettingsNavigationProps {
@@ -18,8 +22,14 @@ export function SettingsNavigation({
onCloseMobileMenu,
navigationItems,
}: SettingsNavigationProps) {
const { orgId, setOrgId } = useSelectedOrganizationId();
const { data: organizations } = useOrganizations();
const { data: me } = useMe();
const { t } = useTranslation();
const isUser = me?.role === "user";
return (
<>
{/* Mobile backdrop */}
@@ -41,6 +51,28 @@ export function SettingsNavigation({
"md:relative md:translate-x-0 md:w-64 md:p-0 md:bg-transparent",
)}
>
<div className="px-3 py-2">
<SettingsDropdownInput
testId="org-select"
name="organization"
placeholder="Please select an organization"
selectedKey={orgId || ""}
items={
organizations?.map((org) => ({
key: org.id,
label: org.name,
})) || []
}
onSelectionChange={(org) => {
if (org) {
setOrgId(org.toString());
} else {
setOrgId(null);
}
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 ml-1 sm:ml-4.5">
<SettingsIcon width={16} height={16} />
@@ -58,27 +90,40 @@ export function SettingsNavigation({
</div>
<div className="flex flex-col gap-2">
{navigationItems.map(({ to, icon, text }) => (
<NavLink
end
key={to}
to={to}
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]",
)
{navigationItems
.filter((navItem) => {
// if user is not an admin or no org is selected, do not show organization members/org settings
if (
(navItem.to === "/settings/org-members" ||
navItem.to === "/settings/org") &&
(isUser || !orgId)
) {
return false;
}
>
{icon}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<Typography.Text className="text-[#A3A3A3] whitespace-nowrap">
{t(text as I18nKey)}
</Typography.Text>
</div>
</NavLink>
))}
return true;
})
.map(({ to, icon, text }) => (
<NavLink
end
key={to}
to={to}
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]",
)
}
>
{icon}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<Typography.Text className="text-[#A3A3A3] whitespace-nowrap">
{t(text as I18nKey)}
</Typography.Text>
</div>
</NavLink>
))}
</div>
</nav>
</>

View File

@@ -9,7 +9,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 { MicroagentManagementButton } from "#/components/shared/buttons/microagent-management-button";
@@ -25,7 +24,6 @@ export function Sidebar() {
isError: settingsIsError,
isFetching: isFetchingSettings,
} = useSettings();
const { mutate: logout } = useLogout();
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
@@ -92,7 +90,6 @@ export function Sidebar() {
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined
}
onLogout={logout}
isLoading={user.isFetching}
/>
</div>

View File

@@ -1,28 +1,29 @@
import React from "react";
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 { 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();
// 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);
};
const closeAccountMenu = () => {
@@ -31,40 +32,27 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
}
};
const handleLogout = () => {
onLogout();
closeAccountMenu();
};
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"
className="relative cursor-pointer group"
onMouseEnter={showAccountMenu}
onMouseLeave={hideAccountMenu}
>
<UserAvatar
avatarUrl={user?.avatar_url}
onClick={toggleAccountMenu}
isLoading={isLoading}
/>
<UserAvatar avatarUrl={user?.avatar_url} isLoading={isLoading} />
{(shouldShowUserActions || isOSS) && (
{shouldShowUserActions && user && (
<div
className={cn(
"opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
showMenu && "opacity-100 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:absolute group-hover:before:bottom-0 group-hover:before:right-0 group-hover:before:w-[200px] group-hover:before:h-[300px]",
)}
>
<AccountSettingsContextMenu
onLogout={handleLogout}
<UserContextMenu
type={me?.role || "user"}
onClose={closeAccountMenu}
/>
</div>

View File

@@ -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 && (

View File

@@ -0,0 +1,193 @@
import React from "react";
import ReactDOM from "react-dom";
import { Link, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import {
IoCardOutline,
IoLogOutOutline,
IoPersonAddOutline,
IoPersonOutline,
} from "react-icons/io5";
import { useLogout } from "#/hooks/mutation/use-logout";
import { OrganizationUserRole } from "#/types/org";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { InviteOrganizationMemberModal } from "../org/invite-organization-member-modal";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { useOrganizations } from "#/hooks/query/use-organizations";
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
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";
// Shared className for context menu list items in the user context menu
// Removes default padding and hover background to match the simpler text-hover style
const contextMenuListItemClassName = cn(
"flex items-center p-0 h-auto hover:bg-transparent hover:text-white gap-1",
);
interface UserContextMenuProps {
type: OrganizationUserRole;
onClose: () => void;
}
export function UserContextMenu({ type, onClose }: UserContextMenuProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { orgId, setOrgId } = useSelectedOrganizationId();
const { data: organizations } = useOrganizations();
const { mutate: logout } = useLogout();
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
// Get nav items from the shared hook (already filtered by feature flags)
// Then filter out org-related items since they're handled separately in this menu
const settingsNavItems = useSettingsNavItems();
const navItems = settingsNavItems.filter(
(item) =>
item.to !== "/settings/org-members" && item.to !== "/settings/org",
);
const [inviteMemberModalIsOpen, setInviteMemberModalIsOpen] =
React.useState(false);
const isUser = type === "user";
const handleLogout = () => {
logout();
onClose();
};
const handleInviteMemberClick = () => {
setInviteMemberModalIsOpen(true);
};
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-64 flex flex-col gap-3 bg-tertiary border border-tertiary rounded-xl p-6",
"text-sm absolute left-full bottom-0 z-60",
)}
>
{inviteMemberModalIsOpen &&
ReactDOM.createPortal(
<InviteOrganizationMemberModal
onClose={() => setInviteMemberModalIsOpen(false)}
/>,
document.getElementById("portal-root") || document.body,
)}
<h3 className="text-lg font-semibold text-white">
{t(I18nKey.ORG$ACCOUNT)}
</h3>
<div className="flex flex-col items-start gap-2">
<div className="w-full relative">
<SettingsDropdownInput
testId="org-selector"
name="organization"
placeholder="Please select an organization"
selectedKey={orgId || "personal"}
items={[
{ key: "personal", label: "Personal Account" },
...(organizations?.map((org) => ({
key: org.id,
label: org.name,
})) || []),
]}
onSelectionChange={(org) => {
if (org === "personal") {
setOrgId(null);
} else if (org) {
setOrgId(org.toString());
} else {
setOrgId(null);
}
}}
/>
</div>
{!isUser && (
<>
<ContextMenuListItem
onClick={handleInviteMemberClick}
className={contextMenuListItemClassName}
>
<IoPersonAddOutline className="text-white" size={14} />
{t(I18nKey.ORG$INVITE_ORGANIZATION_MEMBER)}
</ContextMenuListItem>
<Divider className="my-1.5" />
<ContextMenuListItem
onClick={handleManageAccountClick}
className={contextMenuListItemClassName}
>
<IoCardOutline className="text-white" size={14} />
{t(I18nKey.ORG$MANAGE_ACCOUNT)}
</ContextMenuListItem>
<ContextMenuListItem
onClick={handleManageOrganizationMembersClick}
className={contextMenuListItemClassName}
>
<IoPersonOutline className="text-white" size={14} />
{t(I18nKey.ORG$MANAGE_ORGANIZATION_MEMBERS)}
</ContextMenuListItem>
</>
)}
<Divider className="my-1.5" />
{navItems.map((item) => (
<Link
key={item.to}
to={item.to}
onClick={onClose}
className="flex items-center gap-1 cursor-pointer hover:text-white w-full"
>
{React.cloneElement(item.icon, {
className: "text-white",
width: 14,
height: 14,
} as React.SVGProps<SVGSVGElement>)}
{t(item.text)}
</Link>
))}
<Divider className="my-1.5" />
<a
href="https://docs.openhands.dev"
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
className="flex items-center gap-1 cursor-pointer hover:text-white 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>
);
}

View File

@@ -118,7 +118,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}

View File

@@ -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[] = [

View File

@@ -0,0 +1,17 @@
import { useRevalidator } from "react-router";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
export const useSelectedOrganizationId = () => {
const revalidator = useRevalidator();
const { organizationId: orgId, setOrganizationId: setOrganizationIdStore } =
useSelectedOrganizationStore();
const setOrgId = (newOrganizationId: string | null) => {
setOrganizationIdStore(newOrganizationId);
// Revalidate route to ensure the latest orgId is used.
// This is useful for redirecting the user away from admin-only org pages.
revalidator.revalidate();
};
return { orgId, setOrgId };
};

View File

@@ -0,0 +1,22 @@
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 { orgId, setOrgId } = useSelectedOrganizationId();
return useMutation({
mutationFn: () => {
if (!orgId) throw new Error("Organization ID is required");
return organizationService.deleteOrganization({ orgId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizations"] });
setOrgId(null);
navigate("/");
},
});
};

View File

@@ -0,0 +1,18 @@
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 useInviteMembersBatch = () => {
const queryClient = useQueryClient();
const { orgId } = useSelectedOrganizationId();
return useMutation({
mutationFn: ({ emails }: { emails: string[] }) =>
organizationService.inviteMembers({ orgId: orgId!, emails }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["organizations", "members", orgId],
});
},
});
};

View File

@@ -0,0 +1,18 @@
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 useRemoveMember = () => {
const queryClient = useQueryClient();
const { orgId } = useSelectedOrganizationId();
return useMutation({
mutationFn: ({ userId }: { userId: string }) =>
organizationService.removeMember({ orgId: orgId!, userId }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["organizations", "members", orgId],
});
},
});
};

View File

@@ -0,0 +1,31 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { OrganizationUserRole } from "#/types/org";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
export const useUpdateMemberRole = () => {
const queryClient = useQueryClient();
const { orgId } = useSelectedOrganizationId();
return useMutation({
mutationFn: async ({
userId,
role,
}: {
userId: string;
role: OrganizationUserRole;
}) => {
if (!orgId) {
throw new Error("Organization ID is required to update member role");
}
return organizationService.updateMember({
orgId,
userId,
role,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizations", "members"] });
},
});
};

View File

@@ -0,0 +1,18 @@
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 { orgId } = useSelectedOrganizationId();
return useMutation({
mutationFn: (name: string) => {
if (!orgId) throw new Error("Organization ID is required");
return organizationService.updateOrganization({ orgId, name });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizations", orgId] });
},
});
};

View File

@@ -0,0 +1,17 @@
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 { orgId } = useSelectedOrganizationId();
const isSaas = config?.APP_MODE === "saas";
return useQuery({
queryKey: ["organizations", orgId, "me"],
queryFn: () => organizationService.getMe({ orgId: orgId! }),
enabled: isSaas && !!orgId,
});
};

View 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 useOrganizationMembers = () => {
const { orgId } = useSelectedOrganizationId();
return useQuery({
queryKey: ["organizations", "members", orgId],
queryFn: () =>
organizationService.getOrganizationMembers({ orgId: orgId! }),
enabled: !!orgId,
});
};

View 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 useOrganizationPaymentInfo = () => {
const { orgId } = useSelectedOrganizationId();
return useQuery({
queryKey: ["organizations", orgId, "payment"],
queryFn: () =>
organizationService.getOrganizationPaymentInfo({ orgId: orgId! }),
enabled: !!orgId,
});
};

View File

@@ -0,0 +1,13 @@
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 { orgId } = useSelectedOrganizationId();
return useQuery({
queryKey: ["organizations", orgId],
queryFn: () => organizationService.getOrganization({ orgId: orgId! }),
enabled: !!orgId,
});
};

View File

@@ -0,0 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { organizationService } from "#/api/organization-service/organization-service.api";
export const useOrganizations = () =>
useQuery({
queryKey: ["organizations"],
queryFn: organizationService.getOrganizations,
});

View File

@@ -5,11 +5,18 @@ export function useSettingsNavItems() {
const { data: config } = useConfig();
const shouldHideLlmSettings = !!config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS;
const shouldHideBilling = !!config?.FEATURE_FLAGS?.HIDE_BILLING;
const isSaasMode = config?.APP_MODE === "saas";
const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
let items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
return shouldHideLlmSettings
? items.filter((item) => item.to !== "/settings")
: items;
if (shouldHideLlmSettings) {
items = items.filter((item) => item.to !== "/settings");
}
if (shouldHideBilling) {
items = items.filter((item) => item.to !== "/settings/billing");
}
return items;
}

View File

@@ -204,6 +204,7 @@ export enum I18nKey {
MODAL$END_SESSION_MESSAGE = "MODAL$END_SESSION_MESSAGE",
BUTTON$END_SESSION = "BUTTON$END_SESSION",
BUTTON$CANCEL = "BUTTON$CANCEL",
BUTTON$ADD = "BUTTON$ADD",
EXIT_PROJECT$CONFIRM = "EXIT_PROJECT$CONFIRM",
EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE",
LANGUAGE$LABEL = "LANGUAGE$LABEL",
@@ -699,6 +700,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$GITHUB_TOKEN_HELP_LINK = "GIT$GITHUB_TOKEN_HELP_LINK",
@@ -958,6 +964,31 @@ 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 = "ORG$INVITE_USERS",
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_USER = "ORG$ROLE_USER",
ORG$ROLE_OWNER = "ORG$ROLE_OWNER",
ORG$REMOVE = "ORG$REMOVE",
ORG$ACCOUNT = "ORG$ACCOUNT",
ORG$INVITE_TEAM = "ORG$INVITE_TEAM",
ORG$MANAGE_ACCOUNT = "ORG$MANAGE_ACCOUNT",
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",
ACCOUNT_SETTINGS$SETTINGS = "ACCOUNT_SETTINGS$SETTINGS",
ORG$INVITE_ORGANIZATION_MEMBER = "ORG$INVITE_ORGANIZATION_MEMBER",
ORG$MANAGE_ORGANIZATION_MEMBERS = "ORG$MANAGE_ORGANIZATION_MEMBERS",
CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS",
SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE",
}

View File

@@ -3263,6 +3263,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": "プロジェクトを終了",
@@ -11183,6 +11199,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トークンヘルプリンク",
@@ -15327,6 +15423,406 @@
"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": {
"en": "Invite Users",
"ja": "ユーザーを招待",
"zh-CN": "邀请用户",
"zh-TW": "邀請用戶",
"ko-KR": "사용자 초대",
"no": "Inviter brukere",
"it": "Invita utenti",
"pt": "Convidar usuários",
"es": "Invitar usuarios",
"ar": "دعوة المستخدمين",
"fr": "Inviter des utilisateurs",
"tr": "Kullanıcı Davet Et",
"de": "Benutzer einladen",
"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_USER": {
"en": "user",
"ja": "ユーザー",
"zh-CN": "用户",
"zh-TW": "用戶",
"ko-KR": "사용자",
"no": "bruker",
"it": "utente",
"pt": "usuário",
"es": "usuario",
"ar": "مستخدم",
"fr": "utilisateur",
"tr": "kullanıcı",
"de": "Benutzer",
"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$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_ACCOUNT": {
"en": "Manage Account",
"ja": "アカウント管理",
"zh-CN": "管理账户",
"zh-TW": "管理帳戶",
"ko-KR": "계정 관리",
"no": "Administrer konto",
"it": "Gestisci account",
"pt": "Gerenciar conta",
"es": "Administrar cuenta",
"ar": "إدارة الحساب",
"fr": "Gérer le compte",
"tr": "Hesabı Yönet",
"de": "Konto verwalten",
"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 Org Name",
"ja": "組織名を変更",
"zh-CN": "更改组织名称",
"zh-TW": "更改組織名稱",
"ko-KR": "조직 이름 변경",
"no": "Endre organisasjonsnavn",
"it": "Cambia nome organizzazione",
"pt": "Alterar nome da organização",
"es": "Cambiar nombre de organización",
"ar": "تغيير اسم المنظمة",
"fr": "Changer le nom de l'organisation",
"tr": "Organizasyon Adını Değiştir",
"de": "Organisationsname ändern",
"uk": "Змінити назву організації"
},
"ORG$MODIFY_ORG_NAME_DESCRIPTION": {
"en": "Modify your Org Name and Save",
"ja": "組織名を変更して保存",
"zh-CN": "修改您的组织名称并保存",
"zh-TW": "修改您的組織名稱並儲存",
"ko-KR": "조직 이름을 수정하고 저장",
"no": "Endre organisasjonsnavnet og lagre",
"it": "Modifica il nome dell'organizzazione e salva",
"pt": "Modifique o nome da organização e salve",
"es": "Modifica el nombre de la organización y guarda",
"ar": "قم بتعديل اسم المنظمة واحفظ",
"fr": "Modifiez le nom de l'organisation et enregistrez",
"tr": "Organizasyon adını değiştir ve kaydet",
"de": "Organisationsname ändern und speichern",
"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": "Видалити організацію"
},
"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$INVITE_ORGANIZATION_MEMBER": {
"en": "Invite Organization Member",
"ja": "組織メンバーを招待",
"zh-CN": "邀请组织成员",
"zh-TW": "邀請組織成員",
"ko-KR": "조직 구성원 초대",
"no": "Inviter organisasjonsmedlem",
"it": "Invita membro dell'organizzazione",
"pt": "Convidar membro da organização",
"es": "Invitar miembro de la organización",
"ar": "دعوة عضو في المنظمة",
"fr": "Inviter un membre de l'organisation",
"tr": "Organizasyon Üyesi Davet Et",
"de": "Organisationsmitglied einladen",
"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": "Керувати учасниками організації"
},
"CONVERSATION$SHOW_SKILLS": {
"en": "Show Available Skills",
"ja": "利用可能なスキルを表示",

View 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

View File

@@ -2,6 +2,7 @@ import { STRIPE_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,
@@ -14,6 +15,7 @@ import { FEEDBACK_HANDLERS } from "./feedback-handlers";
import { ANALYTICS_HANDLERS } from "./analytics-handlers";
export const handlers = [
...ORG_HANDLERS,
...STRIPE_BILLING_HANDLERS,
...FILE_SERVICE_HANDLERS,
...TASK_SUGGESTIONS_HANDLERS,

View File

@@ -0,0 +1,447 @@
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",
};
const createMockOrganization = (
id: string,
name: string,
credits: number,
): 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,
});
export const INITIAL_MOCK_ORGS: Organization[] = [
createMockOrganization("1", "Acme Corp", 1000),
createMockOrganization("2", "Beta LLC", 500),
createMockOrganization("3", "All Hands AI", 750),
];
const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
"1": [
{
org_id: "1",
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: "user",
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: "4",
email: "tony@gamma.org",
role: "user",
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",
},
],
"3": [
{
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: "user",
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: "user",
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: "user",
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 })),
};
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 = "user";
switch (orgId) {
case "1":
role = "owner";
break;
case "2":
role = "user";
break;
case "3":
role = "admin";
break;
default:
role = "user";
}
const me: OrganizationMember = {
...MOCK_ME,
org_id: orgId,
role,
};
return HttpResponse.json(me);
}),
http.get("/api/organizations/:orgId/members", ({ params }) => {
const orgId = params.orgId?.toString();
if (!orgId || !ORGS_AND_MEMBERS[orgId]) {
return HttpResponse.json(
{ error: "Organization not found" },
{ status: 404 },
);
}
const members = ORGS_AND_MEMBERS[orgId];
return HttpResponse.json(members);
}),
http.get("/api/organizations", () => {
const organizations = Array.from(orgs.values());
return HttpResponse.json(organizations);
}),
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/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: "user" 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 });
},
),
];

View File

@@ -76,6 +76,7 @@ export const SETTINGS_HANDLERS = [
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: mockSaas,
HIDE_BILLING: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,

View File

@@ -18,6 +18,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"),

View File

@@ -0,0 +1,344 @@
import React from "react";
import { redirect } from "react-router";
import { useTranslation } from "react-i18next";
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
import { useOrganization } from "#/hooks/query/use-organization";
import { useOrganizationPaymentInfo } from "#/hooks/query/use-organization-payment-info";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { cn } from "#/utils/utils";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { BrandButton } from "#/components/features/settings/brand-button";
import { useMe } from "#/hooks/query/use-me";
import { useConfig } from "#/hooks/query/use-config";
import { rolePermissions } from "#/utils/org/permissions";
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
import { getMeFromQueryClient } from "#/utils/query-client-getters";
import { queryClient } from "#/query-client-config";
import { I18nKey } from "#/i18n/declaration";
import { amountIsValid } from "#/utils/amount-is-valid";
import { useUpdateOrganization } from "#/hooks/mutation/use-update-organization";
import { useDeleteOrganization } from "#/hooks/mutation/use-delete-organization";
import { CreditsChip } from "#/ui/credits-chip";
import { InteractiveChip } from "#/ui/interactive-chip";
interface ChangeOrgNameModalProps {
onClose: () => void;
}
function ChangeOrgNameModal({ onClose }: ChangeOrgNameModalProps) {
const { t } = useTranslation();
const { mutate: updateOrganization } = useUpdateOrganization();
const formAction = (formData: FormData) => {
const orgName = formData.get("org-name")?.toString();
if (orgName?.trim()) {
updateOrganization(orgName, {
onSuccess: () => {
onClose();
},
});
}
};
return (
<ModalBackdrop onClose={onClose}>
<form
action={formAction}
data-testid="update-org-name-form"
className={cn(
"bg-base rounded-xl p-4 border w-sm border-tertiary items-start",
"flex flex-col gap-6",
)}
>
<div className="flex flex-col gap-2 w-full">
<h3 className="text-lg font-semibold">
{t(I18nKey.ORG$CHANGE_ORG_NAME)}
</h3>
<p className="text-xs text-gray-400">
{t(I18nKey.ORG$MODIFY_ORG_NAME_DESCRIPTION)}
</p>
<SettingsInput
name="org-name"
type="text"
required
className="w-full"
label="Organization Name"
placeholder="Enter new organization name"
/>
</div>
<BrandButton variant="primary" type="submit" className="w-full">
{t(I18nKey.BUTTON$SAVE)}
</BrandButton>
</form>
</ModalBackdrop>
);
}
interface DeleteOrgConfirmationModalProps {
onClose: () => void;
}
function DeleteOrgConfirmationModal({
onClose,
}: DeleteOrgConfirmationModalProps) {
const { t } = useTranslation();
const { mutate: deleteOrganization } = useDeleteOrganization();
return (
<div data-testid="delete-org-confirmation">
<button
type="button"
onClick={() =>
deleteOrganization(undefined, {
onSuccess: onClose,
})
}
>
{t(I18nKey.BUTTON$CONFIRM)}
</button>
</div>
);
}
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);
// Clear error message when user starts typing again
setErrorMessage(null);
};
return (
<ModalBackdrop>
<form
data-testid="add-credits-form"
action={formAction}
noValidate
className="w-md rounded-xl bg-[#171717] flex flex-col p-6 gap-6"
>
<div className="flex flex-col gap-2">
<h3 className="text-xl font-semibold">
{t(I18nKey.ORG$ADD_CREDITS)}
</h3>
<input
data-testid="amount-input"
name="amount"
type="number"
className="text-lg bg-[#27272A] p-2"
placeholder={t(I18nKey.PAYMENT$SPECIFY_AMOUNT_USD)}
min={10}
max={25000}
step={1}
value={inputValue}
onChange={(e) => handleAmountInputChange(e.target.value)}
/>
{errorMessage && (
<p className="text-red-500 text-sm mt-1" data-testid="amount-error">
{errorMessage}
</p>
)}
</div>
<div className="flex gap-2">
<BrandButton type="submit" variant="primary" className="flex-1 py-3">
{t(I18nKey.ORG$NEXT)}
</BrandButton>
<BrandButton
type="button"
onClick={onClose}
variant="secondary"
className="flex-1 py-3"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</div>
</form>
</ModalBackdrop>
);
}
export const clientLoader = async () => {
const selectedOrgId = getSelectedOrganizationIdFromStore();
let me = getMeFromQueryClient(selectedOrgId);
if (!me && selectedOrgId) {
me = await organizationService.getMe({ orgId: selectedOrgId });
queryClient.setQueryData(["organizations", selectedOrgId, "me"], me);
}
if (!me || me.role === "user") {
// if user is USER role, redirect to user settings
return redirect("/settings/user");
}
return null;
};
function ManageOrg() {
const { t } = useTranslation();
const { data: me } = useMe();
const { data: organization } = useOrganization();
const { data: organizationPaymentInfo } = useOrganizationPaymentInfo();
const { data: config } = useConfig();
const [addCreditsFormVisible, setAddCreditsFormVisible] =
React.useState(false);
const [changeOrgNameFormVisible, setChangeOrgNameFormVisible] =
React.useState(false);
const [deleteOrgConfirmationVisible, setDeleteOrgConfirmationVisible] =
React.useState(false);
const canChangeOrgName =
!!me && rolePermissions[me.role].includes("change_organization_name");
const canDeleteOrg =
!!me && rolePermissions[me.role].includes("delete_organization");
const canAddCredits =
!!me && rolePermissions[me.role].includes("add_credits");
const isBillingHidden = config?.FEATURE_FLAGS?.HIDE_BILLING;
return (
<div
data-testid="manage-org-screen"
className="flex flex-col items-start gap-6 px-11 py-6"
>
{changeOrgNameFormVisible && (
<ChangeOrgNameModal
onClose={() => setChangeOrgNameFormVisible(false)}
/>
)}
{deleteOrgConfirmationVisible && (
<DeleteOrgConfirmationModal
onClose={() => setDeleteOrgConfirmationVisible(false)}
/>
)}
{!isBillingHidden && (
<div className="flex flex-col gap-2">
<span className="text-white text-xs font-semibold ml-1">
{t(I18nKey.ORG$CREDITS)}
</span>
<div className="flex items-center gap-2">
<CreditsChip testId="available-credits">
{organization?.credits}
</CreditsChip>
{canAddCredits && (
<InteractiveChip onClick={() => setAddCreditsFormVisible(true)}>
{t(I18nKey.ORG$ADD)}
</InteractiveChip>
)}
</div>
</div>
)}
{addCreditsFormVisible && !isBillingHidden && (
<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 ml-1">
{t(I18nKey.ORG$ORGANIZATION_NAME)}
</span>
<div
className={cn(
"text-sm p-3 bg-base rounded",
"flex items-center justify-between",
)}
>
<span className="text-white">{organization?.name}</span>
{canChangeOrgName && (
<button
type="button"
onClick={() => setChangeOrgNameFormVisible(true)}
className="text-[#A3A3A3] hover:text-white transition-colors cursor-pointer"
>
{t(I18nKey.ORG$CHANGE)}
</button>
)}
</div>
</div>
{!isBillingHidden && (
<div className="flex flex-col gap-2 w-sm">
<span className="text-white text-xs font-semibold ml-1">
{t(I18nKey.ORG$BILLING_INFORMATION)}
</span>
<span
data-testid="billing-info"
className={cn(
"text-sm p-3 bg-base rounded text-[#A3A3A3]",
"flex items-center justify-between",
)}
>
{organizationPaymentInfo?.cardNumber}
</span>
</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;

View File

@@ -0,0 +1,152 @@
import React from "react";
import ReactDOM from "react-dom";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { redirect } from "react-router";
import { InviteOrganizationMemberModal } from "#/components/features/org/invite-organization-member-modal";
import { useOrganizationMembers } from "#/hooks/query/use-organization-members";
import { OrganizationUserRole } from "#/types/org";
import { OrganizationMemberListItem } from "#/components/features/org/organization-member-list-item";
import { useUpdateMemberRole } from "#/hooks/mutation/use-update-member-role";
import { useRemoveMember } from "#/hooks/mutation/use-remove-member";
import { useMe } from "#/hooks/query/use-me";
import { BrandButton } from "#/components/features/settings/brand-button";
import { rolePermissions } from "#/utils/org/permissions";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { queryClient } from "#/query-client-config";
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
import { getMeFromQueryClient } from "#/utils/query-client-getters";
import { I18nKey } from "#/i18n/declaration";
export const clientLoader = async () => {
const selectedOrgId = getSelectedOrganizationIdFromStore();
let me = getMeFromQueryClient(selectedOrgId);
if (!me && selectedOrgId) {
me = await organizationService.getMe({ orgId: selectedOrgId });
queryClient.setQueryData(["organizations", selectedOrgId, "me"], me);
}
if (!me || me.role === "user") {
// if user is USER role, redirect to user settings
return redirect("/settings/user");
}
return null;
};
function ManageOrganizationMembers() {
const { t } = useTranslation();
const { data: organizationMembers } = useOrganizationMembers();
const { data: user } = useMe();
const { mutate: updateMemberRole } = useUpdateMemberRole();
const { mutate: removeMember } = useRemoveMember();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const currentUserRole = user?.role || "user";
const hasPermissionToInvite = rolePermissions[currentUserRole].includes(
"invite_user_to_organization",
);
const handleRoleSelectionClick = (id: string, role: OrganizationUserRole) => {
updateMemberRole({ userId: id, role });
};
const handleRemoveMember = (userId: string) => {
removeMember({ userId });
};
const checkIfUserHasPermissionToChangeRole = (
memberId: string,
memberRole: OrganizationUserRole,
) => {
if (!user) return false;
// Users cannot change their own role
if (memberId === user.user_id) return false;
// Owners cannot change another owner's role
if (user.role === "owner" && memberRole === "owner") return false;
// Admins cannot change another admin's role
if (user.role === "admin" && memberRole === "admin") return false;
const userPermissions = rolePermissions[user.role];
return userPermissions.includes(`change_user_role:${memberRole}`);
};
const availableRolesToChangeTo = React.useMemo((): OrganizationUserRole[] => {
if (!user) return [];
const availableRoles: OrganizationUserRole[] = [];
const userPermissions = rolePermissions[user.role];
if (userPermissions.includes("change_user_role:owner")) {
availableRoles.push("owner");
}
if (userPermissions.includes("change_user_role:admin")) {
availableRoles.push("admin");
}
if (userPermissions.includes("change_user_role:user")) {
availableRoles.push("user");
}
return availableRoles;
}, [user]);
return (
<div
data-testid="manage-organization-members-settings"
className="px-11 py-6 flex flex-col gap-2"
>
{hasPermissionToInvite && (
<BrandButton
type="button"
variant="secondary"
onClick={() => setInviteModalOpen(true)}
className="flex items-center gap-1"
>
<Plus size={14} />
{t(I18nKey.ORG$INVITE_ORGANIZATION_MEMBER)}
</BrandButton>
)}
{inviteModalOpen &&
ReactDOM.createPortal(
<InviteOrganizationMemberModal
onClose={() => setInviteModalOpen(false)}
/>,
document.getElementById("portal-root") || document.body,
)}
{organizationMembers && (
<ul>
{organizationMembers.map((member) => (
<li
key={member.user_id}
data-testid="member-item"
className="border-b border-tertiary"
>
<OrganizationMemberListItem
email={member.email}
role={member.role}
status={member.status}
hasPermissionToChangeRole={checkIfUserHasPermissionToChangeRole(
member.user_id,
member.role,
)}
availableRolesToChangeTo={availableRolesToChangeTo}
onRoleChange={(role) =>
handleRoleSelectionClick(member.user_id, role)
}
onRemove={() => handleRemoveMember(member.user_id)}
/>
</li>
))}
</ul>
)}
</div>
);
}
export default ManageOrganizationMembers;

View File

@@ -14,6 +14,8 @@ const SAAS_ONLY_PATHS = [
"/settings/billing",
"/settings/credits",
"/settings/api-keys",
"/settings/team",
"/settings/org",
];
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
@@ -38,6 +40,15 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
return isSaas ? redirect("/settings/user") : redirect("/settings/mcp");
}
// If billing is hidden and user tries to access the billing page
if (config?.FEATURE_FLAGS?.HIDE_BILLING && pathname === "/settings/billing") {
// Redirect to the first available settings page
if (isSaas) {
return redirect("/settings/user");
}
return redirect("/settings/mcp");
}
return null;
};

View File

@@ -0,0 +1,30 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface SelectedOrganizationState {
organizationId: string | null;
}
interface SelectedOrganizationActions {
setOrganizationId: (organizationId: string | null) => void;
}
type SelectedOrganizationStore = SelectedOrganizationState &
SelectedOrganizationActions;
const initialState: SelectedOrganizationState = {
organizationId: null,
};
export const useSelectedOrganizationStore = create<SelectedOrganizationStore>()(
devtools(
(set) => ({
...initialState,
setOrganizationId: (organizationId) => set({ organizationId }),
}),
{ name: "SelectedOrganizationStore" },
),
);
export const getSelectedOrganizationIdFromStore = (): string | null =>
useSelectedOrganizationStore.getState().organizationId;

51
frontend/src/types/org.ts Normal file
View File

@@ -0,0 +1,51 @@
export type OrganizationUserRole = "user" | "admin" | "owner";
export interface Organization {
id: string;
name: string;
contact_name: string;
contact_email: string;
conversation_expiration: number;
agent: string;
default_max_iterations: number;
security_analyzer: string;
confirmation_mode: boolean;
default_llm_model: string;
default_llm_api_key_for_byor: string;
default_llm_base_url: string;
remote_runtime_resource_factor: number;
enable_default_condenser: boolean;
billing_margin: number;
enable_proactive_conversation_starters: boolean;
sandbox_base_container_image: string;
sandbox_runtime_container_image: string;
org_version: number;
mcp_config: {
tools: unknown[];
settings: Record<string, unknown>;
};
search_api_key: string | null;
sandbox_api_key: string | null;
max_budget_per_task: number;
enable_solvability_analysis: boolean;
v1_enabled: boolean;
credits: number;
}
export interface OrganizationMember {
org_id: string;
user_id: string;
email: string;
role: OrganizationUserRole;
llm_api_key: string;
max_iterations: number;
llm_model: string;
llm_api_key_for_byor: string | null;
llm_base_url: string;
status: "active" | "invited" | "inactive";
}
/** org_id and user_id are provided via URL params */
export type UpdateOrganizationMemberParams = Partial<
Omit<OrganizationMember, "org_id" | "user_id">
>;

View File

@@ -0,0 +1,30 @@
import { cn } from "#/utils/utils";
interface ContextMenuIconTextProps {
icon: React.ReactNode;
text: string;
rightIcon?: React.ReactNode;
className?: string;
}
export function ContextMenuIconText({
icon,
text,
rightIcon,
className,
}: ContextMenuIconTextProps) {
return (
<div
className={cn(
"flex items-center justify-between p-2 hover:bg-[#5C5D62] rounded",
className,
)}
>
<div className="flex items-center gap-2">
{icon}
{text}
</div>
{rightIcon && <div className="flex items-center">{rightIcon}</div>}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { cn } from "#/utils/utils";
interface CreditsChipProps {
testId?: string;
className?: string;
}
/**
* Chip component for displaying credits amount
* Uses yellow background with black text for visibility
*/
export function CreditsChip({
children,
testId,
className,
}: React.PropsWithChildren<CreditsChipProps>) {
return (
<div
data-testid={testId}
data-openhands-chip
style={{ minWidth: "100px" }}
className={cn(
"bg-[#FFE165] px-4 rounded-[100px] text-black text-lg text-center font-semibold",
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { cn } from "#/utils/utils";
interface InteractiveChipProps {
onClick: () => void;
testId?: string;
className?: string;
}
/**
* Small clickable chip component for actions like "Add"
* Uses gray background with black text
*/
export function InteractiveChip({
children,
onClick,
testId,
className,
}: React.PropsWithChildren<InteractiveChipProps>) {
return (
<button
type="button"
data-testid={testId}
onClick={onClick}
className={cn(
"bg-[#E4E4E4] px-2 rounded-[100px] text-black text-sm text-center font-semibold cursor-pointer",
"hover:bg-[#D4D4D4] transition-colors",
className,
)}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,2 @@
export type GetComponentPropTypes<T> =
T extends React.ComponentType<infer P> ? P : never;

View File

@@ -0,0 +1,41 @@
import { OrganizationUserRole } from "#/types/org";
type UserRoleChangePermissionKey = "change_user_role";
type InviteUserToOrganizationKey = "invite_user_to_organization";
type ChangeUserRolePermission =
`${UserRoleChangePermissionKey}:${OrganizationUserRole}`;
type ChangeOrganizationNamePermission = "change_organization_name";
type DeleteOrganizationPermission = "delete_organization";
type AddCreditsPermission = "add_credits";
type UserPermission =
| InviteUserToOrganizationKey
| ChangeUserRolePermission
| ChangeOrganizationNamePermission
| DeleteOrganizationPermission
| AddCreditsPermission;
const ownerPerms: UserPermission[] = [
"invite_user_to_organization",
"change_organization_name",
"delete_organization",
"add_credits",
"change_user_role:owner",
"change_user_role:admin",
"change_user_role:user",
];
const adminPerms: UserPermission[] = [
"invite_user_to_organization",
"add_credits",
"change_user_role:admin",
"change_user_role:user",
];
const userPerms: UserPermission[] = [];
export const rolePermissions: Record<OrganizationUserRole, UserPermission[]> = {
owner: ownerPerms,
admin: adminPerms,
user: userPerms,
};

View File

@@ -0,0 +1,5 @@
import { queryClient } from "#/query-client-config";
import { OrganizationMember } from "#/types/org";
export const getMeFromQueryClient = (orgId: string | null) =>
queryClient.getQueryData<OrganizationMember>(["organizations", orgId, "me"]);

View File

@@ -1,12 +1,12 @@
// Test utilities for React components
import React, { PropsWithChildren } from "react";
import { RenderOptions, render } from "@testing-library/react";
import { RenderOptions, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18n from "i18next";
import { vi } from "vitest";
import { expect, vi } from "vitest";
import { AxiosError } from "axios";
import userEvent from "@testing-library/user-event";
import { INITIAL_MOCK_ORGS } from "#/mocks/org-handlers";
// Mock useParams before importing components
vi.mock("react-router", async () => {
@@ -15,6 +15,9 @@ vi.mock("react-router", async () => {
return {
...actual,
useParams: () => ({ conversationId: "test-conversation-id" }),
useRevalidator: () => ({
revalidate: vi.fn(),
}),
};
});
@@ -74,3 +77,24 @@ export const createAxiosNotFoundErrorObject = () =>
config: {},
},
);
export const selectOrganization = async ({
orgIndex,
}: {
orgIndex: number;
}) => {
const organizationSelect = await screen.findByTestId("org-select");
expect(organizationSelect).toBeInTheDocument();
await userEvent.click(organizationSelect);
// Wait for the options to appear in the popover
const targetOrg = INITIAL_MOCK_ORGS[orgIndex];
if (!targetOrg) {
expect.fail(`No organization found at index ${orgIndex}`);
}
// Find the option by its text content (organization name)
const option = await screen.findByText(targetOrg.name);
await userEvent.click(option);
};

View File

@@ -31,7 +31,7 @@ test("avatar context menu stays open when moving cursor diagonally to menu", asy
await page.mouse.move(avatarCenterX, avatarCenterY);
// The context menu should appear via CSS group-hover
const contextMenu = page.getByTestId("account-settings-context-menu");
const contextMenu = page.getByTestId("user-context-menu");
await expect(contextMenu).toBeVisible();
// Move UP from the LEFT side of the avatar - simulating diagonal movement

View File

@@ -10,7 +10,9 @@ window.scrollTo = vi.fn();
// Mock ResizeObserver for test environment
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}