mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(frontend): add contextual info messages on LLM settings page (org project) (#13460)
This commit is contained in:
@@ -13,7 +13,39 @@ import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import type { OrganizationMember } from "#/types/org";
|
||||
import type { Organization, OrganizationMember } from "#/types/org";
|
||||
|
||||
/** Creates a mock Organization with default values for testing */
|
||||
const createMockOrganization = (
|
||||
overrides: Partial<Organization> & Pick<Organization, "id" | "name">,
|
||||
): Organization => ({
|
||||
contact_name: "",
|
||||
contact_email: "",
|
||||
conversation_expiration: 0,
|
||||
agent: "CodeActAgent",
|
||||
default_max_iterations: 20,
|
||||
security_analyzer: "",
|
||||
confirmation_mode: false,
|
||||
default_llm_model: "",
|
||||
default_llm_api_key_for_byor: "",
|
||||
default_llm_base_url: "",
|
||||
remote_runtime_resource_factor: 1,
|
||||
enable_default_condenser: true,
|
||||
billing_margin: 0,
|
||||
enable_proactive_conversation_starters: false,
|
||||
sandbox_base_container_image: "",
|
||||
sandbox_runtime_container_image: "",
|
||||
org_version: 1,
|
||||
mcp_config: { tools: [], settings: {} },
|
||||
search_api_key: null,
|
||||
sandbox_api_key: null,
|
||||
max_budget_per_task: 0,
|
||||
enable_solvability_analysis: false,
|
||||
v1_enabled: true,
|
||||
credits: 0,
|
||||
is_personal: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
@@ -1767,3 +1799,163 @@ describe("clientLoader permission checks", () => {
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Contextual info messages", () => {
|
||||
it("should show admin message when user is an admin in a team organization", async () => {
|
||||
// Arrange
|
||||
const orgId = "team-org-1";
|
||||
const adminMeData: OrganizationMember = {
|
||||
org_id: orgId,
|
||||
user_id: "1",
|
||||
email: "admin@example.com",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
};
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(adminMeData);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [
|
||||
createMockOrganization({
|
||||
id: orgId,
|
||||
name: "Team Org",
|
||||
is_personal: false,
|
||||
}),
|
||||
],
|
||||
currentOrgId: orgId,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderLlmSettingsScreen(orgId, adminMeData);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("llm-settings-info-message"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent(
|
||||
"SETTINGS$LLM_ADMIN_INFO",
|
||||
);
|
||||
});
|
||||
|
||||
it("should show member message when user is a member in a team organization", async () => {
|
||||
// Arrange
|
||||
const orgId = "team-org-2";
|
||||
const memberMeData: OrganizationMember = {
|
||||
org_id: orgId,
|
||||
user_id: "2",
|
||||
email: "member@example.com",
|
||||
role: "member",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
};
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(memberMeData);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [
|
||||
createMockOrganization({
|
||||
id: orgId,
|
||||
name: "Team Org",
|
||||
is_personal: false,
|
||||
}),
|
||||
],
|
||||
currentOrgId: orgId,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderLlmSettingsScreen(orgId, memberMeData);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("llm-settings-info-message"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent(
|
||||
"SETTINGS$LLM_MEMBER_INFO",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show info message in personal workspace", async () => {
|
||||
// Arrange
|
||||
const orgId = "personal-org-1";
|
||||
const ownerMeData: OrganizationMember = {
|
||||
org_id: orgId,
|
||||
user_id: "3",
|
||||
email: "user@example.com",
|
||||
role: "owner",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
};
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(ownerMeData);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [
|
||||
createMockOrganization({ id: orgId, name: "Personal", is_personal: true }),
|
||||
],
|
||||
currentOrgId: orgId,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderLlmSettingsScreen(orgId, ownerMeData);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-info-message"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show info message in OSS mode", async () => {
|
||||
// Arrange
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-info-message"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,6 +434,8 @@ export enum I18nKey {
|
||||
SETTINGS$OPENHANDS_API_KEY_HELP_TEXT = "SETTINGS$OPENHANDS_API_KEY_HELP_TEXT",
|
||||
SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX = "SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX",
|
||||
SETTINGS$LLM_BILLING_INFO = "SETTINGS$LLM_BILLING_INFO",
|
||||
SETTINGS$LLM_ADMIN_INFO = "SETTINGS$LLM_ADMIN_INFO",
|
||||
SETTINGS$LLM_MEMBER_INFO = "SETTINGS$LLM_MEMBER_INFO",
|
||||
SETTINGS$SEE_PRICING_DETAILS = "SETTINGS$SEE_PRICING_DETAILS",
|
||||
SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY",
|
||||
SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION",
|
||||
|
||||
@@ -6943,6 +6943,38 @@
|
||||
"de": "LLM-Nutzung wird zu Anbieterpreisen ohne Aufschlag abgerechnet.",
|
||||
"uk": "Використання LLM оплачується за тарифами провайдерів без надбавки."
|
||||
},
|
||||
"SETTINGS$LLM_ADMIN_INFO": {
|
||||
"en": "The LLM settings configured below will apply to all users of the organization.",
|
||||
"ja": "以下に設定されたLLM設定は、組織のすべてのユーザーに適用されます。",
|
||||
"zh-CN": "以下配置的LLM设置将应用于组织的所有用户。",
|
||||
"zh-TW": "以下配置的LLM設定將適用於組織的所有用戶。",
|
||||
"ko-KR": "아래에 구성된 LLM 설정은 조직의 모든 사용자에게 적용됩니다.",
|
||||
"no": "LLM-innstillingene som er konfigurert nedenfor, vil gjelde for alle brukere i organisasjonen.",
|
||||
"it": "Le impostazioni LLM configurate di seguito si applicheranno a tutti gli utenti dell'organizzazione.",
|
||||
"pt": "As configurações de LLM configuradas abaixo serão aplicadas a todos os usuários da organização.",
|
||||
"es": "La configuración de LLM configurada a continuación se aplicará a todos los usuarios de la organización.",
|
||||
"ar": "ستنطبق إعدادات LLM المكونة أدناه على جميع مستخدمي المؤسسة.",
|
||||
"fr": "Les paramètres LLM configurés ci-dessous s'appliqueront à tous les utilisateurs de l'organisation.",
|
||||
"tr": "Aşağıda yapılandırılan LLM ayarları, kuruluştaki tüm kullanıcılara uygulanacaktır.",
|
||||
"de": "Die unten konfigurierten LLM-Einstellungen gelten für alle Benutzer der Organisation.",
|
||||
"uk": "Налаштування LLM, налаштовані нижче, будуть застосовані до всіх користувачів організації."
|
||||
},
|
||||
"SETTINGS$LLM_MEMBER_INFO": {
|
||||
"en": "LLM settings are managed by your organization's administrator.",
|
||||
"ja": "LLM設定は組織の管理者によって管理されています。",
|
||||
"zh-CN": "LLM设置由您的组织管理员管理。",
|
||||
"zh-TW": "LLM設定由您的組織管理員管理。",
|
||||
"ko-KR": "LLM 설정은 조직 관리자가 관리합니다.",
|
||||
"no": "LLM-innstillinger administreres av organisasjonens administrator.",
|
||||
"it": "Le impostazioni LLM sono gestite dall'amministratore della tua organizzazione.",
|
||||
"pt": "As configurações de LLM são gerenciadas pelo administrador da sua organização.",
|
||||
"es": "La configuración de LLM es administrada por el administrador de su organización.",
|
||||
"ar": "يتم إدارة إعدادات LLM بواسطة مسؤول مؤسستك.",
|
||||
"fr": "Les paramètres LLM sont gérés par l'administrateur de votre organisation.",
|
||||
"tr": "LLM ayarları kuruluşunuzun yöneticisi tarafından yönetilmektedir.",
|
||||
"de": "LLM-Einstellungen werden vom Administrator Ihrer Organisation verwaltet.",
|
||||
"uk": "Налаштування LLM керуються адміністратором вашої організації."
|
||||
},
|
||||
"SETTINGS$SEE_PRICING_DETAILS": {
|
||||
"en": "See pricing details.",
|
||||
"ja": "価格詳細を見る。",
|
||||
|
||||
@@ -31,6 +31,7 @@ import { getProviderId } from "#/utils/map-provider";
|
||||
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
|
||||
import { useMe } from "#/hooks/query/use-me";
|
||||
import { usePermission } from "#/hooks/organizations/use-permissions";
|
||||
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||
|
||||
interface OpenHandsApiKeyHelpProps {
|
||||
testId: string;
|
||||
@@ -74,12 +75,35 @@ function LlmSettingsScreen() {
|
||||
const { data: config } = useConfig();
|
||||
const { data: me } = useMe();
|
||||
const { hasPermission } = usePermission(me?.role ?? "member");
|
||||
const { isPersonalOrg, isTeamOrg } = useOrgTypeAndAccess();
|
||||
|
||||
// In OSS mode, user has full access (no permission restrictions)
|
||||
// In SaaS mode, check role-based permissions (members can only view, owners and admins can edit)
|
||||
const isOssMode = config?.app_mode === "oss";
|
||||
const isReadOnly = isOssMode ? false : !hasPermission("edit_llm_settings");
|
||||
|
||||
// Determine the contextual info message based on workspace type and role
|
||||
const getLlmSettingsInfoMessage = (): I18nKey | null => {
|
||||
// No message in OSS mode (no organization context)
|
||||
if (isOssMode) return null;
|
||||
|
||||
// No message for personal workspaces
|
||||
if (isPersonalOrg) return null;
|
||||
|
||||
// Team org - show appropriate message based on role
|
||||
if (isTeamOrg) {
|
||||
const role = me?.role ?? "member";
|
||||
if (role === "admin" || role === "owner") {
|
||||
return I18nKey.SETTINGS$LLM_ADMIN_INFO;
|
||||
}
|
||||
return I18nKey.SETTINGS$LLM_MEMBER_INFO;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const llmInfoMessage = getLlmSettingsInfoMessage();
|
||||
|
||||
const [view, setView] = React.useState<"basic" | "advanced">("basic");
|
||||
|
||||
const [dirtyInputs, setDirtyInputs] = React.useState({
|
||||
@@ -504,6 +528,14 @@ function LlmSettingsScreen() {
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
{llmInfoMessage && (
|
||||
<p
|
||||
data-testid="llm-settings-info-message"
|
||||
className="text-sm text-tertiary-alt"
|
||||
>
|
||||
{t(llmInfoMessage)}
|
||||
</p>
|
||||
)}
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={view === "advanced"}
|
||||
|
||||
Reference in New Issue
Block a user