mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
feat(ui): make Projects generally available and remove enable_projects_ui flag
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
e6b4774ac6
commit
0e71513813
18 changed files with 71 additions and 222 deletions
|
|
@ -4,13 +4,12 @@ from litellm_enterprise.enterprise_callbacks.send_emails.endpoints import (
|
|||
router as email_events_router,
|
||||
)
|
||||
|
||||
from . import ui_crud_endpoints # side-effect: registers extra UI settings
|
||||
from .audit_logging_endpoints import router as audit_logging_router
|
||||
from .liteadmin import router as liteadmin_router
|
||||
from .management_endpoints import management_endpoints_router
|
||||
from .utils import should_block_robots
|
||||
|
||||
__all__ = ["router", "ui_crud_endpoints"]
|
||||
__all__ = ["router"]
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(email_events_router)
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
from . import ui_settings_extensions # side-effect: registers extra UI settings fields
|
||||
|
||||
__all__ = ["ui_settings_extensions"]
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
"""Enterprise-only UI settings fields.
|
||||
|
||||
Registers additional fields onto the OSS ``UISettings`` model at import time.
|
||||
Importing this module has the side effect of extending both the GET schema
|
||||
and the PATCH allowlist served by ``/get/ui_settings`` and
|
||||
``/update/ui_settings``.
|
||||
"""
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
|
||||
register_extra_ui_setting,
|
||||
)
|
||||
|
||||
register_extra_ui_setting(
|
||||
"enable_projects_ui",
|
||||
bool,
|
||||
FieldInfo(
|
||||
default=False,
|
||||
description=(
|
||||
"If enabled, shows the Projects feature in the UI sidebar and "
|
||||
"the project field in key management."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -477,12 +477,6 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS: Final = [
|
|||
# valid inputs.
|
||||
_EXTRA_UI_SETTINGS_FIELDS: Final[dict[str, tuple[object, FieldInfo]]] = {}
|
||||
|
||||
# Settings OSS knows about as enterprise-gated. If a caller sends one of
|
||||
# these keys and no extension package has registered it, the PATCH
|
||||
# endpoint returns 403 instead of silently dropping the value, so the
|
||||
# client gets a clear signal that the feature requires LiteLLM Enterprise.
|
||||
_ENTERPRISE_ONLY_UI_SETTINGS: Final[set[str]] = {"enable_projects_ui"}
|
||||
|
||||
# Memoized effective class; invalidated on registration.
|
||||
_EFFECTIVE_UI_SETTINGS_CLASS: type[UISettings] | None = None
|
||||
|
||||
|
|
@ -1977,20 +1971,6 @@ async def update_ui_settings(
|
|||
# Only include fields the caller actually sent (not Pydantic defaults).
|
||||
settings_dict: Final = _json_settings(settings.model_dump(exclude_unset=True))
|
||||
|
||||
# Reject enterprise-only settings up front so the caller gets a clear
|
||||
# signal instead of a silent drop.
|
||||
blocked_enterprise_keys = sorted((settings_dict.keys() & _ENTERPRISE_ONLY_UI_SETTINGS) - ALLOWED_UI_SETTINGS_FIELDS)
|
||||
if blocked_enterprise_keys:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": (
|
||||
f"Setting(s) {blocked_enterprise_keys} are a LiteLLM "
|
||||
"Enterprise feature and are not available on this build."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
# Enforce allowlist and drop anything unexpected
|
||||
incoming: Final = {k: v for k, v in settings_dict.items() if k in ALLOWED_UI_SETTINGS_FIELDS}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,20 +19,6 @@ async function globalSetup() {
|
|||
// keeps the default "." a no-op.
|
||||
fs.mkdirSync(ARTIFACT_DIR, { recursive: true });
|
||||
|
||||
// The Projects sidebar item is hidden unless the enterprise-gated
|
||||
// enable_projects_ui setting is on, and the seeded DB starts with it off.
|
||||
// The proxy runs with LITELLM_LICENSE in CI, so enable it the same way
|
||||
// the admin UI toggle does; the projects migration smoke needs the link.
|
||||
const masterKey = getMasterKey();
|
||||
const api = await request.newContext();
|
||||
const settingsRes = await api.patch(`${UI_BASE_URL}${rootPath}/update/ui_settings`, {
|
||||
headers: { Authorization: `Bearer ${masterKey}` },
|
||||
data: { enable_projects_ui: true },
|
||||
});
|
||||
if (!settingsRes.ok()) {
|
||||
throw new Error(`Enabling enable_projects_ui failed (${settingsRes.status()}): ${await settingsRes.text()}`);
|
||||
}
|
||||
|
||||
const roles = [Role.ProxyAdmin, ...Object.values(Role).filter((role) => role !== Role.ProxyAdmin)];
|
||||
for (const role of roles) {
|
||||
const { email, password, seedApiRole } = users[role];
|
||||
|
|
|
|||
|
|
@ -39,22 +39,6 @@ test("project creation and explicit detachment preserve saved scope and restore
|
|||
),
|
||||
);
|
||||
try {
|
||||
const previous = await request.get("/get/ui_settings", { headers });
|
||||
expect(previous.ok(), await previous.text()).toBe(true);
|
||||
const priorEnabled =
|
||||
(await previous.json()).values.enable_projects_ui ?? false;
|
||||
resources.push(async () => {
|
||||
const response = await request.patch("/update/ui_settings", {
|
||||
headers,
|
||||
data: { enable_projects_ui: priorEnabled },
|
||||
});
|
||||
expect(response.ok(), await response.text()).toBe(true);
|
||||
});
|
||||
const settings = await request.patch("/update/ui_settings", {
|
||||
headers,
|
||||
data: { enable_projects_ui: true },
|
||||
});
|
||||
expect(settings.ok(), await settings.text()).toBe(true);
|
||||
for (const alias of [prefix, `${prefix}-outside`]) {
|
||||
const model = await post("/model/new", {
|
||||
model_name: alias,
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import Sidebar from "@/components/leftnav";
|
|||
import SidebarProvider from "./SidebarProvider";
|
||||
|
||||
const { getSettings } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn().mockResolvedValue({ values: { enable_projects_ui: true } }),
|
||||
getSettings: vi.fn().mockResolvedValue({ values: { allow_agents_for_team_admins: true } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({ getUiSettings: getSettings, getUISettings: getSettings }));
|
||||
vi.mock("@/components/leftnav", () => ({
|
||||
default: ({ enableProjectsUI }: ComponentProps<typeof Sidebar>) => (
|
||||
<nav>{enableProjectsUI && <a href="/projects">Projects</a>}</nav>
|
||||
default: ({ allowAgentsForTeamAdmins }: ComponentProps<typeof Sidebar>) => (
|
||||
<nav>{allowAgentsForTeamAdmins && <a href="/agents">Agents</a>}</nav>
|
||||
),
|
||||
}));
|
||||
|
||||
|
|
@ -31,13 +31,13 @@ it("reuses loaded navigation settings immediately when the mobile sidebar mounts
|
|||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(shell(false));
|
||||
expect(await screen.findByRole("link", { name: "Projects" })).toBeVisible();
|
||||
expect(await screen.findByRole("link", { name: "Agents" })).toBeVisible();
|
||||
|
||||
for (const open of [true, false, true]) {
|
||||
rerender(shell(open));
|
||||
if (open) {
|
||||
expect(
|
||||
within(screen.getByRole("region", { name: "Mobile navigation" })).getByRole("link", { name: "Projects" }),
|
||||
within(screen.getByRole("region", { name: "Mobile navigation" })).getByRole("link", { name: "Agents" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ const SidebarProvider = ({ sidebarCollapsed, onToggleCollapsed }: SidebarProvide
|
|||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
enabledPagesInternalUsers={values?.enabled_ui_pages_internal_users ?? null}
|
||||
enableProjectsUI={Boolean(values?.enable_projects_ui)}
|
||||
disableAgentsForInternalUsers={Boolean(values?.disable_agents_for_internal_users)}
|
||||
allowAgentsForTeamAdmins={Boolean(values?.allow_agents_for_team_admins)}
|
||||
disableVectorStoresForInternalUsers={Boolean(values?.disable_vector_stores_for_internal_users)}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export const useVisibleMenuGroups = () => {
|
|||
isOrgAdmin,
|
||||
isTeamAdmin,
|
||||
enabledPagesInternalUsers: values?.enabled_ui_pages_internal_users ?? null,
|
||||
enableProjectsUI: Boolean(values?.enable_projects_ui),
|
||||
disableAgentsForInternalUsers: Boolean(values?.disable_agents_for_internal_users),
|
||||
allowAgentsForTeamAdmins: Boolean(values?.allow_agents_for_team_admins),
|
||||
disableVectorStoresForInternalUsers: Boolean(values?.disable_vector_stores_for_internal_users),
|
||||
|
|
|
|||
|
|
@ -57,13 +57,12 @@ describe("visibleMenuGroups", () => {
|
|||
expect(flattenNavItems(visible).map(({ route }) => route)).toEqual(["api-keys"]);
|
||||
});
|
||||
|
||||
it("hides Projects when its UI setting is disabled", () => {
|
||||
const projectAdminContext = { ...context, userRole: "Admin", isTeamAdmin: true };
|
||||
const visibleWithFlag = visibleMenuGroups(menuGroups, { ...projectAdminContext, enableProjectsUI: true });
|
||||
const visibleWithoutFlag = visibleMenuGroups(menuGroups, { ...projectAdminContext, enableProjectsUI: false });
|
||||
it("shows Projects to admins and hides it from internal users who administer no team", () => {
|
||||
const visibleToAdmin = visibleMenuGroups(menuGroups, { ...context, userRole: "Admin" });
|
||||
const visibleToMember = visibleMenuGroups(menuGroups, context);
|
||||
|
||||
expect(flattenNavItems(visibleWithFlag).some(({ route }) => route === "projects")).toBe(true);
|
||||
expect(flattenNavItems(visibleWithoutFlag).some(({ route }) => route === "projects")).toBe(false);
|
||||
expect(flattenNavItems(visibleToAdmin).some(({ route }) => route === "projects")).toBe(true);
|
||||
expect(flattenNavItems(visibleToMember).some(({ route }) => route === "projects")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps admin pages visible despite the internal-user page allowlist", () => {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ export default function UISettings() {
|
|||
const requireAuthForPublicAIHubProperty = schema?.properties?.require_auth_for_public_ai_hub;
|
||||
const forwardClientHeadersProperty = schema?.properties?.forward_client_headers_to_llm_api;
|
||||
const forwardLLMProviderAuthHeadersProperty = schema?.properties?.forward_llm_provider_auth_headers;
|
||||
const enableProjectsUIProperty = schema?.properties?.enable_projects_ui;
|
||||
const enableChatUIProperty = schema?.properties?.enable_chat_ui;
|
||||
const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users;
|
||||
const disableAgentsProperty = schema?.properties?.disable_agents_for_internal_users;
|
||||
|
|
@ -150,21 +149,6 @@ export default function UISettings() {
|
|||
);
|
||||
};
|
||||
|
||||
const handleToggleEnableProjectsUI = (checked: boolean) => {
|
||||
updateSettings(
|
||||
{ enable_projects_ui: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully. Refreshing page...");
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleEnableChatUI = (checked: boolean) => {
|
||||
updateSettings(
|
||||
{ enable_chat_ui: checked },
|
||||
|
|
@ -411,19 +395,6 @@ export default function UISettings() {
|
|||
"Forwards provider auth headers (x-api-key, x-goog-api-key, api-key, ocp-apim-subscription-key) to the upstream LLM, overriding any deployment-configured key for that request. Enable for Claude Code BYOK (clients bring their own API key). Independent of the client-headers toggle — enable only the one(s) you need."
|
||||
}
|
||||
/>
|
||||
{enableProjectsUIProperty && (
|
||||
<SettingRow
|
||||
checked={Boolean(values.enable_projects_ui)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleEnableProjectsUI}
|
||||
ariaLabel={enableProjectsUIProperty.description ?? "Enable Projects UI"}
|
||||
label="[BETA] Enable Projects (page will refresh)"
|
||||
description={
|
||||
enableProjectsUIProperty.description ??
|
||||
"If enabled, shows the Projects feature in the UI sidebar and the project field in key management."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingRow
|
||||
checked={Boolean(values.enable_chat_ui)}
|
||||
disabled={isUpdating}
|
||||
|
|
|
|||
|
|
@ -589,32 +589,15 @@ describe("Sidebar (leftnav)", () => {
|
|||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: /Projects/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Projects when the feature flag is disabled for a team admin", () => {
|
||||
mockUseAuthorized.mockReturnValue(teamAdminAuthorization);
|
||||
mockUseTeams.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }],
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI={false} />);
|
||||
|
||||
expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Projects from an internal user who is not a team admin", () => {
|
||||
mockUseAuthorized.mockReturnValue(teamMemberAuthorization);
|
||||
|
||||
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -634,7 +617,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI enabledPagesInternalUsers={allowlist} />);
|
||||
renderWithProviders(<Sidebar {...defaultProps} enabledPagesInternalUsers={allowlist} />);
|
||||
|
||||
expect(screen.queryByRole("link", { name: /Projects/ }) !== null).toBe(visible);
|
||||
});
|
||||
|
|
@ -642,7 +625,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
it("shows Projects to a user whose global role is org admin", () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...teamMemberAuthorization, userRole: "Org Admin" });
|
||||
|
||||
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: /Projects/ })).toHaveAttribute("href", "/ui/projects");
|
||||
});
|
||||
|
|
@ -683,7 +666,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
});
|
||||
|
||||
it("never links a leaf to the legacy ?page= switch", () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
for (const group of ["Agentic", "Tools", "Experimental", "Settings"]) {
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText(group));
|
||||
|
|
@ -707,19 +690,27 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(label).toHaveClass("group-data-[collapsed=true]/sidebar:hidden");
|
||||
});
|
||||
|
||||
it("shows Cost Optimization without a Beta badge and no feature-flag gate", () => {
|
||||
const { container } = renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI={false} />);
|
||||
it("shows Cost Optimization without a Beta badge", () => {
|
||||
const { container } = renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
const costOptimization = container.querySelector('a[href*="cost-optimization"]');
|
||||
expect(costOptimization).not.toBeNull();
|
||||
expect(costOptimization!).toHaveTextContent("Cost Optimization");
|
||||
expect(costOptimization!).not.toHaveTextContent("Beta");
|
||||
});
|
||||
|
||||
expect(container.querySelector('a[href*="projects"]')).toBeNull();
|
||||
it("shows Projects to an org admin without a Beta badge", () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...teamMemberAuthorization, userRole: "Org Admin" });
|
||||
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
const projects = screen.getByRole("link", { name: "Projects" });
|
||||
expect(projects).toHaveAttribute("href", "/ui/projects");
|
||||
expect(projects).not.toHaveTextContent("Beta");
|
||||
});
|
||||
|
||||
it("keeps a readable collapsed-rail tooltip for items whose label carries a badge", () => {
|
||||
const { container } = renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI collapsed />);
|
||||
const { container } = renderWithProviders(<Sidebar {...defaultProps} collapsed />);
|
||||
|
||||
expect(container.querySelector('a[href*="cost-optimization"]')).toHaveAttribute("title", "Cost Optimization");
|
||||
expect(container.querySelector('a[href*="projects"]')).toHaveAttribute("title", "Projects");
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ interface SidebarProps {
|
|||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
enabledPagesInternalUsers?: string[] | null;
|
||||
enableProjectsUI?: boolean;
|
||||
disableAgentsForInternalUsers?: boolean;
|
||||
allowAgentsForTeamAdmins?: boolean;
|
||||
disableVectorStoresForInternalUsers?: boolean;
|
||||
|
|
@ -129,7 +128,6 @@ export interface MenuVisibilityContext {
|
|||
isOrgAdmin: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
enabledPagesInternalUsers?: string[] | null;
|
||||
enableProjectsUI?: boolean;
|
||||
disableAgentsForInternalUsers?: boolean;
|
||||
allowAgentsForTeamAdmins?: boolean;
|
||||
disableVectorStoresForInternalUsers?: boolean;
|
||||
|
|
@ -148,7 +146,6 @@ const adminPageVisibility = (item: MenuItem, context: MenuVisibilityContext, isA
|
|||
|
||||
const projectPageIsVisible = (item: MenuItem, context: MenuVisibilityContext): boolean => {
|
||||
if (item.key !== "projects") return true;
|
||||
if (!context.enableProjectsUI) return false;
|
||||
return canViewProjectsPage({
|
||||
userRole: context.userRole,
|
||||
isOrgAdmin: context.isOrgAdmin,
|
||||
|
|
@ -342,11 +339,7 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "projects",
|
||||
page: "projects",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Projects <BetaBadge />
|
||||
</span>
|
||||
),
|
||||
label: "Projects",
|
||||
icon: <Folder {...ICON} />,
|
||||
roles: [...projectReaderRoles],
|
||||
},
|
||||
|
|
@ -533,7 +526,6 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
collapsed = false,
|
||||
onToggleCollapsed,
|
||||
enabledPagesInternalUsers,
|
||||
enableProjectsUI,
|
||||
disableAgentsForInternalUsers,
|
||||
allowAgentsForTeamAdmins,
|
||||
disableVectorStoresForInternalUsers,
|
||||
|
|
@ -580,7 +572,6 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
isOrgAdmin,
|
||||
isTeamAdmin,
|
||||
enabledPagesInternalUsers,
|
||||
enableProjectsUI,
|
||||
disableAgentsForInternalUsers,
|
||||
allowAgentsForTeamAdmins,
|
||||
disableVectorStoresForInternalUsers,
|
||||
|
|
@ -592,7 +583,6 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
allowVectorStoresForTeamAdmins,
|
||||
disableAgentsForInternalUsers,
|
||||
disableVectorStoresForInternalUsers,
|
||||
enableProjectsUI,
|
||||
enabledPagesInternalUsers,
|
||||
isOrgAdmin,
|
||||
isTeamAdmin,
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ const SECTIONS = {
|
|||
|
||||
const ALL_CLOSED_PAYLOAD = {
|
||||
organization_id: undefined,
|
||||
project_id: undefined,
|
||||
team_id: null,
|
||||
key_alias: "contract-key",
|
||||
models: [],
|
||||
|
|
@ -538,15 +539,6 @@ describe("CreateKey", () => {
|
|||
expect(payload).toHaveProperty("guardrails");
|
||||
});
|
||||
|
||||
it("adds project_id only when the projects UI is enabled", async () => {
|
||||
state.uiSettings = { enable_projects_ui: true };
|
||||
await openModal();
|
||||
await nameTheKey();
|
||||
await submit();
|
||||
|
||||
expect(await createdPayload()).toStrictEqual({ ...ALL_CLOSED_PAYLOAD, project_id: undefined });
|
||||
});
|
||||
|
||||
it("keeps disable_global_guardrails out of the payload while the switch is off", async () => {
|
||||
await openModal();
|
||||
await nameTheKey();
|
||||
|
|
@ -923,7 +915,6 @@ describe("CreateKey", () => {
|
|||
});
|
||||
|
||||
it("discards the old project and team when the organization changes", async () => {
|
||||
state.uiSettings = { enable_projects_ui: true };
|
||||
state.organizations = [
|
||||
{ organization_id: "scope-silver", organization_alias: "Silver" },
|
||||
{ organization_id: "scope-copper", organization_alias: "Copper" },
|
||||
|
|
|
|||
|
|
@ -241,7 +241,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
const { data: projects, isLoading: isProjectsLoading } = useProjects();
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const { data: tagsData } = useTags();
|
||||
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
|
||||
const disableCustomApiKeys = Boolean(uiSettingsData?.values?.disable_custom_api_keys);
|
||||
const tagOptions = tagsData ? Object.values(tagsData).map((tag) => ({ value: tag.name, label: tag.name })) : [];
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -884,31 +883,29 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
)}
|
||||
</MountedFormField>
|
||||
{ownerLimitedToTeamMembers && ownerField}
|
||||
{enableProjectsUI && (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span>
|
||||
Project{" "}
|
||||
<SimpleTooltip content="Assign this key to a project. Selecting a project will lock the team to the project's team.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="project_id"
|
||||
className="mt-4"
|
||||
>
|
||||
{(control) => (
|
||||
<ProjectDropdown
|
||||
id={control.id}
|
||||
value={typeof control.value === "string" ? control.value : null}
|
||||
projects={projects}
|
||||
teamId={selectedCreateKeyTeam?.team_id}
|
||||
loading={isProjectsLoading || !teams}
|
||||
onChange={changeProject(control.onChange)}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
<MountedFormField
|
||||
label={
|
||||
<span>
|
||||
Project{" "}
|
||||
<SimpleTooltip content="Assign this key to a project. Selecting a project will lock the team to the project's team.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="project_id"
|
||||
className="mt-4"
|
||||
>
|
||||
{(control) => (
|
||||
<ProjectDropdown
|
||||
id={control.id}
|
||||
value={typeof control.value === "string" ? control.value : null}
|
||||
projects={projects}
|
||||
teamId={selectedCreateKeyTeam?.team_id}
|
||||
loading={isProjectsLoading || !teams}
|
||||
onChange={changeProject(control.onChange)}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
|
||||
{/* Show message when team selection is required */}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ vi.mock("../networking", async () => {
|
|||
const actual = await vi.importActual("../networking");
|
||||
return {
|
||||
...actual,
|
||||
getUiSettings: vi.fn().mockResolvedValue({ values: { enable_projects_ui: false } }),
|
||||
getUiSettings: vi.fn().mockResolvedValue({ values: {} }),
|
||||
getPromptsList: vi.fn().mockResolvedValue({
|
||||
prompts: [{ prompt_id: "prompt-1" }, { prompt_id: "prompt-2" }],
|
||||
}),
|
||||
|
|
@ -396,7 +396,7 @@ describe("KeyEditView", () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
can.mockReturnValue(true);
|
||||
vi.mocked(getUiSettings).mockResolvedValue({ values: { enable_projects_ui: false } });
|
||||
vi.mocked(getUiSettings).mockResolvedValue({ values: {} });
|
||||
testQueryClient.removeQueries({ queryKey: ["uiSettings"] });
|
||||
});
|
||||
|
||||
|
|
@ -2006,7 +2006,6 @@ describe("KeyEditView", () => {
|
|||
});
|
||||
|
||||
it("should save an explicit project detach while keeping parents locked until reopening the editor", async () => {
|
||||
vi.mocked(getUiSettings).mockResolvedValue({ values: { enable_projects_ui: true } });
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const onCancel = vi.fn();
|
||||
const key = { ...MOCK_KEY_DATA, organization_id: "org-1", team_id: "group-maple", project_id: "project-orbit" };
|
||||
|
|
@ -2067,7 +2066,7 @@ describe("KeyEditView", () => {
|
|||
expect(await screen.findByRole("button", { name: "Detach from project" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps project key relationships locked and omits project updates when the project UI is disabled", async () => {
|
||||
it("keeps project key relationships locked and omits project_id when the project is not detached", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { canDetachKeyProject, KeyProjectField } from "./KeyProjectField";
|
||||
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import PolicySelector from "@/components/policies/PolicySelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -143,8 +142,6 @@ function KeyEditSession({
|
|||
const keyTypeFieldId = React.useId();
|
||||
const endUserBudgetFieldId = React.useId();
|
||||
const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations();
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
|
||||
const hasProject = Boolean(keyData.project_id);
|
||||
const canChangeOrganization = userRole === "Admin" && !hasProject;
|
||||
const detachProject = hasProject && form.watch("project_id") === null;
|
||||
|
|
@ -317,7 +314,7 @@ function KeyEditSession({
|
|||
await onSubmit(
|
||||
withNormalizedEstimates({
|
||||
...values,
|
||||
...(detachProject && enableProjectsUI && canDetachProject ? { project_id: null } : {}),
|
||||
...(detachProject && canDetachProject ? { project_id: null } : {}),
|
||||
}),
|
||||
[...Object.keys(form.formState.dirtyFields), ...(budgetLimitsUnchanged ? [] : ["budget_limits"])],
|
||||
);
|
||||
|
|
@ -822,7 +819,7 @@ function KeyEditSession({
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
{enableProjectsUI && hasProject && (
|
||||
{hasProject && (
|
||||
<KeyProjectField
|
||||
projectId={keyData.project_id}
|
||||
canDetach={canDetachProject}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { useApplyUserBudgetToTeamKeys } from "@/app/(dashboard)/hooks/uiSettings/useApplyUserBudgetToTeamKeys";
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
|
|
@ -111,11 +110,9 @@ export default function KeyInfoView({
|
|||
const { teams: teamsData } = useTeams();
|
||||
const { data: organizations } = useOrganizations();
|
||||
const { data: projects } = useProjects();
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const applyUserBudgetToTeamKeys = useApplyUserBudgetToTeamKeys();
|
||||
const { data: allMcpServers } = useMCPServers();
|
||||
const { data: allMcpToolsets } = useMCPToolsets();
|
||||
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
|
@ -968,21 +965,19 @@ export default function KeyInfoView({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{enableProjectsUI && (
|
||||
<div>
|
||||
<p className="text-sm font-medium">Project</p>
|
||||
<p className="text-sm">
|
||||
{currentKeyData.project_id
|
||||
? (() => {
|
||||
const project = projects?.find((p) => p.project_id === currentKeyData.project_id);
|
||||
return project?.project_alias
|
||||
? `${project.project_alias} (${currentKeyData.project_id})`
|
||||
: currentKeyData.project_id;
|
||||
})()
|
||||
: "Not Set"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">Project</p>
|
||||
<p className="text-sm">
|
||||
{currentKeyData.project_id
|
||||
? (() => {
|
||||
const project = projects?.find((p) => p.project_id === currentKeyData.project_id);
|
||||
return project?.project_alias
|
||||
? `${project.project_alias} (${currentKeyData.project_id})`
|
||||
: currentKeyData.project_id;
|
||||
})()
|
||||
: "Not Set"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium">Organization</p>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue