mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
92 Commits
fix/git-ap
...
hotfix/con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f78bdafc7e | ||
|
|
89a9e73c8a | ||
|
|
a51b285021 | ||
|
|
d1a9731534 | ||
|
|
5362391045 | ||
|
|
ef58494544 | ||
|
|
87470f4cc6 | ||
|
|
03acf76af4 | ||
|
|
37aa4ab9d6 | ||
|
|
33f3861d95 | ||
|
|
4b6288a5ac | ||
|
|
fc3dd517df | ||
|
|
464f8ad56d | ||
|
|
1678993235 | ||
|
|
10fcbbcafa | ||
|
|
872e1a362c | ||
|
|
6187b8834d | ||
|
|
0dbb5b0d15 | ||
|
|
15aefdfb0a | ||
|
|
feeda18234 | ||
|
|
b32c7a65b5 | ||
|
|
05e254a725 | ||
|
|
945cc12d4e | ||
|
|
48b014f368 | ||
|
|
b5cf283deb | ||
|
|
77523bd9e8 | ||
|
|
967aa40918 | ||
|
|
b335d61f4e | ||
|
|
bbabc327e2 | ||
|
|
bd6e96958d | ||
|
|
8da45989b6 | ||
|
|
2ca43a026f | ||
|
|
87cdfae255 | ||
|
|
8265a5e2d3 | ||
|
|
fb2453e74b | ||
|
|
c8e51aa8ae | ||
|
|
968566fb64 | ||
|
|
5d4e72df5e | ||
|
|
5ce558b6a0 | ||
|
|
e24bd87275 | ||
|
|
65a165e2e3 | ||
|
|
25589d9cac | ||
|
|
30300a9192 | ||
|
|
5a03b74487 | ||
|
|
d68b2913b2 | ||
|
|
528d0132e7 | ||
|
|
13f14e589a | ||
|
|
70aee4f940 | ||
|
|
9d59387fe7 | ||
|
|
b280c9236d | ||
|
|
e1b39638a8 | ||
|
|
5bde31d9bd | ||
|
|
232932e707 | ||
|
|
7f5f771b2f | ||
|
|
bfdcd8a35f | ||
|
|
7ae7dd0af2 | ||
|
|
6e4539058e | ||
|
|
8f47778237 | ||
|
|
d179c885c9 | ||
|
|
d546194787 | ||
|
|
6a3c90c1f4 | ||
|
|
98aa68511a | ||
|
|
2da07f5c3f | ||
|
|
7f92df8664 | ||
|
|
7ba2c6d6e1 | ||
|
|
c5d3a9a7bc | ||
|
|
df8b1d1a1c | ||
|
|
7d8a3cc04c | ||
|
|
5001a06c5d | ||
|
|
0cd1e80ba7 | ||
|
|
f8e18781c9 | ||
|
|
396c5c7d8a | ||
|
|
525e840d8d | ||
|
|
8074fee5c8 | ||
|
|
d08e5fe07c | ||
|
|
c11da628f4 | ||
|
|
6d43a3b946 | ||
|
|
3c409b9e55 | ||
|
|
12f9f2f2ca | ||
|
|
a491756a20 | ||
|
|
fbcc7c650f | ||
|
|
e77776440b | ||
|
|
3a12d71bef | ||
|
|
4cc4fcf6ca | ||
|
|
fa3d5bbb32 | ||
|
|
2c71e13004 | ||
|
|
bce21c0906 | ||
|
|
eee2f796c7 | ||
|
|
f0f9fca965 | ||
|
|
d4dcf9f82f | ||
|
|
2f77fc542c | ||
|
|
b2e9167a0e |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(", ")}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { UserAvatar } from "#/components/features/sidebar/user-avatar";
|
||||
|
||||
describe("UserAvatar", () => {
|
||||
const onClickMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
onClickMock.mockClear();
|
||||
});
|
||||
|
||||
it("(default) should render the placeholder avatar when the user is logged out", () => {
|
||||
render(<UserAvatar onClick={onClickMock} />);
|
||||
render(<UserAvatar />);
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserAvatar onClick={onClickMock} />);
|
||||
|
||||
const userAvatarContainer = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatarContainer);
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should display the user's avatar when available", () => {
|
||||
render(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
/>,
|
||||
);
|
||||
render(<UserAvatar avatarUrl="https://example.com/avatar.png" />);
|
||||
|
||||
expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -43,24 +21,20 @@ describe("UserAvatar", () => {
|
||||
});
|
||||
|
||||
it("should display a loading spinner instead of an avatar when isLoading is true", () => {
|
||||
const { rerender } = render(<UserAvatar onClick={onClickMock} />);
|
||||
const { rerender } = render(<UserAvatar />);
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(<UserAvatar onClick={onClickMock} isLoading />);
|
||||
rerender(<UserAvatar isLoading />);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
isLoading
|
||||
/>,
|
||||
<UserAvatar avatarUrl="https://example.com/avatar.png" isLoading />,
|
||||
);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
862
frontend/__tests__/routes/manage-org.test.tsx
Normal file
862
frontend/__tests__/routes/manage-org.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
824
frontend/__tests__/routes/manage-organization-members.test.tsx
Normal file
824
frontend/__tests__/routes/manage-organization-members.test.tsx
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function SetupPaymentModal() {
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
isDisabled={isPending}
|
||||
onClick={mutate}
|
||||
onClick={() => mutate()}
|
||||
>
|
||||
{t(I18nKey.BILLING$PROCEED_TO_STRIPE)}
|
||||
</BrandButton>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface BrandButtonProps {
|
||||
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onClick?: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
startContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export function SettingsDropdownInput({
|
||||
isRequired={required}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
|
||||
popoverContent: "bg-tertiary rounded-xl",
|
||||
}}
|
||||
inputProps={{
|
||||
classNames: {
|
||||
|
||||
@@ -5,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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
193
frontend/src/components/features/user/user-context-menu.tsx
Normal file
193
frontend/src/components/features/user/user-context-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
17
frontend/src/context/use-selected-organization.ts
Normal file
17
frontend/src/context/use-selected-organization.ts
Normal 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 };
|
||||
};
|
||||
22
frontend/src/hooks/mutation/use-delete-organization.ts
Normal file
22
frontend/src/hooks/mutation/use-delete-organization.ts
Normal 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("/");
|
||||
},
|
||||
});
|
||||
};
|
||||
18
frontend/src/hooks/mutation/use-invite-members-batch.ts
Normal file
18
frontend/src/hooks/mutation/use-invite-members-batch.ts
Normal 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],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
18
frontend/src/hooks/mutation/use-remove-member.ts
Normal file
18
frontend/src/hooks/mutation/use-remove-member.ts
Normal 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],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
31
frontend/src/hooks/mutation/use-update-member-role.ts
Normal file
31
frontend/src/hooks/mutation/use-update-member-role.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
18
frontend/src/hooks/mutation/use-update-organization.ts
Normal file
18
frontend/src/hooks/mutation/use-update-organization.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
17
frontend/src/hooks/query/use-me.ts
Normal file
17
frontend/src/hooks/query/use-me.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
14
frontend/src/hooks/query/use-organization-members.ts
Normal file
14
frontend/src/hooks/query/use-organization-members.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export const useOrganizationMembers = () => {
|
||||
const { orgId } = useSelectedOrganizationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["organizations", "members", orgId],
|
||||
queryFn: () =>
|
||||
organizationService.getOrganizationMembers({ orgId: orgId! }),
|
||||
enabled: !!orgId,
|
||||
});
|
||||
};
|
||||
14
frontend/src/hooks/query/use-organization-payment-info.tsx
Normal file
14
frontend/src/hooks/query/use-organization-payment-info.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export const useOrganizationPaymentInfo = () => {
|
||||
const { orgId } = useSelectedOrganizationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["organizations", orgId, "payment"],
|
||||
queryFn: () =>
|
||||
organizationService.getOrganizationPaymentInfo({ orgId: orgId! }),
|
||||
enabled: !!orgId,
|
||||
});
|
||||
};
|
||||
13
frontend/src/hooks/query/use-organization.ts
Normal file
13
frontend/src/hooks/query/use-organization.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
8
frontend/src/hooks/query/use-organizations.ts
Normal file
8
frontend/src/hooks/query/use-organizations.ts
Normal 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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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": "利用可能なスキルを表示",
|
||||
|
||||
3
frontend/src/icons/admin.svg
Normal file
3
frontend/src/icons/admin.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 13 12" fill="none">
|
||||
<path d="M5.93266 9.60059H2.99809C2.0034 9.60059 1.19828 10.4057 1.19828 11.4004C1.19815 11.7727 0.860332 12.0653 0.473671 11.9873C0.187333 11.9299 -0.00314397 11.6658 3.8147e-05 11.373C0.0144066 9.72907 1.35287 8.40039 3.00004 8.40039H4.34476L5.93266 9.60059ZM8.99027 6.50586C9.0747 6.2466 9.44203 6.24656 9.52641 6.50586H9.52445L10.0977 8.26758H11.9502C12.2224 8.26758 12.3365 8.6166 12.1153 8.77734L10.6162 9.86523L11.1895 11.627C11.2738 11.8863 10.9765 12.103 10.7569 11.9424L9.25785 10.8535L7.75785 11.9424C7.53665 12.1028 7.24091 11.8863 7.32523 11.627L7.89848 9.86523L6.39945 8.77734C6.17827 8.61663 6.29243 8.26768 6.56449 8.26758H8.41703L8.99027 6.50586ZM5.39945 0C7.38708 0 8.99979 1.61204 9.00004 3.59961C9.00004 5.58739 7.38723 7.2002 5.39945 7.2002C3.41175 7.20011 1.79984 5.58734 1.79984 3.59961C1.80009 1.61209 3.4119 8.31798e-05 5.39945 0ZM5.39945 1.2002C4.07396 1.20028 3.00029 2.27416 3.00004 3.59961C3.00004 4.92527 4.07381 5.99992 5.39945 6C6.72517 6 7.79984 4.92532 7.79984 3.59961C7.79959 2.27411 6.72501 1.2002 5.39945 1.2002Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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,
|
||||
|
||||
447
frontend/src/mocks/org-handlers.ts
Normal file
447
frontend/src/mocks/org-handlers.ts
Normal 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 });
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
344
frontend/src/routes/manage-org.tsx
Normal file
344
frontend/src/routes/manage-org.tsx
Normal 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;
|
||||
152
frontend/src/routes/manage-organization-members.tsx
Normal file
152
frontend/src/routes/manage-organization-members.tsx
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
30
frontend/src/stores/selected-organization-store.ts
Normal file
30
frontend/src/stores/selected-organization-store.ts
Normal 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
51
frontend/src/types/org.ts
Normal 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">
|
||||
>;
|
||||
30
frontend/src/ui/context-menu-icon-text.tsx
Normal file
30
frontend/src/ui/context-menu-icon-text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/ui/credits-chip.tsx
Normal file
30
frontend/src/ui/credits-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/ui/interactive-chip.tsx
Normal file
33
frontend/src/ui/interactive-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
frontend/src/utils/get-component-prop-types.ts
Normal file
2
frontend/src/utils/get-component-prop-types.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type GetComponentPropTypes<T> =
|
||||
T extends React.ComponentType<infer P> ? P : never;
|
||||
41
frontend/src/utils/org/permissions.ts
Normal file
41
frontend/src/utils/org/permissions.ts
Normal 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,
|
||||
};
|
||||
5
frontend/src/utils/query-client-getters.ts
Normal file
5
frontend/src/utils/query-client-getters.ts
Normal 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"]);
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,9 @@ window.scrollTo = vi.fn();
|
||||
// Mock ResizeObserver for test environment
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn();
|
||||
|
||||
unobserve = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user