refactor(ui): migrate team detail controls

This commit is contained in:
Yuneng Jiang 2026-08-12 13:57:10 -07:00
parent 2569c27a91
commit 3a0e1cefde
No known key found for this signature in database
3 changed files with 93 additions and 107 deletions

View file

@ -3525,17 +3525,11 @@
"src/components/team/TeamMemberTab.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/team/TeamVirtualKeysTable.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/team/member_permissions.tsx": {

View file

@ -1,13 +1,13 @@
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Tooltip } from "@/components/atoms/Tooltip";
import MemberTable from "@/components/common_components/MemberTable";
import { Member } from "@/components/networking";
import { DateCell, MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Space, Tooltip, Typography } from "antd";
import type { ColumnsType } from "antd/es/table";
import MemberTable from "@/components/common_components/MemberTable";
import { CircleHelp } from "lucide-react";
import type { ComponentProps } from "react";
import { TeamData } from "./TeamInfo";
interface TeamMemberTabProps {
@ -97,48 +97,48 @@ export default function TeamMemberTab({
return membership?.litellm_budget_table?.budget_reset_at ?? null;
};
const extraColumns: ColumnsType<Member> = [
const extraColumns: NonNullable<ComponentProps<typeof MemberTable>["extraColumns"]> = [
{
title: (
<Space direction="horizontal">
<span className="flex items-center gap-1">
Model Scope
<Tooltip title="Models this member can access. Empty means they inherit all team models.">
<InfoCircleOutlined />
<Tooltip content="Models this member can access. Empty means they inherit all team models.">
<CircleHelp className="size-4" aria-label="Model scope information" />
</Tooltip>
</Space>
</span>
),
key: "model_scope",
render: (_: unknown, record: Member) => {
const models = getUserAllowedModels(record.user_id);
if (!models) {
return <Typography.Text type="secondary">(all team models)</Typography.Text>;
return <span className="text-muted-foreground">(all team models)</span>;
}
const displayed = models.slice(0, 2);
const remaining = models.length - displayed.length;
return (
<Space wrap>
<div className="flex flex-wrap gap-1">
{displayed.map((m) => (
<Typography.Text key={m} code style={{ fontSize: "12px" }}>
<code key={m} className="rounded bg-muted px-1 py-0.5 text-xs">
{m}
</Typography.Text>
</code>
))}
{remaining > 0 && (
<Tooltip title={models.slice(2).join(", ")}>
<Typography.Text type="secondary">+{remaining} more</Typography.Text>
<Tooltip content={models.slice(2).join(", ")}>
<span className="text-muted-foreground">+{remaining} more</span>
</Tooltip>
)}
</Space>
</div>
);
},
},
{
title: (
<Space direction="horizontal">
<span className="flex items-center gap-1">
Current Cycle Spend (USD)
<Tooltip title="Spend for the current budget cycle. Resets to $0 when the member's budget window rolls over. This is the value checked against the member's budget.">
<InfoCircleOutlined />
<Tooltip content="Spend for the current budget cycle. Resets to $0 when the member's budget window rolls over. This is the value checked against the member's budget.">
<CircleHelp className="size-4" aria-label="Current cycle spend information" />
</Tooltip>
</Space>
</span>
),
key: "spend",
render: (_: unknown, record: Member) => (
@ -147,12 +147,12 @@ export default function TeamMemberTab({
},
{
title: (
<Space direction="horizontal">
<span className="flex items-center gap-1">
Total Spend (USD)
<Tooltip title="Cumulative spend by this member within this team, across all budget cycles. Tracking began 2026-04-21; spend from before that date is not included.">
<InfoCircleOutlined />
<Tooltip content="Cumulative spend by this member within this team, across all budget cycles. Tracking began 2026-04-21; spend from before that date is not included.">
<CircleHelp className="size-4" aria-label="Total spend information" />
</Tooltip>
</Space>
</span>
),
key: "total_spend",
render: (_: unknown, record: Member) => <MoneyCell value={getUserTotalSpend(record.user_id)} decimals={2} />,
@ -171,15 +171,15 @@ export default function TeamMemberTab({
},
{
title: (
<Space direction="horizontal">
<span className="flex items-center gap-1">
Team Member Rate Limits
<Tooltip title="Rate limits for this member's usage within this team.">
<InfoCircleOutlined />
<Tooltip content="Rate limits for this member's usage within this team.">
<CircleHelp className="size-4" aria-label="Team member rate limits information" />
</Tooltip>
</Space>
</span>
),
key: "rate_limits",
render: (_: unknown, record: Member) => <Typography.Text>{getUserRateLimits(record.user_id)}</Typography.Text>,
render: (_: unknown, record: Member) => <span>{getUserRateLimits(record.user_id)}</span>,
},
];

View file

@ -1,5 +1,7 @@
"use client";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { Tooltip } from "@/components/atoms/Tooltip";
import CopyButton from "@/components/shared/CopyButton";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import {
DataTable,
@ -8,13 +10,13 @@ import {
DataTableSortHeader,
DataTableToolbar,
} from "@/components/shared/DataTable";
import { Badge } from "@/components/ui/badge";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { Badge, Icon, Text } from "@tremor/react";
import { Popover, Tooltip, Typography } from "antd";
import { ChevronDown, ChevronRight } from "lucide-react";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
@ -147,12 +149,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
enableSorting: true,
cell: (info) => {
const value = info.getValue() as string;
const width = info.cell.column.getSize();
return (
<Tooltip title={value}>
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
{value ?? "-"}
</span>
<Tooltip content={value}>
<span className="block max-w-full truncate font-mono text-xs">{value ?? "-"}</span>
</Tooltip>
);
},
@ -182,12 +181,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
cell: (info) => {
const user = info.getValue() as { user_email?: string } | undefined;
const value = user?.user_email;
const width = info.cell.column.getSize();
return (
<Tooltip title={value}>
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
{value ?? "-"}
</span>
<Tooltip content={value}>
<span className="block max-w-full truncate font-mono text-xs">{value ?? "-"}</span>
</Tooltip>
);
},
@ -201,12 +197,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
cell: (info) => {
const userId = info.getValue() as string | null;
const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId;
const width = info.cell.column.getSize();
return (
<Tooltip title={displayValue}>
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
{displayValue ?? "-"}
</span>
<Tooltip content={displayValue}>
<span className="block max-w-full truncate font-mono text-xs">{displayValue ?? "-"}</span>
</Tooltip>
);
},
@ -234,21 +227,21 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
const userEmail = created_by_user?.user_email ?? null;
const isDefaultAdmin = userId === "default_user_id";
const displayValue = userAlias || userEmail || userId;
const width = info.cell.column.getSize();
const popoverContent = (
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
<div className="flex min-w-[200px] max-w-[300px] flex-col gap-2 text-xs">
{[
{ label: "User Alias", value: userAlias },
{ label: "User Email", value: userEmail },
{ label: "User ID", value: userId },
].map(({ label, value }) => (
<div key={label} className="flex flex-col min-w-0">
<span className="text-gray-400">{label}</span>
<span className="text-muted-foreground">{label}</span>
{value ? (
<Typography.Text className="font-mono text-xs" ellipsis={{ tooltip: value }} copyable>
{value}
</Typography.Text>
<span className="flex items-center gap-1">
<span className="min-w-0 flex-1 truncate font-mono text-xs">{value}</span>
<CopyButton value={value} label={`Copy ${label}`} />
</span>
) : (
<span className="font-mono">-</span>
)}
@ -259,23 +252,24 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
if (isDefaultAdmin && !userAlias && !userEmail) {
return (
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
<span className="cursor-default">
<HoverCard>
<HoverCardTrigger render={<span className="cursor-default" />}>
<DefaultProxyAdminTag userId={userId} />
</span>
</Popover>
</HoverCardTrigger>
<HoverCardContent align="start">{popoverContent}</HoverCardContent>
</HoverCard>
);
}
return (
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
<span
className="font-mono text-xs truncate block cursor-default"
style={{ maxWidth: width, overflow: "hidden" }}
<HoverCard>
<HoverCardTrigger
render={<span className="block max-w-full cursor-default truncate font-mono text-xs" />}
>
{displayValue}
</span>
</Popover>
</HoverCardTrigger>
<HoverCardContent align="start">{popoverContent}</HoverCardContent>
</HoverCard>
);
},
},
@ -342,14 +336,14 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
const models = info.getValue() as string[];
const scope = deriveKeyModelScope(info.row.original.allowed_routes, info.row.original.key_type);
const emptyModelsBadge = !scope.hasModelAccess ? (
<Tooltip title={`Scoped to ${scope.label} routes; this key cannot call any models`}>
<Badge size="xs" className="mb-1" color="gray">
<Text>No model access</Text>
<Tooltip content={`Scoped to ${scope.label} routes; this key cannot call any models`}>
<Badge variant="secondary" className="mb-1">
No model access
</Badge>
</Tooltip>
) : (
<Badge size="xs" className="mb-1" color="red">
<Text>All Proxy Models</Text>
<Badge variant="destructive" className="mb-1">
All Proxy Models
</Badge>
);
return (
@ -362,57 +356,55 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
<>
<div className="flex items-start">
{models.length > 3 && (
<div>
<Icon
icon={expandedAccordions[info.row.id] ? ChevronDownIcon : ChevronRightIcon}
className="cursor-pointer"
size="xs"
onClick={() =>
setExpandedAccordions((prev) => ({
...prev,
[info.row.id]: !prev[info.row.id],
}))
}
/>
</div>
<button
type="button"
aria-label={expandedAccordions[info.row.id] ? "Collapse models" : "Expand models"}
className="rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() =>
setExpandedAccordions((prev) => ({
...prev,
[info.row.id]: !prev[info.row.id],
}))
}
>
{expandedAccordions[info.row.id] ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
)}
<div className="flex flex-wrap gap-1">
{models.slice(0, 3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index} size="xs" color="red">
<Text>All Proxy Models</Text>
<Badge key={index} variant="destructive">
All Proxy Models
</Badge>
) : (
<Badge key={index} size="xs" color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
<Badge key={index}>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Badge>
),
)}
{models.length > 3 && !expandedAccordions[info.row.id] && (
<Badge size="xs" color="gray" className="cursor-pointer">
<Text>
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
</Text>
<Badge variant="secondary">
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
</Badge>
)}
{expandedAccordions[info.row.id] && (
<div className="flex flex-wrap gap-1">
{models.slice(3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index + 3} size="xs" color="red">
<Text>All Proxy Models</Text>
<Badge key={index + 3} variant="destructive">
All Proxy Models
</Badge>
) : (
<Badge key={index + 3} size="xs" color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
<Badge key={index + 3}>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Badge>
),
)}