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:
yuneng-jiang 2026-02-28 00:15:31 -08:00 committed by GitHub
commit 8abba63b48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 420 additions and 35 deletions

View file

@ -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", () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

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

View file

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

View file

@ -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");

View file

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

View file

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

View file

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