mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Merge pull request #22373 from BerriAI/litellm_ui_project_keys
[Feature] UI - Projects: Add project keys table and project dropdown to key create/edit
This commit is contained in:
commit
8abba63b48
15 changed files with 420 additions and 35 deletions
|
|
@ -50,6 +50,7 @@ const mockKeys: KeyResponse[] = [
|
|||
config: {},
|
||||
user_id: "user-1",
|
||||
team_id: null,
|
||||
project_id: null,
|
||||
max_parallel_requests: 10,
|
||||
metadata: {},
|
||||
tpm_limit: 1000,
|
||||
|
|
@ -105,6 +106,7 @@ const mockKeys: KeyResponse[] = [
|
|||
config: {},
|
||||
user_id: "user-2",
|
||||
team_id: "team-1",
|
||||
project_id: "project-1",
|
||||
max_parallel_requests: 5,
|
||||
metadata: {},
|
||||
tpm_limit: 500,
|
||||
|
|
@ -396,6 +398,76 @@ describe("useKeys", () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass projectID filter to the API", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
keys: [mockKeys[1]],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useKeys(1, 10, { projectID: "project-1" }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
const callUrl = mockFetch.mock.calls[0][0];
|
||||
expect(callUrl).toContain("project_id=project-1");
|
||||
expect(result.current.data?.keys).toHaveLength(1);
|
||||
expect(result.current.data?.keys[0].project_id).toBe("project-1");
|
||||
});
|
||||
|
||||
it("should pass both projectID and teamID filters to the API", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
keys: [mockKeys[1]],
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useKeys(1, 10, { projectID: "project-1", teamID: "team-1" }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
const callUrl = mockFetch.mock.calls[0][0];
|
||||
expect(callUrl).toContain("project_id=project-1");
|
||||
expect(callUrl).toContain("team_id=team-1");
|
||||
});
|
||||
|
||||
it("should not include project_id param when projectID is null", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockKeysResponse,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useKeys(1, 10, { projectID: null }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
const callUrl = mockFetch.mock.calls[0][0];
|
||||
expect(callUrl).not.toContain("project_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDeletedKeys", () => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export interface DeletedKeysResponse {
|
|||
export interface KeyListCallOptions {
|
||||
organizationID?: string | null;
|
||||
teamID?: string | null;
|
||||
projectID?: string | null;
|
||||
selectedKeyAlias?: string | null;
|
||||
userID?: string | null;
|
||||
keyHash?: string | null;
|
||||
|
|
@ -57,6 +58,7 @@ const keyListCall = async (
|
|||
const params = new URLSearchParams(
|
||||
Object.entries({
|
||||
team_id: options.teamID,
|
||||
project_id: options.projectID,
|
||||
organization_id: options.organizationID,
|
||||
key_alias: options.selectedKeyAlias,
|
||||
key_hash: options.keyHash,
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ import {
|
|||
} from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { BarChart } from "@tremor/react";
|
||||
import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
|
||||
import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { EditProjectModal } from "./ProjectModals/EditProjectModal";
|
||||
import { ProjectKeysSection } from "./ProjectKeysSection";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Content } = Layout;
|
||||
|
|
@ -203,17 +204,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
{/* Keys & Team */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<KeyIcon size={16} />
|
||||
Keys
|
||||
</Flex>
|
||||
}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Empty description="No keys to display" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Card>
|
||||
<ProjectKeysSection projectId={project.project_id} />
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { Card, Flex, Input, Pagination, Spin } from "antd";
|
||||
import { KeyIcon, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ProjectKeysTable } from "./ProjectKeysTable";
|
||||
|
||||
interface ProjectKeysSectionProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyAlias, setKeyAlias] = useState<string>("");
|
||||
|
||||
const { data, isLoading } = useKeys(page, PAGE_SIZE, {
|
||||
projectID: projectId,
|
||||
selectedKeyAlias: keyAlias || null,
|
||||
});
|
||||
|
||||
// Reset to page 1 when filter changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [keyAlias]);
|
||||
|
||||
const keys = data?.keys ?? [];
|
||||
const totalCount = data?.total_count ?? 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<KeyIcon size={16} />
|
||||
Keys
|
||||
</Flex>
|
||||
}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
prefix={<SearchIcon size={14} />}
|
||||
placeholder="Filter by key name..."
|
||||
style={{ maxWidth: 220 }}
|
||||
value={keyAlias}
|
||||
onChange={(e) => setKeyAlias(e.target.value)}
|
||||
allowClear
|
||||
size="small"
|
||||
/>
|
||||
<Pagination
|
||||
current={page}
|
||||
total={totalCount}
|
||||
pageSize={PAGE_SIZE}
|
||||
onChange={setPage}
|
||||
size="small"
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `${total} keys`}
|
||||
/>
|
||||
</Flex>
|
||||
<ProjectKeysTable
|
||||
keys={keys}
|
||||
loading={isLoading ? { indicator: <Spin indicator={<LoadingOutlined spin />} /> } : false}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
import { Empty, Table, Tooltip } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { SpinProps } from "antd";
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
|
||||
interface ProjectKeysTableProps {
|
||||
keys: KeyResponse[];
|
||||
loading?: boolean | SpinProps;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<KeyResponse> = [
|
||||
{
|
||||
title: "Key Name",
|
||||
dataIndex: "key_alias",
|
||||
key: "key_alias",
|
||||
render: (alias: string | null) => alias || "—",
|
||||
},
|
||||
{
|
||||
title: "Owner",
|
||||
key: "owner",
|
||||
render: (_: unknown, record: KeyResponse) => {
|
||||
const email = record.user?.user_email ?? record.user_id ?? null;
|
||||
if (!email) return "—";
|
||||
return (
|
||||
<Tooltip title={email}>
|
||||
<DefaultProxyAdminTag userId={email} />
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Created",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"),
|
||||
},
|
||||
{
|
||||
title: "Last Active",
|
||||
dataIndex: "last_active",
|
||||
key: "last_active",
|
||||
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"),
|
||||
},
|
||||
];
|
||||
|
||||
export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) {
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={keys}
|
||||
rowKey="token"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: <Empty description="No keys found" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/u
|
|||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
|
|
@ -165,6 +166,12 @@ export function ProjectsPage() {
|
|||
<Content
|
||||
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
|
||||
>
|
||||
<Alert
|
||||
message="Projects is currently in beta. Features and behavior may change without notice."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import React from "react";
|
||||
import { Select, Spin } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
|
||||
interface ProjectDropdownProps {
|
||||
projects?: ProjectResponse[] | null;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
/** When set, only show projects belonging to this team */
|
||||
teamId?: string | null;
|
||||
}
|
||||
|
||||
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
|
||||
projects,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
loading,
|
||||
teamId,
|
||||
}) => {
|
||||
const filtered = teamId
|
||||
? projects?.filter((p) => p.team_id === teamId)
|
||||
: projects;
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select a project"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
allowClear
|
||||
notFoundContent={loading ? <Spin indicator={<LoadingOutlined spin />} size="small" /> : undefined}
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const project = filtered?.find((p) => p.project_id === option.key);
|
||||
if (!project) return false;
|
||||
|
||||
const searchTerm = input.toLowerCase().trim();
|
||||
const alias = (project.project_alias || "").toLowerCase();
|
||||
const id = (project.project_id || "").toLowerCase();
|
||||
|
||||
return alias.includes(searchTerm) || id.includes(searchTerm);
|
||||
}}
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{!loading &&
|
||||
filtered?.map((project) => (
|
||||
<Select.Option key={project.project_id} value={project.project_id}>
|
||||
<span className="font-medium">{project.project_alias || project.project_id}</span>{" "}
|
||||
<span className="text-gray-500">({project.project_id})</span>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDropdown;
|
||||
|
|
@ -7,10 +7,10 @@ interface TeamDropdownProps {
|
|||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const TeamDropdown: React.FC<TeamDropdownProps> = ({ teams, value, onChange, disabled }) => {
|
||||
console.log("disabled", disabled);
|
||||
const TeamDropdown: React.FC<TeamDropdownProps> = ({ teams, value, onChange, disabled, loading }) => {
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
|
|
@ -18,6 +18,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({ teams, value, onChange, dis
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
allowClear
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface KeyResponse {
|
|||
config: Record<string, unknown>;
|
||||
user_id: string;
|
||||
team_id: string | null;
|
||||
project_id: string | null;
|
||||
max_parallel_requests: number;
|
||||
metadata: Record<string, unknown>;
|
||||
tpm_limit: number;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ vi.mock("../networking", () => ({
|
|||
keyCreateCall: mockKeyCreateCall,
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }] }),
|
||||
getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }),
|
||||
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
|
||||
getPromptsList: vi.fn().mockResolvedValue({ prompts: [] }),
|
||||
proxyBaseUrl: "http://localhost:4000",
|
||||
getPossibleUserRoles: vi.fn().mockResolvedValue({
|
||||
|
|
@ -27,7 +28,7 @@ vi.mock("../networking", () => ({
|
|||
soft_budget: null,
|
||||
}),
|
||||
fetchMCPAccessGroups: vi.fn().mockResolvedValue([]),
|
||||
getAgentsList: vi.fn().mockResolvedValue([]),
|
||||
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../molecules/notifications_manager", () => ({
|
||||
|
|
@ -41,6 +42,20 @@ vi.mock("../molecules/notifications_manager", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
|
||||
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../common_components/ProjectDropdown", () => ({
|
||||
default: ({ value, onChange }: { value?: string; onChange?: (v: string) => void }) => (
|
||||
<input
|
||||
data-testid="project-dropdown"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../common_components/AccessGroupSelector", () => ({
|
||||
default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
import { keyKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
|
@ -21,6 +22,7 @@ import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings"
|
|||
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
import ProjectDropdown from "../common_components/ProjectDropdown";
|
||||
import { CreateUserButton } from "../CreateUserButton";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
|
|
@ -143,6 +145,7 @@ export const fetchUserModels = async (
|
|||
*/
|
||||
const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
||||
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
|
||||
const { data: projects, isLoading: isProjectsLoading } = useProjects();
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
|
@ -157,6 +160,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
const [promptsList, setPromptsList] = useState<string[]>([]);
|
||||
const [loggingSettings, setLoggingSettings] = useState<any[]>([]);
|
||||
const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState<Team | null>(team);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false);
|
||||
const [newlyCreatedUserId, setNewlyCreatedUserId] = useState<string | null>(null);
|
||||
const [possibleUIRoles, setPossibleUIRoles] = useState<Record<string, Record<string, string>>>({});
|
||||
|
|
@ -184,6 +188,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
setRouterSettings(null);
|
||||
setRouterSettingsKey((prev) => prev + 1);
|
||||
setSelectedAgentId(null);
|
||||
setSelectedProjectId(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
|
|
@ -200,6 +205,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
setRouterSettings(null);
|
||||
setRouterSettingsKey((prev) => prev + 1);
|
||||
setSelectedAgentId(null);
|
||||
setSelectedProjectId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -468,6 +474,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) {
|
||||
// When a project is selected, use the project's models
|
||||
const project = projects?.find((p) => p.project_id === selectedProjectId);
|
||||
const projectModels = project?.models ?? [];
|
||||
setModelsToPick(projectModels);
|
||||
form.setFieldValue("models", []);
|
||||
return;
|
||||
}
|
||||
if (userID && userRole && accessToken) {
|
||||
fetchTeamModels(userID, userRole, accessToken, selectedCreateKeyTeam?.team_id ?? null).then((models) => {
|
||||
let allModels = Array.from(new Set([...(selectedCreateKeyTeam?.models ?? []), ...models]));
|
||||
|
|
@ -475,7 +489,22 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
});
|
||||
}
|
||||
form.setFieldValue("models", []);
|
||||
}, [selectedCreateKeyTeam, accessToken, userID, userRole]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCreateKeyTeam, selectedProjectId, accessToken, userID, userRole]);
|
||||
|
||||
// Sync team when project is selected but teams loaded later (race condition)
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId || !teams) return;
|
||||
const project = projects?.find((p) => p.project_id === selectedProjectId);
|
||||
if (!project?.team_id) return;
|
||||
// If team is already set correctly, skip
|
||||
if (selectedCreateKeyTeam?.team_id === project.team_id) return;
|
||||
const projectTeam = teams.find((t) => t.team_id === project.team_id) || null;
|
||||
if (projectTeam) {
|
||||
setSelectedCreateKeyTeam(projectTeam);
|
||||
form.setFieldValue("team_id", projectTeam.team_id);
|
||||
}
|
||||
}, [teams, selectedProjectId, projects]);
|
||||
|
||||
// Add a callback function to handle user creation
|
||||
const handleUserCreated = (userId: string) => {
|
||||
|
|
@ -653,9 +682,40 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
>
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
disabled={selectedProjectId !== null}
|
||||
loading={!teams}
|
||||
onChange={(teamId) => {
|
||||
const selectedTeam = teams?.find((t) => t.team_id === teamId) || null;
|
||||
setSelectedCreateKeyTeam(selectedTeam);
|
||||
setSelectedProjectId(null);
|
||||
form.setFieldValue("project_id", undefined);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Project{" "}
|
||||
<Tooltip title="Assign this key to a project. Selecting a project will lock the team to the project's team.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="project_id"
|
||||
className="mt-4"
|
||||
>
|
||||
<ProjectDropdown
|
||||
projects={projects}
|
||||
teamId={selectedCreateKeyTeam?.team_id}
|
||||
loading={isProjectsLoading || !teams}
|
||||
onChange={(projectId) => {
|
||||
if (!projectId) {
|
||||
setSelectedProjectId(null);
|
||||
setSelectedCreateKeyTeam(null);
|
||||
form.setFieldValue("team_id", undefined);
|
||||
return;
|
||||
}
|
||||
setSelectedProjectId(projectId);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
|
@ -735,9 +795,11 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Option key="all-team-models" value="all-team-models">
|
||||
All Team Models
|
||||
</Option>
|
||||
{!selectedProjectId && (
|
||||
<Option key="all-team-models" value="all-team-models">
|
||||
All Team Models
|
||||
</Option>
|
||||
)}
|
||||
{modelsToPick.map((model: string) => (
|
||||
<Option key={model} value={model}>
|
||||
{getModelDisplayName(model)}
|
||||
|
|
|
|||
|
|
@ -249,6 +249,11 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
// Mock useProjects hook
|
||||
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
|
||||
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
// KeyEditView mock: triggers onSubmit with our injected form values
|
||||
vi.mock("./key_edit_view", async () => {
|
||||
const React = await import("react");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
|
||||
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import PolicySelector from "@/components/policies/PolicySelector";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { TextInput, Button as TremorButton } from "@tremor/react";
|
||||
|
|
@ -95,6 +96,15 @@ export function KeyEditView({
|
|||
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(keyData.auto_rotate || false);
|
||||
const [rotationInterval, setRotationInterval] = useState<string>(keyData.rotation_interval || "");
|
||||
const [isKeySaving, setIsKeySaving] = useState(false);
|
||||
const { data: projects } = useProjects();
|
||||
const hasProject = Boolean(keyData.project_id);
|
||||
const projectDisplay = (() => {
|
||||
if (!keyData.project_id) return null;
|
||||
const project = projects?.find((p) => p.project_id === keyData.project_id);
|
||||
return project?.project_alias
|
||||
? `${project.project_alias} (${keyData.project_id})`
|
||||
: keyData.project_id;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
|
|
@ -590,10 +600,15 @@ export function KeyEditView({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Team ID" name="team_id">
|
||||
<Form.Item
|
||||
label="Team ID"
|
||||
name="team_id"
|
||||
help={hasProject ? "Team is locked because this key belongs to a project" : undefined}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select team"
|
||||
showSearch
|
||||
disabled={hasProject}
|
||||
style={{ width: "100%" }}
|
||||
filterOption={(input, option) => {
|
||||
const team = teams?.find((t) => t.team_id === option?.value);
|
||||
|
|
@ -601,7 +616,6 @@ export function KeyEditView({
|
|||
return team.team_alias?.toLowerCase().includes(input.toLowerCase()) ?? false;
|
||||
}}
|
||||
>
|
||||
{/* Only show All Team Models if team has models */}
|
||||
{teams?.map((team) => (
|
||||
<Select.Option key={team.team_id} value={team.team_id}>
|
||||
{`${team.team_alias} (${team.team_id})`}
|
||||
|
|
@ -609,6 +623,11 @@ export function KeyEditView({
|
|||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{hasProject && (
|
||||
<Form.Item label="Project">
|
||||
<Input value={projectDisplay ?? ""} disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label="Logging Settings" name="logging_settings">
|
||||
<EditLoggingSettings
|
||||
value={form.getFieldValue("logging_settings")}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
|
|
@ -14,6 +16,10 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
|
||||
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
keyDeleteCall: vi.fn().mockResolvedValue({}),
|
||||
keyUpdateCall: vi.fn().mockResolvedValue({}),
|
||||
|
|
@ -49,6 +55,7 @@ describe("KeyInfoView", () => {
|
|||
config: {},
|
||||
user_id: "default_user_id",
|
||||
team_id: null,
|
||||
project_id: null,
|
||||
max_parallel_requests: 10,
|
||||
metadata: {
|
||||
logging: [],
|
||||
|
|
@ -121,7 +128,7 @@ describe("KeyInfoView", () => {
|
|||
it("should render tags", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onClose={() => { }}
|
||||
|
|
@ -138,7 +145,7 @@ describe("KeyInfoView", () => {
|
|||
it("should not render tags in metadata textarea", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
const { container } = render(
|
||||
const { container } = renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onClose={() => { }}
|
||||
|
|
@ -168,7 +175,7 @@ describe("KeyInfoView", () => {
|
|||
});
|
||||
|
||||
const keyData = { ...MOCK_KEY_DATA, user_id: "other-user-id" };
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
|
|
@ -213,7 +220,7 @@ describe("KeyInfoView", () => {
|
|||
});
|
||||
|
||||
const keyData = { ...MOCK_KEY_DATA, team_id: teamId, user_id: "other-user-id" };
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
|
|
@ -237,7 +244,7 @@ describe("KeyInfoView", () => {
|
|||
|
||||
const ownerUserId = "owner-user-id";
|
||||
const keyData = { ...MOCK_KEY_DATA, user_id: ownerUserId };
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
|
|
@ -260,7 +267,7 @@ describe("KeyInfoView", () => {
|
|||
});
|
||||
|
||||
const keyData = { ...MOCK_KEY_DATA, user_id: "owner-user-id" };
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
|
|
@ -284,7 +291,7 @@ describe("KeyInfoView", () => {
|
|||
|
||||
const ownerUserId = "internal-viewer-user-id";
|
||||
const keyData = { ...MOCK_KEY_DATA, user_id: ownerUserId };
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
|
|
@ -328,7 +335,7 @@ describe("KeyInfoView", () => {
|
|||
});
|
||||
|
||||
const keyData = { ...MOCK_KEY_DATA, team_id: "non-matching-team-id", user_id: "other-user-id" };
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
|
|
@ -342,7 +349,7 @@ describe("KeyInfoView", () => {
|
|||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onClose={onCloseMock}
|
||||
|
|
@ -365,7 +372,7 @@ describe("KeyInfoView", () => {
|
|||
|
||||
describe("'Edit Settings' button visibility in the Settings tab", () => {
|
||||
const renderAndOpenSettingsTab = async (keyData = MOCK_KEY_DATA) => {
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={keyData}
|
||||
onClose={() => {}}
|
||||
|
|
@ -474,7 +481,7 @@ describe("KeyInfoView", () => {
|
|||
},
|
||||
};
|
||||
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={keyDataWithGuardrails}
|
||||
onClose={() => { }}
|
||||
|
|
@ -500,7 +507,7 @@ describe("KeyInfoView", () => {
|
|||
},
|
||||
};
|
||||
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={keyDataWithPolicies}
|
||||
onClose={() => { }}
|
||||
|
|
@ -518,7 +525,7 @@ describe("KeyInfoView", () => {
|
|||
it("should display no key found message when keyData is undefined", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
render(
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={undefined}
|
||||
onClose={() => { }}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
|
||||
|
|
@ -48,6 +49,7 @@ export default function KeyInfoView({
|
|||
}: KeyInfoViewProps) {
|
||||
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
|
||||
const { teams: teamsData } = useTeams();
|
||||
const { data: projects } = useProjects();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
|
@ -571,6 +573,20 @@ export default function KeyInfoView({
|
|||
<Text>{currentKeyData.team_id || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Project</Text>
|
||||
<Text>
|
||||
{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"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Organization</Text>
|
||||
<Text>{currentKeyData.organization_id || "Not Set"}</Text>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue