feat(frontend): add contextual info messages on LLM settings page (org project) (#13460)

This commit is contained in:
Hiep Le
2026-03-18 22:50:16 +07:00
committed by GitHub
parent fe4c0569f7
commit 6589e592e3
4 changed files with 259 additions and 1 deletions

View File

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

View File

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

View File

@@ -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": "価格詳細を見る。",

View File

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