[Feature] UI - Projects: Add Projects page with list and create flows

Add a new Projects page under ACCESS CONTROL in the left nav, positioned
between Teams and Internal Users. Projects sit between teams and keys in
the hierarchy for use-case management.

- ProjectsPage with Ant Design table (list, search, pagination)
- CreateProjectModal with team-scoped model selection
- React-query hooks for /project/list and /project/new
- New badge on Projects, removed from Access Groups
- Page metadata entry for test compliance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-02-27 13:31:17 -08:00
parent ea5d551525
commit 2613277ede
8 changed files with 753 additions and 11 deletions

View file

@ -0,0 +1,70 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ProjectResponse, projectKeys } from "./useProjects";
// ── Types ────────────────────────────────────────────────────────────────────
export interface ProjectCreateParams {
project_alias?: string;
description?: string;
team_id: string;
models?: string[];
max_budget?: number;
blocked?: boolean;
metadata?: Record<string, unknown>;
model_rpm_limit?: Record<string, number>;
model_tpm_limit?: Record<string, number>;
}
// ── Fetch function ───────────────────────────────────────────────────────────
const createProject = async (
accessToken: string,
params: ProjectCreateParams,
): Promise<ProjectResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/new`;
const response = await fetch(url, {
method: "POST",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useCreateProject = () => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<ProjectResponse, Error, ProjectCreateParams>({
mutationFn: async (params) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return createProject(accessToken, params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
});
};

View file

@ -0,0 +1,87 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
// ── Types ────────────────────────────────────────────────────────────────────
export interface ProjectBudget {
budget_id: string;
max_budget: number | null;
soft_budget: number | null;
max_parallel_requests: number | null;
tpm_limit: number | null;
rpm_limit: number | null;
model_max_budget: Record<string, number> | null;
budget_duration: string | null;
}
export interface ProjectResponse {
project_id: string;
project_alias: string | null;
description: string | null;
team_id: string | null;
budget_id: string | null;
metadata: Record<string, unknown> | null;
models: string[];
spend: number;
model_spend: Record<string, number> | null;
model_rpm_limit: Record<string, number> | null;
model_tpm_limit: Record<string, number> | null;
blocked: boolean;
object_permission_id: string | null;
created_at: string;
created_by: string;
updated_at: string;
updated_by: string;
litellm_budget_table: ProjectBudget | null;
}
// ── Query keys (shared across project hooks) ─────────────────────────────────
export const projectKeys = createQueryKeys("projects");
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchProjects = async (
accessToken: string,
): Promise<ProjectResponse[]> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/list`;
const response = await fetch(url, {
method: "GET",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useProjects = () => {
const { accessToken, userRole } = useAuthorized();
return useQuery<ProjectResponse[]>({
queryKey: projectKeys.list({}),
queryFn: async () => fetchProjects(accessToken!),
enabled:
Boolean(accessToken) && all_admin_roles.includes(userRole || ""),
});
};

View file

@ -37,6 +37,7 @@ import UIThemeSettings from "@/components/ui_theme_settings";
import Usage from "@/components/usage";
import UserDashboard from "@/components/user_dashboard";
import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage";
import { ProjectsPage } from "@/components/Projects/ProjectsPage";
import VectorStoreManagement from "@/components/vector_store_management";
import ToolPolicies from "@/components/ToolPolicies";
import SpendLogsTable from "@/components/view_logs";
@ -547,6 +548,8 @@ function CreateKeyPageContent() {
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "access-groups" ? (
<AccessGroupsPage />
) : page == "projects" ? (
<ProjectsPage />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tool-policies" ? (

View file

@ -0,0 +1,348 @@
import { useEffect, useState } from "react";
import {
Alert,
Modal,
Form,
Input,
Select,
Switch,
InputNumber,
Collapse,
Button,
Col,
Flex,
Row,
Space,
Divider,
Typography,
message,
} from "antd";
import { FolderAddOutlined, PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useCreateProject, ProjectCreateParams } from "@/app/(dashboard)/hooks/projects/useCreateProject";
import { Team } from "../../key_team_helpers/key_list";
import { fetchTeamModels } from "../../organisms/create_key_button";
import { getModelDisplayName } from "../../key_team_helpers/fetch_available_models_team_key";
interface CreateProjectModalProps {
isOpen: boolean;
onClose: () => void;
}
export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) {
const [form] = Form.useForm();
const { accessToken, userId, userRole } = useAuthorized();
const { data: teams } = useTeams();
const createMutation = useCreateProject();
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
// Fetch team-scoped models when team selection changes
useEffect(() => {
if (userId && userRole && accessToken && selectedTeam) {
fetchTeamModels(userId, userRole, accessToken, selectedTeam.team_id).then((models) => {
const allModels = Array.from(new Set([...(selectedTeam.models ?? []), ...models]));
setModelsToPick(allModels);
});
} else {
setModelsToPick([]);
}
form.setFieldValue("models", []);
}, [selectedTeam, accessToken, userId, userRole, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
// Build model-specific limits from the dynamic form list
const modelRpmLimit: Record<string, number> = {};
const modelTpmLimit: Record<string, number> = {};
for (const entry of values.modelLimits ?? []) {
if (entry.model) {
if (entry.rpm != null) modelRpmLimit[entry.model] = entry.rpm;
if (entry.tpm != null) modelTpmLimit[entry.model] = entry.tpm;
}
}
// Build metadata from the dynamic form list
const metadata: Record<string, unknown> = {};
for (const entry of values.metadata ?? []) {
if (entry.key) metadata[entry.key] = entry.value;
}
const params: ProjectCreateParams = {
project_alias: values.project_alias,
description: values.description,
team_id: values.team_id,
models: values.models ?? [],
max_budget: values.max_budget,
blocked: values.isBlocked ?? false,
...(Object.keys(modelRpmLimit).length > 0 && { model_rpm_limit: modelRpmLimit }),
...(Object.keys(modelTpmLimit).length > 0 && { model_tpm_limit: modelTpmLimit }),
...(Object.keys(metadata).length > 0 && { metadata }),
};
createMutation.mutate(params, {
onSuccess: () => {
message.success("Project created successfully");
form.resetFields();
setSelectedTeam(null);
setModelsToPick([]);
onClose();
},
onError: (error) => {
message.error(error.message || "Failed to create project");
},
});
} catch (error) {
console.error("Validation failed:", error);
}
};
const handleCancel = () => {
form.resetFields();
setSelectedTeam(null);
setModelsToPick([]);
onClose();
};
const handleTeamChange = (teamId: string) => {
const team = teams?.find((t) => t.team_id === teamId) ?? null;
setSelectedTeam(team);
};
return (
<Modal
title={
<Typography.Text strong style={{ fontSize: 18 }}>
Create New Project
</Typography.Text>
}
open={isOpen}
onCancel={handleCancel}
width={720}
footer={[
<Button key="cancel" onClick={handleCancel}>
Cancel
</Button>,
<Button key="submit" type="primary" icon={<FolderAddOutlined />} loading={createMutation.isPending} onClick={handleSubmit}>
Create Project
</Button>,
]}
>
<Form
form={form}
layout="vertical"
initialValues={{
isBlocked: false,
}}
style={{ marginTop: 24 }}
>
{/* Basic Info */}
<Typography.Text
strong
style={{ fontSize: 13, color: "#374151", textTransform: "uppercase", letterSpacing: "0.05em" }}
>
Basic Information
</Typography.Text>
<Divider style={{ marginTop: 8, marginBottom: 16 }} />
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="project_alias"
label="Project Name"
rules={[{ required: true, message: "Please enter a project name" }]}
>
<Input placeholder="e.g. Customer Support Bot" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="team_id" label="Team" rules={[{ required: true, message: "Please select a team" }]}>
<Select
showSearch
placeholder="Search or select a team"
onChange={handleTeamChange}
allowClear
optionLabelProp="label"
filterOption={(input, option) => {
const team = teams?.find((t) => t.team_id === option?.value);
if (!team) return false;
const search = input.toLowerCase().trim();
return (
(team.team_alias || "").toLowerCase().includes(search) ||
team.team_id.toLowerCase().includes(search)
);
}}
>
{teams?.map((team) => (
<Select.Option key={team.team_id} value={team.team_id} label={team.team_alias || team.team_id}>
<span style={{ fontWeight: 500 }}>{team.team_alias}</span>{" "}
<span style={{ color: "#9ca3af" }}>({team.team_id})</span>
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item name="description" label="Description">
<Input.TextArea placeholder="Describe the purpose of this project" rows={3} />
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item
name="models"
label="Allowed Models (scoped to selected team's models)"
help={!selectedTeam ? "Select a team first to see available models" : undefined}
>
<Select
mode="multiple"
placeholder={selectedTeam ? "Select models" : "Select a team first"}
disabled={!selectedTeam}
allowClear
maxTagCount="responsive"
onChange={(values) => {
if (values.includes("all-team-models")) {
form.setFieldsValue({ models: ["all-team-models"] });
}
}}
>
<Select.Option key="all-team-models" value="all-team-models">
All Team Models
</Select.Option>
{modelsToPick.map((model) => (
<Select.Option key={model} value={model}>
{getModelDisplayName(model)}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item name="max_budget" label="Max Budget (USD)">
<InputNumber prefix="$" style={{ width: "100%" }} placeholder="0.00" min={0} precision={2} />
</Form.Item>
</Col>
</Row>
{/* Advanced Settings */}
<Row>
<Col span={24}>
<Collapse ghost style={{ background: "#f9fafb", borderRadius: 8, border: "1px solid #e5e7eb" }}>
<Collapse.Panel
header={
<Typography.Text strong style={{ color: "#374151" }}>
Advanced Settings
</Typography.Text>
}
key="1"
>
<Flex align="center" gap={12}>
<Typography.Text strong>Block Project</Typography.Text>
<Form.Item name="isBlocked" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</Flex>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.isBlocked !== cur.isBlocked}>
{({ getFieldValue }) =>
getFieldValue("isBlocked") ? (
<Alert
banner
type="warning"
showIcon
message="All API requests using keys under this project will be rejected."
style={{ marginTop: 12 }}
/>
) : null
}
</Form.Item>
<Divider />
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
Model-Specific Limits
</Typography.Text>
<Form.List name="modelLimits">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, "model"]}
rules={[{ required: true, message: "Missing model" }]}
>
<Input placeholder="Model name (e.g. gpt-4)" />
</Form.Item>
<Form.Item {...restField} name={[name, "tpm"]}>
<InputNumber placeholder="TPM Limit" min={0} />
</Form.Item>
<Form.Item {...restField} name={[name, "rpm"]}>
<InputNumber placeholder="RPM Limit" min={0} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Model Limit
</Button>
</Form.Item>
</>
)}
</Form.List>
<Divider />
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
Metadata
</Typography.Text>
<Form.List name="metadata">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, "key"]}
rules={[{ required: true, message: "Missing key" }]}
>
<Input placeholder="Key" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
rules={[{ required: true, message: "Missing value" }]}
>
<Input placeholder="Value" />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Key-Value Pair
</Button>
</Form.Item>
</>
)}
</Form.List>
</Collapse.Panel>
</Collapse>
</Col>
</Row>
</Form>
</Modal>
);
}

