feat(ui): i18n for team batch 3

This commit is contained in:
吹雪 2026-06-11 03:06:02 +09:00
parent dc538a8a3b
commit fd28940f49
6 changed files with 248 additions and 94 deletions

View file

@ -8,6 +8,7 @@ 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 { useTranslation } from "react-i18next";
import { TeamData } from "./TeamInfo";
interface TeamMemberTabProps {
@ -27,6 +28,7 @@ export default function TeamMemberTab({
setIsEditMemberModalVisible,
setIsAddMemberModalVisible,
}: TeamMemberTabProps) {
const { t } = useTranslation();
const formatNumber = (value: number | null): string => {
if (value === null || value === undefined) return "0";
@ -70,7 +72,7 @@ export default function TeamMemberTab({
// Helper function to get rate limits for a user
const getUserRateLimits = (userId: string | null): string => {
if (!userId) return "No Limits";
if (!userId) return t("teamPage.teamMemberTab.noLimits");
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
const rpmLimit = membership?.litellm_budget_table?.rpm_limit;
const tpmLimit = membership?.litellm_budget_table?.tpm_limit;
@ -79,7 +81,7 @@ export default function TeamMemberTab({
const tpmText = tpmLimit ? `${formatNumber(tpmLimit)} TPM` : null;
const limits = [rpmText, tpmText].filter(Boolean);
return limits.length > 0 ? limits.join(" / ") : "No Limits";
return limits.length > 0 ? limits.join(" / ") : t("teamPage.teamMemberTab.noLimits");
};
const { data: uiSettingsData } = useUISettings();
@ -105,8 +107,8 @@ export default function TeamMemberTab({
{
title: (
<Space direction="horizontal">
Model Scope
<Tooltip title="Models this member can access. Empty means they inherit all team models.">
{t("teamPage.teamMemberTab.modelScopeTitle")}
<Tooltip title={t("teamPage.teamMemberTab.modelScopeTooltip")}>
<InfoCircleOutlined />
</Tooltip>
</Space>
@ -115,7 +117,7 @@ export default function TeamMemberTab({
render: (_: unknown, record: Member) => {
const models = getUserAllowedModels(record.user_id);
if (!models) {
return <Typography.Text type="secondary">(all team models)</Typography.Text>;
return <Typography.Text type="secondary">{t("teamPage.teamMemberTab.allTeamModels")}</Typography.Text>;
}
const displayed = models.slice(0, 2);
const remaining = models.length - displayed.length;
@ -128,7 +130,9 @@ export default function TeamMemberTab({
))}
{remaining > 0 && (
<Tooltip title={models.slice(2).join(", ")}>
<Typography.Text type="secondary">+{remaining} more</Typography.Text>
<Typography.Text type="secondary">
{t("teamPage.teamMemberTab.moreModels", { count: remaining })}
</Typography.Text>
</Tooltip>
)}
</Space>
@ -138,8 +142,8 @@ export default function TeamMemberTab({
{
title: (
<Space direction="horizontal">
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.">
{t("teamPage.teamMemberTab.currentCycleSpendTitle")}
<Tooltip title={t("teamPage.teamMemberTab.currentCycleSpendTooltip")}>
<InfoCircleOutlined />
</Tooltip>
</Space>
@ -152,8 +156,8 @@ export default function TeamMemberTab({
{
title: (
<Space direction="horizontal">
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.">
{t("teamPage.teamMemberTab.totalSpendTitle")}
<Tooltip title={t("teamPage.teamMemberTab.totalSpendTooltip")}>
<InfoCircleOutlined />
</Tooltip>
</Space>
@ -164,17 +168,19 @@ export default function TeamMemberTab({
),
},
{
title: "Team Member Budget (USD)",
title: t("teamPage.teamMemberTab.memberBudgetTitle"),
key: "budget",
render: (_: unknown, record: Member) => {
const budget = getUserBudget(record.user_id);
return (
<Typography.Text>{budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : "No Limit"}</Typography.Text>
<Typography.Text>
{budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : t("teamPage.teamMemberTab.noLimit")}
</Typography.Text>
);
},
},
{
title: "Budget Reset",
title: t("teamPage.teamMemberTab.budgetResetTitle"),
key: "budget_reset",
render: (_: unknown, record: Member) => {
const reset = getUserBudgetReset(record.user_id);
@ -188,8 +194,8 @@ export default function TeamMemberTab({
{
title: (
<Space direction="horizontal">
Team Member Rate Limits
<Tooltip title="Rate limits for this member's usage within this team.">
{t("teamPage.teamMemberTab.rateLimitsTitle")}
<Tooltip title={t("teamPage.teamMemberTab.rateLimitsTooltip")}>
<InfoCircleOutlined />
</Tooltip>
</Space>
@ -218,8 +224,8 @@ export default function TeamMemberTab({
}}
onDelete={handleMemberDelete}
onAddMember={() => setIsAddMemberModalVisible(true)}
roleColumnTitle="Team Role"
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
roleColumnTitle={t("teamPage.teamMemberTab.roleColumnTitle")}
roleTooltip={t("teamPage.teamMemberTab.roleTooltip")}
extraColumns={extraColumns}
showDeleteForMember={() =>
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)

View file

@ -28,6 +28,7 @@ import { InfoCircleOutlined } from "@ant-design/icons";
import { Popover, Skeleton, Tooltip, Typography } from "antd";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import FilterComponent, { FilterOption } from "../molecules/filter";
@ -48,6 +49,7 @@ interface TeamVirtualKeysTableProps {
* Displays all virtual keys belonging to the team with same format and styling.
*/
export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) {
const { t } = useTranslation();
const { accessToken } = useAuthorized();
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
@ -211,7 +213,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "token",
accessorKey: "token",
header: "Key ID",
header: t("teamPage.teamVirtualKeysTable.colKeyId"),
size: 100,
enableSorting: true,
cell: (info) => {
@ -235,7 +237,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "key_alias",
accessorKey: "key_alias",
header: "Key Alias",
header: t("teamPage.teamVirtualKeysTable.colKeyAlias"),
size: 150,
enableSorting: true,
cell: (info) => {
@ -253,7 +255,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "key_name",
accessorKey: "key_name",
header: "Secret Key",
header: t("teamPage.teamVirtualKeysTable.colSecretKey"),
size: 120,
enableSorting: false,
cell: (info) => <span className="font-mono text-xs">{info.getValue() as string}</span>,
@ -261,7 +263,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "organization_id",
accessorKey: "organization_id",
header: "Organization ID",
header: t("teamPage.teamVirtualKeysTable.colOrganizationId"),
size: 140,
enableSorting: false,
cell: (info) => (info.getValue() ? info.renderValue() : "-"),
@ -269,7 +271,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "user_email",
accessorKey: "user",
header: "User Email",
header: t("teamPage.teamVirtualKeysTable.colUserEmail"),
size: 160,
enableSorting: false,
cell: (info) => {
@ -288,12 +290,13 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "user_id",
accessorKey: "user_id",
header: "User ID",
header: t("teamPage.teamVirtualKeysTable.colUserId"),
size: 70,
enableSorting: false,
cell: (info) => {
const userId = info.getValue() as string | null;
const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId;
const displayValue =
userId === "default_user_id" ? t("teamPage.teamVirtualKeysTable.defaultProxyAdmin") : userId;
const width = info.cell.column.getSize();
return (
<Tooltip title={displayValue}>
@ -307,7 +310,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "created_at",
accessorKey: "created_at",
header: "Created At",
header: t("common.createdAt"),
size: 120,
enableSorting: true,
cell: (info) => {
@ -318,7 +321,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "created_by",
accessorKey: "created_by",
header: "Created By",
header: t("teamPage.teamVirtualKeysTable.colCreatedBy"),
size: 70,
enableSorting: false,
cell: (info) => {
@ -334,9 +337,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
const popoverContent = (
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
{[
{ label: "User Alias", value: userAlias },
{ label: "User Email", value: userEmail },
{ label: "User ID", value: userId },
{ label: t("teamPage.teamVirtualKeysTable.popoverUserAlias"), value: userAlias },
{ label: t("teamPage.teamVirtualKeysTable.popoverUserEmail"), value: userEmail },
{ label: t("teamPage.teamVirtualKeysTable.popoverUserId"), value: userId },
].map(({ label, value }) => (
<div key={label} className="flex flex-col min-w-0">
<span className="text-gray-400">{label}</span>
@ -377,12 +380,12 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "updated_at",
accessorKey: "updated_at",
header: "Updated At",
header: t("common.updatedAt"),
size: 120,
enableSorting: true,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
return value ? new Date(value as string).toLocaleDateString() : t("common.never");
},
},
{
@ -390,11 +393,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
accessorKey: "last_active",
header: () => (
<span className="flex items-center gap-1">
Last Active
<Popover
content="This is a new field and is not backfilled. Only new key usage will update this value."
trigger="hover"
>
{t("teamPage.teamVirtualKeysTable.colLastActive")}
<Popover content={t("teamPage.teamVirtualKeysTable.colLastActiveTooltip")} trigger="hover">
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
</Popover>
</span>
@ -403,7 +403,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
enableSorting: false,
cell: (info) => {
const value = info.getValue();
if (!value) return "Unknown";
if (!value) return t("common.unknown");
const date = new Date(value as string);
return (
<Tooltip title={date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" })}>
@ -415,18 +415,18 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "expires",
accessorKey: "expires",
header: "Expires",
header: t("teamPage.teamVirtualKeysTable.colExpires"),
size: 120,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
return value ? new Date(value as string).toLocaleDateString() : t("common.never");
},
},
{
id: "spend",
accessorKey: "spend",
header: "Spend (USD)",
header: t("teamPage.teamVirtualKeysTable.colSpend"),
size: 100,
enableSorting: true,
cell: (info) => formatNumberWithCommas(info.getValue() as number, 4),
@ -434,30 +434,30 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "max_budget",
accessorKey: "max_budget",
header: "Budget (USD)",
header: t("teamPage.teamVirtualKeysTable.colBudget"),
size: 110,
enableSorting: true,
cell: (info) => {
const maxBudget = info.getValue() as number | null;
if (maxBudget === null) return "Unlimited";
if (maxBudget === null) return t("teamPage.teamVirtualKeysTable.unlimited");
return `$${formatNumberWithCommas(maxBudget)}`;
},
},
{
id: "budget_reset_at",
accessorKey: "budget_reset_at",
header: "Budget Reset",
header: t("teamPage.teamVirtualKeysTable.colBudgetReset"),
size: 130,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleString() : "Never";
return value ? new Date(value as string).toLocaleString() : t("common.never");
},
},
{
id: "models",
accessorKey: "models",
header: "Models",
header: t("teamPage.teamVirtualKeysTable.colModels"),
size: 200,
enableSorting: false,
cell: (info) => {
@ -468,7 +468,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
<div className="flex flex-col">
{models.length === 0 ? (
<Badge size="xs" className="mb-1" color="red">
<Text>All Proxy Models</Text>
<Text>{t("teamPage.teamVirtualKeysTable.allProxyModels")}</Text>
</Badge>
) : (
<>
@ -492,7 +492,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{models.slice(0, 3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index} size="xs" color="red">
<Text>All Proxy Models</Text>
<Text>{t("teamPage.teamVirtualKeysTable.allProxyModels")}</Text>
</Badge>
) : (
<Badge key={index} size="xs" color="blue">
@ -506,9 +506,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
)}
{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>
<Text>{t("teamPage.teamVirtualKeysTable.moreModel", { count: models.length - 3 })}</Text>
</Badge>
)}
{expandedAccordions[info.row.id] && (
@ -516,7 +514,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{models.slice(3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index + 3} size="xs" color="red">
<Text>All Proxy Models</Text>
<Text>{t("teamPage.teamVirtualKeysTable.allProxyModels")}</Text>
</Badge>
) : (
<Badge key={index + 3} size="xs" color="blue">
@ -542,21 +540,21 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
},
{
id: "rate_limits",
header: "Rate Limits",
header: t("teamPage.teamVirtualKeysTable.colRateLimits"),
size: 140,
enableSorting: false,
cell: ({ row }) => {
const key = row.original;
return (
<div>
<div>TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}</div>
<div>RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}</div>
<div>TPM: {key.tpm_limit !== null ? key.tpm_limit : t("teamPage.teamVirtualKeysTable.unlimited")}</div>
<div>RPM: {key.rpm_limit !== null ? key.rpm_limit : t("teamPage.teamVirtualKeysTable.unlimited")}</div>
</div>
);
},
},
],
[expandedAccordions],
[expandedAccordions, t],
);
const handleSortingChange = useCallback(
@ -620,7 +618,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
<Skeleton.Node active style={{ width: 74, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
Page {pageIndex + 1} of {table.getPageCount()}
{t("teamPage.teamVirtualKeysTable.pageOf", { current: pageIndex + 1, total: table.getPageCount() })}
</span>
)}
@ -632,7 +630,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
{t("common.previous")}
</button>
)}
@ -644,7 +642,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
disabled={isLoading || isFetching || !table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
{t("common.next")}
</button>
)}
</div>
@ -730,7 +728,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading keys...</p>
<p>{t("teamPage.teamVirtualKeysTable.loadingKeys")}</p>
</div>
</TableCell>
</TableRow>
@ -763,7 +761,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No keys found</p>
<p>{t("teamPage.teamVirtualKeysTable.noKeysFound")}</p>
</div>
</TableCell>
</TableRow>

View file

@ -13,6 +13,7 @@ import {
} from "@tremor/react";
import { availableTeamListCall, teamMemberAddCall } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { useTranslation, Trans } from "react-i18next";
interface AvailableTeam {
team_id: string;
@ -28,6 +29,7 @@ interface AvailableTeamsProps {
}
const AvailableTeamsPanel: React.FC<AvailableTeamsProps> = ({ accessToken, userID }) => {
const { t } = useTranslation();
const [availableTeams, setAvailableTeams] = useState<AvailableTeam[]>([]);
useEffect(() => {
@ -55,12 +57,12 @@ const AvailableTeamsPanel: React.FC<AvailableTeamsProps> = ({ accessToken, userI
role: "user",
});
NotificationsManager.success("Successfully joined team");
NotificationsManager.success(t("teamPage.availableTeams.joinedSuccess"));
// Update available teams list
setAvailableTeams((teams) => teams.filter((team) => team.team_id !== teamId));
} catch (error) {
console.error("Error joining team:", error);
NotificationsManager.fromBackend("Failed to join team");
NotificationsManager.fromBackend(t("teamPage.availableTeams.joinFailed"));
}
};
@ -69,11 +71,11 @@ const AvailableTeamsPanel: React.FC<AvailableTeamsProps> = ({ accessToken, userI
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Team Name</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Members</TableHeaderCell>
<TableHeaderCell>Models</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
<TableHeaderCell>{t("teamPage.availableTeams.colTeamName")}</TableHeaderCell>
<TableHeaderCell>{t("common.description")}</TableHeaderCell>
<TableHeaderCell>{t("teamPage.availableTeams.colMembers")}</TableHeaderCell>
<TableHeaderCell>{t("teamPage.teamInfo.modelsLabel")}</TableHeaderCell>
<TableHeaderCell>{t("common.actions")}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
@ -83,16 +85,16 @@ const AvailableTeamsPanel: React.FC<AvailableTeamsProps> = ({ accessToken, userI
<Text>{team.team_alias}</Text>
</TableCell>
<TableCell>
<Text>{team.description || "No description available"}</Text>
<Text>{team.description || t("teamPage.availableTeams.noDescription")}</Text>
</TableCell>
<TableCell>
<Text>{team.members_with_roles.length} members</Text>
<Text>{t("teamPage.availableTeams.memberCount", { count: team.members_with_roles.length })}</Text>
</TableCell>
<TableCell>
<div className="flex flex-col">
{!team.models || team.models.length === 0 ? (
<Badge size="xs" color="red">
<Text>All Proxy Models</Text>
<Text>{t("teamPage.availableTeams.allProxyModels")}</Text>
</Badge>
) : (
team.models.map((model, index) => (
@ -105,7 +107,7 @@ const AvailableTeamsPanel: React.FC<AvailableTeamsProps> = ({ accessToken, userI
</TableCell>
<TableCell>
<Button size="xs" variant="secondary" onClick={() => handleJoinTeam(team.team_id)}>
Join Team
{t("teamPage.availableTeams.joinTeam")}
</Button>
</TableCell>
</TableRow>
@ -114,16 +116,20 @@ const AvailableTeamsPanel: React.FC<AvailableTeamsProps> = ({ accessToken, userI
<TableRow>
<TableCell colSpan={5} className="text-center">
<Text>
No available teams to join. See how to set available teams{" "}
<a
href="https://docs.litellm.ai/docs/proxy/self_serve#all-settings-for-self-serve--sso-flow"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700 underline"
>
here
</a>
.
<Trans
i18nKey="teamPage.availableTeams.noTeamsAvailable"
components={{
lnk: (
<a
key="lnk"
href="https://docs.litellm.ai/docs/proxy/self_serve#all-settings-for-self-serve--sso-flow"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700 underline"
/>
),
}}
/>
</Text>
</TableCell>
</TableRow>

View file

@ -3,6 +3,7 @@ import { ReloadOutlined, SaveOutlined } from "@ant-design/icons";
import { Card, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text, Title } from "@tremor/react";
import { Button, Checkbox, Empty } from "antd";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import NotificationsManager from "../molecules/notifications_manager";
import { getPermissionInfo } from "./permission_definitions";
@ -13,6 +14,7 @@ interface MemberPermissionsProps {
}
const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessToken, canEditTeam }) => {
const { t } = useTranslation();
const [permissions, setPermissions] = useState<string[]>([]);
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
@ -30,7 +32,7 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
setSelectedPermissions(teamPermissions);
setHasChanges(false);
} catch (error) {
NotificationsManager.fromBackend("Failed to load permissions");
NotificationsManager.fromBackend(t("teamPage.memberPermissions.loadFailed"));
console.error("Error fetching permissions:", error);
} finally {
setLoading(false);
@ -54,10 +56,10 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
if (!accessToken) return;
setSaving(true);
await teamPermissionsUpdateCall(accessToken, teamId, selectedPermissions);
NotificationsManager.success("Permissions updated successfully");
NotificationsManager.success(t("teamPage.memberPermissions.saveSuccess"));
setHasChanges(false);
} catch (error) {
NotificationsManager.fromBackend("Failed to update permissions");
NotificationsManager.fromBackend(t("teamPage.memberPermissions.saveFailed"));
console.error("Error updating permissions:", error);
} finally {
setSaving(false);
@ -69,7 +71,7 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
};
if (loading) {
return <div className="p-6 text-center">Loading permissions...</div>;
return <div className="p-6 text-center">{t("teamPage.memberPermissions.loadingPermissions")}</div>;
}
const hasPermissions = permissions.length > 0;
@ -77,31 +79,31 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
return (
<Card className="bg-white shadow-md rounded-md p-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b pb-4 mb-6">
<Title className="mb-2 sm:mb-0">Member Permissions</Title>
<Title className="mb-2 sm:mb-0">{t("teamPage.memberPermissions.title")}</Title>
{canEditTeam && hasChanges && (
<div className="flex gap-3">
<Button icon={<ReloadOutlined />} onClick={handleReset}>
Reset
{t("common.reset")}
</Button>
<Button onClick={handleSave} loading={saving} type="primary" icon={<SaveOutlined />}>
Save Changes
{t("teamPage.memberPermissions.saveChanges")}
</Button>
</div>
)}
</div>
<Text className="mb-6 text-gray-600">Control what team members can do when they are not team admins.</Text>
<Text className="mb-6 text-gray-600">{t("teamPage.memberPermissions.subtitle")}</Text>
{hasPermissions ? (
<div className="overflow-x-auto">
<Table className=" min-w-full">
<TableHead>
<TableRow>
<TableHeaderCell>Method</TableHeaderCell>
<TableHeaderCell>Endpoint</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>{t("teamPage.memberPermissions.colMethod")}</TableHeaderCell>
<TableHeaderCell>{t("teamPage.memberPermissions.colEndpoint")}</TableHeaderCell>
<TableHeaderCell>{t("common.description")}</TableHeaderCell>
<TableHeaderCell className="sticky right-0 bg-white shadow-[-4px_0_4px_-4px_rgba(0,0,0,0.1)] text-center">
Allow Access
{t("teamPage.memberPermissions.colAllowAccess")}
</TableHeaderCell>
</TableRow>
</TableHead>
@ -138,7 +140,7 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
</div>
) : (
<div className="py-12">
<Empty description="No permissions available" />
<Empty description={t("teamPage.memberPermissions.noPermissions")} />
</div>
)}
</Card>

View file

@ -6418,6 +6418,77 @@
"userIdLabel": "User ID",
"emailLabel": "Email",
"roleLabel": "Role"
},
"teamMemberTab": {
"noLimits": "No Limits",
"modelScopeTitle": "Model Scope",
"modelScopeTooltip": "Models this member can access. Empty means they inherit all team models.",
"allTeamModels": "(all team models)",
"moreModels": "+{{count}} more",
"currentCycleSpendTitle": "Current Cycle Spend (USD)",
"currentCycleSpendTooltip": "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.",
"totalSpendTitle": "Total Spend (USD)",
"totalSpendTooltip": "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.",
"memberBudgetTitle": "Team Member Budget (USD)",
"noLimit": "No Limit",
"budgetResetTitle": "Budget Reset",
"rateLimitsTitle": "Team Member Rate Limits",
"rateLimitsTooltip": "Rate limits for this member's usage within this team.",
"roleColumnTitle": "Team Role",
"roleTooltip": "This role applies only to this team and is independent from the user's proxy-level role."
},
"teamVirtualKeysTable": {
"colKeyId": "Key ID",
"colKeyAlias": "Key Alias",
"colSecretKey": "Secret Key",
"colOrganizationId": "Organization ID",
"colUserEmail": "User Email",
"colUserId": "User ID",
"colCreatedBy": "Created By",
"colLastActive": "Last Active",
"colLastActiveTooltip": "This is a new field and is not backfilled. Only new key usage will update this value.",
"colExpires": "Expires",
"colSpend": "Spend (USD)",
"colBudget": "Budget (USD)",
"colBudgetReset": "Budget Reset",
"colModels": "Models",
"colRateLimits": "Rate Limits",
"defaultProxyAdmin": "Default Proxy Admin",
"popoverUserAlias": "User Alias",
"popoverUserEmail": "User Email",
"popoverUserId": "User ID",
"unlimited": "Unlimited",
"allProxyModels": "All Proxy Models",
"moreModel_one": "+{{count}} more model",
"moreModel_other": "+{{count}} more models",
"pageOf": "Page {{current}} of {{total}}",
"loadingKeys": "Loading keys...",
"noKeysFound": "No keys found"
},
"availableTeams": {
"colTeamName": "Team Name",
"colMembers": "Members",
"memberCount_one": "{{count}} member",
"memberCount_other": "{{count}} members",
"allProxyModels": "All Proxy Models",
"noDescription": "No description available",
"joinTeam": "Join Team",
"joinedSuccess": "Successfully joined team",
"joinFailed": "Failed to join team",
"noTeamsAvailable": "No available teams to join. See how to set available teams <lnk>here</lnk>."
},
"memberPermissions": {
"loadingPermissions": "Loading permissions...",
"title": "Member Permissions",
"subtitle": "Control what team members can do when they are not team admins.",
"saveChanges": "Save Changes",
"colMethod": "Method",
"colEndpoint": "Endpoint",
"colAllowAccess": "Allow Access",
"noPermissions": "No permissions available",
"loadFailed": "Failed to load permissions",
"saveSuccess": "Permissions updated successfully",
"saveFailed": "Failed to update permissions"
}
}
}

View file

@ -6418,6 +6418,77 @@
"userIdLabel": "用户 ID",
"emailLabel": "邮箱",
"roleLabel": "角色"
},
"teamMemberTab": {
"noLimits": "无限制",
"modelScopeTitle": "模型范围",
"modelScopeTooltip": "该成员可访问的模型。为空表示继承所有团队模型。",
"allTeamModels": "(所有团队模型)",
"moreModels": "+{{count}} 个更多",
"currentCycleSpendTitle": "当前周期花费(USD)",
"currentCycleSpendTooltip": "当前预算周期的花费。成员预算窗口滚动时将重置为 $0,此值与成员预算进行比较。",
"totalSpendTitle": "累计花费(USD)",
"totalSpendTooltip": "该成员在此团队内跨所有预算周期的累计花费。统计始于 2026-04-21,之前的花费不包含在内。",
"memberBudgetTitle": "团队成员预算(USD)",
"noLimit": "无限制",
"budgetResetTitle": "预算重置",
"rateLimitsTitle": "团队成员速率限制",
"rateLimitsTooltip": "该成员在此团队内的速率限制。",
"roleColumnTitle": "团队角色",
"roleTooltip": "此角色仅适用于当前团队,与用户的代理级角色相互独立。"
},
"teamVirtualKeysTable": {
"colKeyId": "密钥 ID",
"colKeyAlias": "密钥别名",
"colSecretKey": "密钥",
"colOrganizationId": "组织 ID",
"colUserEmail": "用户邮箱",
"colUserId": "用户 ID",
"colCreatedBy": "创建者",
"colLastActive": "最后活跃",
"colLastActiveTooltip": "这是一个新字段,未进行历史数据回填。只有新的密钥使用记录才会更新此值。",
"colExpires": "到期时间",
"colSpend": "花费(USD)",
"colBudget": "预算(USD)",
"colBudgetReset": "预算重置",
"colModels": "模型",
"colRateLimits": "速率限制",
"defaultProxyAdmin": "默认代理管理员",
"popoverUserAlias": "用户别名",
"popoverUserEmail": "用户邮箱",
"popoverUserId": "用户 ID",
"unlimited": "无限制",
"allProxyModels": "所有代理模型",
"moreModel_one": "+{{count}} 个模型",
"moreModel_other": "+{{count}} 个模型",
"pageOf": "第 {{current}} 页,共 {{total}} 页",
"loadingKeys": "正在加载密钥...",
"noKeysFound": "未找到密钥"
},
"availableTeams": {
"colTeamName": "团队名称",
"colMembers": "成员",
"memberCount_one": "{{count}} 位成员",
"memberCount_other": "{{count}} 位成员",
"allProxyModels": "所有代理模型",
"noDescription": "暂无描述",
"joinTeam": "加入团队",
"joinedSuccess": "成功加入团队",
"joinFailed": "加入团队失败",
"noTeamsAvailable": "暂无可加入的团队。查看如何设置可用团队,请点击<lnk>此处</lnk>。"
},
"memberPermissions": {
"loadingPermissions": "正在加载权限...",
"title": "成员权限",
"subtitle": "控制非团队管理员的成员可以执行的操作。",
"saveChanges": "保存更改",
"colMethod": "方法",
"colEndpoint": "端点",
"colAllowAccess": "允许访问",
"noPermissions": "暂无可用权限",
"loadFailed": "加载权限失败",
"saveSuccess": "权限更新成功",
"saveFailed": "更新权限失败"
}
}
}