From 6589e592e371a9320abd9f8da1c1c8dbde3078ab Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:50:16 +0700 Subject: [PATCH] feat(frontend): add contextual info messages on LLM settings page (org project) (#13460) --- .../__tests__/routes/llm-settings.test.tsx | 194 +++++++++++++++++- frontend/src/i18n/declaration.ts | 2 + frontend/src/i18n/translation.json | 32 +++ frontend/src/routes/llm-settings.tsx | 32 +++ 4 files changed, 259 insertions(+), 1 deletion(-) diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index 2dabd4da79..615ad9396c 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -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 & Pick, +): 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(); + }); +}); diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 94f72e7c95..a66552ff3c 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index c712b2a2e1..515a7e11b5 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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": "価格詳細を見る。", diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index d9489ec35a..b5c394c064 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -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" >
+ {llmInfoMessage && ( +

+ {t(llmInfoMessage)} +

+ )}