View file

@ -0,0 +1,211 @@
import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { PlusOutlined } from "@ant-design/icons";
import {
Button,
Card,
Flex,
Input,
Layout,
Space,
Table,
Tag,
theme,
Tooltip,
Typography,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import { LayersIcon, SearchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
const { Title, Text } = Typography;
const { Content } = Layout;
export function ProjectsPage() {
const { token } = theme.useToken();
const { data: projects, isLoading } = useProjects();
const { data: teams } = useTeams();
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
useEffect(() => {
setCurrentPage(1);
}, [searchText]);
// Build a team_id → team_alias lookup from the teams list
const teamAliasMap = useMemo(() => {
const map = new Map<string, string>();
for (const team of teams ?? []) {
map.set(team.team_id, team.team_alias ?? team.team_id);
}
return map;
}, [teams]);
// ---------- filtered data ----------
const filteredProjects = useMemo(() => {
const list = projects ?? [];
if (!searchText) return list;
const lower = searchText.toLowerCase();
return list.filter((p) => {
const alias = teamAliasMap.get(p.team_id ?? "") ?? "";
return (
(p.project_alias ?? "").toLowerCase().includes(lower) ||
p.project_id.toLowerCase().includes(lower) ||
(p.description ?? "").toLowerCase().includes(lower) ||
alias.toLowerCase().includes(lower)
);
});
}, [projects, searchText, teamAliasMap]);
// ---------- Ant Design columns ----------
const columns: ColumnsType<ProjectResponse> = [
{
title: "ID",
dataIndex: "project_id",
key: "project_id",
width: 170,
render: (id: string) => (
<Tooltip title={id}>
<Text
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
>
{id}
</Text>
</Tooltip>
),
},
{
title: "Name",
dataIndex: "project_alias",
key: "project_alias",
sorter: (a, b) => (a.project_alias ?? "").localeCompare(b.project_alias ?? ""),
render: (alias: string | null) => alias ?? "—",
},
{
title: "Team",
key: "team",
sorter: (a, b) => {
const aAlias = teamAliasMap.get(a.team_id ?? "") ?? "";
const bAlias = teamAliasMap.get(b.team_id ?? "") ?? "";
return aAlias.localeCompare(bAlias);
},
render: (_: unknown, record: ProjectResponse) => {
const alias = teamAliasMap.get(record.team_id ?? "");
return alias ?? record.team_id ?? "—";
},
},
{
title: "Models",
key: "models",
render: (_: unknown, record: ProjectResponse) => {
const models = record.models ?? [];
return (
<Tooltip title={models.length > 0 ? models.join(", ") : "No models"}>
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<LayersIcon size={14} />
{models.length}
</Flex>
</Tag>
</Tooltip>
);
},
},
{
title: "Status",
dataIndex: "blocked",
key: "status",
render: (blocked: boolean) => (
<Tag color={blocked ? "red" : "green"}>
{blocked ? "Blocked" : "Active"}
</Tag>
),
},
{
title: "Created",
dataIndex: "created_at",
key: "created_at",
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
responsive: ["lg"],
render: (date: string) => new Date(date).toLocaleDateString(),
},
{
title: "Updated",
dataIndex: "updated_at",
key: "updated_at",
responsive: ["xl"],
render: (date: string) => new Date(date).toLocaleDateString(),
},
];
return (
<Content
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
>
<Flex
justify="space-between"
align="center"
style={{ marginBottom: 16 }}
>
<Space direction="vertical" size={0}>
<Title level={2} style={{ margin: 0 }}>
Projects
</Title>
<Text type="secondary">
Manage projects within your teams
</Text>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalVisible(true)}
>
Create Project
</Button>
</Flex>
<Card styles={{ body: { padding: 0 } }}>
<Flex
justify="space-between"
align="center"
style={{ padding: "12px 16px" }}
>
<Input
prefix={<SearchIcon size={16} />}
placeholder="Search projects by name, ID, description, or team..."
style={{ maxWidth: 400 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
</Flex>
<Table
columns={columns}
dataSource={filteredProjects}
rowKey="project_id"
loading={isLoading}
pagination={{
current: currentPage,
pageSize,
total: filteredProjects.length,
onChange: (page) => setCurrentPage(page),
size: "small",
showTotal: (total) => `${total} projects`,
showSizeChanger: false,
}}
/>
</Card>
<CreateProjectModal
isOpen={isCreateModalVisible}
onClose={() => setIsCreateModalVisible(false)}
/>
</Content>
);
}

View file

@ -0,0 +1,14 @@
export interface Project {
id: string;
name: string;
description: string;
teamId: string;
teamAlias: string;
models: string[];
status: "active" | "blocked";
spend: number;
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}

View file

@ -13,6 +13,7 @@ import {
DatabaseOutlined,
ExperimentOutlined,
FileTextOutlined,
FolderOutlined,
KeyOutlined,
LineChartOutlined,
PlayCircleOutlined,
@ -172,6 +173,23 @@ const menuGroups: MenuGroup[] = [
{
groupLabel: "ACCESS CONTROL",
items: [
{
key: "teams",
page: "teams",
label: "Teams",
icon: <TeamOutlined />,
},
{
key: "projects",
page: "projects",
label: (
<span className="flex items-center gap-2">
Projects <NewBadge />
</span>
),
icon: <FolderOutlined />,
roles: all_admin_roles,
},
{
key: "users",
page: "users",
@ -179,12 +197,6 @@ const menuGroups: MenuGroup[] = [
icon: <UserOutlined />,
roles: all_admin_roles,
},
{
key: "teams",
page: "teams",
label: "Teams",
icon: <TeamOutlined />,
},
{
key: "organizations",
page: "organizations",
@ -195,11 +207,7 @@ const menuGroups: MenuGroup[] = [
{
key: "access-groups",
page: "access-groups",
label: (
<span className="flex items-center gap-2">
Access Groups <NewBadge />
</span>
),
label: "Access Groups",
icon: <BlockOutlined />,
roles: all_admin_roles,
},

View file

@ -21,6 +21,7 @@ export const pageDescriptions: Record<string, string> = {
users: "Manage internal user accounts and permissions",
teams: "Create and manage teams for access control",
organizations: "Manage organizations and their members",
projects: "Manage projects within teams",
"access-groups": "Manage access groups for role-based permissions",
budgets: "Set and monitor spending budgets",
api_ref: "Browse API documentation and endpoints",