refactor(ui): move teams, users and orgs text onto i18n

Moves the page-authored English on /teams, /users and /organizations,
plus the shared member table, user search modal, model select and budget
schedule helpers they render, into react-i18next t() calls backed by
en.json. The rendered English is unchanged

Helpers that files outside this batch or untouched tests still call keep
their signatures and take an optional t that defaults to the shared
instance, the same as usageUnits.ts. Text that existing tests pin, values
compared against the API, code samples, CSV column names and date formats
stay as they were
This commit is contained in:
Yuneng Jiang 2026-10-31 11:53:34 -07:00
parent bfef2e1e56
commit 2feef1f636
No known key found for this signature in database
47 changed files with 3946 additions and 1600 deletions

View file

@ -2,6 +2,7 @@ import { FilterInput } from "@/components/common_components/Filters/FilterInput"
import { FiltersButton } from "@/components/common_components/Filters/FiltersButton";
import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton";
import { Search, User } from "lucide-react";
import { useTranslation } from "react-i18next";
interface OrganizationFiltersProps {
filters: FilterState;
@ -23,6 +24,7 @@ const OrganizationFilters = ({
onChange,
onReset,
}: OrganizationFiltersProps) => {
const { t } = useTranslation();
const hasActiveFilters = !!(filters.org_id || filters.org_alias);
return (
@ -30,7 +32,7 @@ const OrganizationFilters = ({
{/* Search and Filter Controls */}
<div className="flex flex-wrap items-center gap-3">
<FilterInput
placeholder="Search by Organization Name"
placeholder={t("organizations.filters.searchByName")}
value={filters.org_alias}
onChange={(value) => onChange("org_alias", value)}
icon={Search}
@ -50,7 +52,7 @@ const OrganizationFilters = ({
{showFilters && (
<div className="flex flex-wrap items-center gap-3 mt-3">
<FilterInput
placeholder="Search by Organization ID"
placeholder={t("organizations.filters.searchById")}
value={filters.org_id}
onChange={(value) => onChange("org_id", value)}
icon={User}

View file

@ -4,6 +4,7 @@ import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organization
import { useQueryClient } from "@tanstack/react-query";
import { parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { toast } from "@/lib/toast";
import { organizationDeleteCall } from "@/components/networking";
@ -28,6 +29,7 @@ const ORGANIZATION_DETAIL_STATE = {
const ORGANIZATION_DETAIL_URL_KEYS = { tab: ORGANIZATION_TAB_URL_KEY };
const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, accessToken, premiumUser }) => {
const { t } = useTranslation();
const [{ org: selectedOrgId }, setOrganizationDetail] = useQueryStates(ORGANIZATION_DETAIL_STATE, {
history: "push",
urlKeys: ORGANIZATION_DETAIL_URL_KEYS,
@ -78,7 +80,7 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
try {
setIsDeleting(true);
await organizationDeleteCall(accessToken, orgToDelete);
toast.success("Organization deleted successfully");
toast.success(t("organizations.panel.toasts.deleted"));
setIsDeleteModalOpen(false);
setOrgToDelete(null);
@ -99,16 +101,19 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
return (
<div className="mx-4 mt-4">
<p className="text-sm text-muted-foreground">
This is a LiteLLM Enterprise feature, and requires a valid key to use. Get a trial key{" "}
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
here
</a>
.
<Trans
i18nKey="organizations.panel.enterpriseNotice"
components={{
pricing: (
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
/>
),
}}
/>
</p>
</div>
);
@ -118,7 +123,7 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
<div className="mx-4 mt-4 flex flex-col gap-4">
{(userRole === "Admin" || userRole === "Org Admin") && (
<Button className="w-fit" onClick={() => setIsOrgModalVisible(true)}>
+ Create New Organization
{t("organizations.panel.createButton")}
</Button>
)}
@ -133,7 +138,7 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
/>
) : (
<>
<p className="text-sm text-muted-foreground">Click on an organization ID to view its details.</p>
<p className="text-sm text-muted-foreground">{t("organizations.panel.listHint")}</p>
<OrganizationFilters
filters={filters}
showFilters={showFilters}
@ -157,10 +162,10 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Organization?"
message="Are you sure you want to delete this organization? This action cannot be undone."
resourceInformationTitle="Organization Information"
resourceInformation={[{ label: "Organization ID", value: orgToDelete, code: true }]}
title={t("organizations.panel.deleteModal.title")}
message={t("organizations.panel.deleteModal.message")}
resourceInformationTitle={t("organizations.panel.deleteModal.infoTitle")}
resourceInformation={[{ label: t("organizations.panel.deleteModal.idLabel"), value: orgToDelete, code: true }]}
onCancel={cancelDelete}
onOk={confirmDelete}
confirmLoading={isDeleting}

View file

@ -2,6 +2,7 @@
import { Building2, SearchX } from "lucide-react";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { DataTable } from "@/components/shared/DataTable";
import { Organization } from "@/components/networking";
@ -20,6 +21,7 @@ interface OrganizationsTableProps {
}
function EmptyState({ searchActive }: { searchActive: boolean }) {
const { t } = useTranslation();
const Icon = searchActive ? SearchX : Building2;
return (
<div className="flex flex-col items-center gap-1 py-6">
@ -27,12 +29,12 @@ function EmptyState({ searchActive }: { searchActive: boolean }) {
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">
{searchActive ? "No matching organizations" : "No organizations yet"}
{searchActive ? t("organizations.table.empty.noMatchesTitle") : t("organizations.table.empty.noneTitle")}
</div>
<div className="text-sm text-muted-foreground">
{searchActive
? "No organizations match your search. Try a different name or ID."
: "Create an organization to group teams, models, and budgets."}
? t("organizations.table.empty.noMatchesDescription")
: t("organizations.table.empty.noneDescription")}
</div>
</div>
);
@ -47,12 +49,13 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
onEditClick,
onDeleteClick,
}) => {
const { t } = useTranslation();
const { sorting, onSortingChange, pagination, onPaginationChange } = useOrganizationsTableState();
const columns = useMemo(() => {
const deps = { userRole, onOrganizationClick, onEditClick, onDeleteClick };
const deps = { userRole, onOrganizationClick, onEditClick, onDeleteClick, t };
return getOrganizationsTableColumns(deps);
}, [userRole, onOrganizationClick, onEditClick, onDeleteClick]);
}, [userRole, onOrganizationClick, onEditClick, onDeleteClick, t]);
return (
<DataTable
@ -67,7 +70,7 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
sorting={sorting}
onSortingChange={onSortingChange}
isLoading={isLoading}
loadingMessage="Loading organizations…"
loadingMessage={t("organizations.table.loading")}
noDataMessage={<EmptyState searchActive={searchActive} />}
size="compact"
/>

View file

@ -1,7 +1,9 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells";
@ -25,11 +27,18 @@ const getOrganizationBudget = (organization: Organization): OrganizationBudget =
(organization.litellm_budget_table ?? {}) as OrganizationBudget;
function OrganizationLimitsCell({ organization }: { organization: Organization }) {
const { t } = useTranslation();
const { tpm_limit, rpm_limit } = getOrganizationBudget(organization);
return (
<div className="flex flex-col text-xs text-muted-foreground">
<span>TPM: {tpm_limit ?? "Unlimited"}</span>
<span>RPM: {rpm_limit ?? "Unlimited"}</span>
<span>
{t("organizations.table.limits.tpmLabel")}
{tpm_limit ?? t("organizations.table.limits.unlimited")}
</span>
<span>
{t("organizations.table.limits.rpmLabel")}
{rpm_limit ?? t("organizations.table.limits.unlimited")}
</span>
</div>
);
}
@ -41,10 +50,11 @@ interface OrganizationRowActionsProps {
}
function OrganizationRowActions({ organization, onEditClick, onDeleteClick }: OrganizationRowActionsProps) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open organization actions"
aria-label={t("organizations.table.actions.open")}
data-testid={`organization-actions-${organization.organization_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -56,7 +66,7 @@ function OrganizationRowActions({ organization, onEditClick, onDeleteClick }: Or
onClick={() => onEditClick(organization.organization_id)}
>
<Pencil />
Edit
{t("organizations.table.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
@ -64,7 +74,7 @@ function OrganizationRowActions({ organization, onEditClick, onDeleteClick }: Or
onClick={() => onDeleteClick(organization.organization_id)}
>
<Trash2 />
Delete
{t("organizations.table.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -76,6 +86,7 @@ export interface OrganizationsTableColumnsDeps {
onOrganizationClick: (organizationId: string) => void;
onEditClick: (organizationId: string) => void;
onDeleteClick: (organizationId: string) => void;
t: TFunction;
}
export const getOrganizationsTableColumns = ({
@ -83,12 +94,13 @@ export const getOrganizationsTableColumns = ({
onOrganizationClick,
onEditClick,
onDeleteClick,
t,
}: OrganizationsTableColumnsDeps): ColumnDef<Organization>[] => [
{
id: "organization_id",
accessorKey: "organization_id",
meta: { title: "Organization ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Organization ID" />,
meta: { title: t("organizations.table.columns.id") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("organizations.table.columns.id")} />,
size: 220,
enableSorting: true,
cell: ({ row }) => (
@ -103,8 +115,8 @@ export const getOrganizationsTableColumns = ({
{
id: "organization_alias",
accessorKey: "organization_alias",
meta: { title: "Organization Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Organization Name" />,
meta: { title: t("organizations.table.columns.name") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("organizations.table.columns.name")} />,
size: 200,
enableSorting: true,
cell: ({ row }) => {
@ -120,8 +132,8 @@ export const getOrganizationsTableColumns = ({
id: "created_at",
accessorKey: "created_at",
sortingFn: "datetime",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
meta: { title: t("organizations.table.columns.created") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("organizations.table.columns.created")} />,
size: 130,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
@ -129,8 +141,8 @@ export const getOrganizationsTableColumns = ({
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend (USD)", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
meta: { title: t("organizations.table.columns.spend"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("organizations.table.columns.spend")} />,
size: 120,
enableSorting: true,
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
@ -140,12 +152,17 @@ export const getOrganizationsTableColumns = ({
accessorFn: (organization) => getOrganizationBudget(organization).max_budget ?? Number.POSITIVE_INFINITY,
sortUndefined: "last",
sortDescFirst: true,
meta: { title: "Budget (USD)", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Budget (USD)" />,
meta: { title: t("organizations.table.columns.budget"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("organizations.table.columns.budget")} />,
size: 120,
enableSorting: true,
cell: ({ row }) => (
<MoneyCell value={getOrganizationBudget(row.original).max_budget} decimals={2} emptyText="Unlimited" showZero />
<MoneyCell
value={getOrganizationBudget(row.original).max_budget}
decimals={2}
emptyText={t("organizations.table.budgetUnlimited")}
showZero
/>
),
},
{
@ -156,21 +173,21 @@ export const getOrganizationsTableColumns = ({
: undefined,
sortUndefined: "last",
sortDescFirst: false,
meta: { title: "Models", skeleton: "chips" },
header: ({ column }) => <DataTableSortHeader column={column} title="Models" />,
meta: { title: t("organizations.table.columns.models"), skeleton: "chips" },
header: ({ column }) => <DataTableSortHeader column={column} title={t("organizations.table.columns.models")} />,
size: 260,
enableSorting: true,
cell: ({ row }) => <ModelsCell models={row.original.models} />,
},
{
id: "limits",
meta: { title: "TPM / RPM Limits" },
meta: { title: t("organizations.table.columns.limits") },
header: ({ table }) => (
<DataTableMultiSortHeader
table={table}
fields={[
{ id: "tpm_limit", label: "TPM" },
{ id: "rpm_limit", label: "RPM" },
{ id: "tpm_limit", label: t("organizations.table.columns.tpmSort") },
{ id: "rpm_limit", label: t("organizations.table.columns.rpmSort") },
]}
/>
),
@ -183,11 +200,15 @@ export const getOrganizationsTableColumns = ({
accessorFn: (organization) => organization.members?.length ?? 0,
sortUndefined: "last",
sortDescFirst: true,
meta: { title: "Members", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Members" />,
meta: { title: t("organizations.table.columns.members"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("organizations.table.columns.members")} />,
size: 100,
enableSorting: true,
cell: ({ row }) => <span className="text-sm">{row.original.members?.length ?? 0} Members</span>,
cell: ({ row }) => (
<span className="text-sm">
{t("organizations.table.memberCount", { count: row.original.members?.length ?? 0 })}
</span>
),
},
...(["tpm_limit", "rpm_limit"] as const).map(
(field): ColumnDef<Organization> => ({
@ -201,7 +222,7 @@ export const getOrganizationsTableColumns = ({
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
header: () => <span className="sr-only">{t("organizations.table.columns.actions")}</span>,
size: 64,
enableSorting: false,
enableHiding: false,

View file

@ -1,4 +1,5 @@
import React, { useId, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "@/components/networking";
import { UserEditView } from "./user_edit_view";
import { toast } from "@/lib/toast";
@ -46,6 +47,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
userModels,
allowAllUsers = false,
}) => {
const { t } = useTranslation();
const { premiumUser } = useAuthorized();
const [loading, setLoading] = useState(false);
const [selectedTeams, setSelectedTeams] = useState<string[]>([]);
@ -89,7 +91,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
const handleSubmit = async (formValues: any) => {
if (!accessToken) {
toast.fromError("Access token not found");
toast.fromError(t("users.bulkEdit.toasts.noAccessToken"));
return;
}
@ -125,7 +127,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
const hasTeamAdditions = addToTeams && selectedTeams.length > 0;
if (!hasUserUpdates && !hasTeamAdditions) {
toast.fromError("Please modify at least one field or select teams to add users to");
toast.fromError(t("users.bulkEdit.toasts.nothingToUpdate"));
return;
}
@ -135,10 +137,10 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
if (hasUserUpdates) {
if (updateAllUsers) {
const result = await userBulkUpdateUserCall(accessToken, updatePayload, undefined, true);
successMessages.push(`Updated all users (${result.total_requested} total)`);
successMessages.push(t("users.bulkEdit.toasts.updatedAll", { total: result.total_requested }));
} else {
await userBulkUpdateUserCall(accessToken, updatePayload, userIds);
successMessages.push(`Updated ${userIds.length} user(s)`);
successMessages.push(t("users.bulkEdit.toasts.updated", { count: userIds.length }));
}
}
@ -190,11 +192,13 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
if (successfulTeams.length > 0) {
const totalAdditions = successfulTeams.reduce((sum, r) => sum + r.successfulAdditions, 0);
successMessages.push(`Added users to ${successfulTeams.length} team(s) (${totalAdditions} total additions)`);
successMessages.push(
t("users.bulkEdit.toasts.addedToTeams", { count: successfulTeams.length, additions: totalAdditions }),
);
}
if (failedTeams.length > 0) {
toast.warning(`Failed to add users to ${failedTeams.length} team(s)`);
toast.warning(t("users.bulkEdit.toasts.teamAddFailed", { count: failedTeams.length }));
}
}
@ -212,7 +216,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
onCancel();
} catch (error) {
console.error("Bulk operation failed:", error);
toast.fromError("Failed to perform bulk operations");
toast.fromError(t("users.bulkEdit.toasts.bulkFailed"));
} finally {
setLoading(false);
}
@ -223,7 +227,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>
{updateAllUsers ? "Bulk Edit All Users" : `Bulk Edit ${selectedUsers.length} User(s)`}
{updateAllUsers ? t("users.bulkEdit.titleAll") : t("users.bulkEdit.title", { count: selectedUsers.length })}
</DialogTitle>
</DialogHeader>
{allowAllUsers && (
@ -233,17 +237,15 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
id={updateAllUsersId}
checked={updateAllUsers}
onCheckedChange={(checked) => setUpdateAllUsers(checked === true)}
aria-label="Update ALL users in the system"
aria-label={t("users.bulkEdit.updateAll")}
/>
<label htmlFor={updateAllUsersId} className="cursor-pointer text-sm font-medium text-foreground">
Update ALL users in the system
{t("users.bulkEdit.updateAll")}
</label>
</div>
{updateAllUsers && (
<div className="mt-2">
<span className="text-xs text-warning">
⚠️ This will apply changes to ALL users in the system, not just the selected ones.
</span>
<span className="text-xs text-warning">{t("users.bulkEdit.updateAllWarning")}</span>
</div>
)}
</div>
@ -251,15 +253,19 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
{!updateAllUsers && (
<div className="mb-4">
<h5 className="mb-2 text-sm font-semibold text-foreground">Selected Users ({selectedUsers.length}):</h5>
<h5 className="mb-2 text-sm font-semibold text-foreground">
{t("users.bulkEdit.selectedUsers", { count: selectedUsers.length })}
</h5>
<div className="max-h-[200px] overflow-y-auto rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30%]">User ID</TableHead>
<TableHead className="w-[25%]">Email</TableHead>
<TableHead className="w-[25%]">Current Role</TableHead>
<TableHead className={cn("w-[20%]", NUMERIC_CELL_CLASS)}>Budget</TableHead>
<TableHead className="w-[30%]">{t("users.bulkEdit.table.userId")}</TableHead>
<TableHead className="w-[25%]">{t("users.bulkEdit.table.email")}</TableHead>
<TableHead className="w-[25%]">{t("users.bulkEdit.table.currentRole")}</TableHead>
<TableHead className={cn("w-[20%]", NUMERIC_CELL_CLASS)}>
{t("users.bulkEdit.table.budget")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -268,12 +274,19 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
<TableCell className="text-xs font-medium text-foreground">
{user.user_id.length > 20 ? `${user.user_id.slice(0, 20)}...` : user.user_id}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{user.user_email || "No email"}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{user.user_email || t("users.bulkEdit.table.noEmail")}
</TableCell>
<TableCell className="text-xs text-foreground">
{possibleUIRoles?.[user.user_role]?.ui_label || user.user_role}
</TableCell>
<TableCell className={NUMERIC_CELL_CLASS}>
<MoneyCell value={user.max_budget} decimals={2} emptyText="Unlimited" showZero />
<MoneyCell
value={user.max_budget}
decimals={2}
emptyText={t("users.bulkEdit.table.unlimited")}
showZero
/>
</TableCell>
</TableRow>
))}
@ -287,15 +300,14 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
<div className="mb-4">
<p className="text-sm text-foreground">
<strong>Instructions:</strong> Fill in the fields below with the values you want to apply to all selected
users. You can bulk edit: role, budget, models, and metadata. You can also add users to teams.
<Trans i18nKey="users.bulkEdit.instructions" components={{ strong: <strong /> }} />
</p>
</div>
{/* Team Management Section */}
<Card size="sm" className="mb-4 bg-muted/50">
<CardHeader>
<CardTitle>Team Management</CardTitle>
<CardTitle>{t("users.bulkEdit.teamManagement.title")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
@ -304,10 +316,10 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
id={addToTeamsId}
checked={addToTeams}
onCheckedChange={(checked) => setAddToTeams(checked === true)}
aria-label="Add selected users to teams"
aria-label={t("users.bulkEdit.teamManagement.addToTeams")}
/>
<label htmlFor={addToTeamsId} className="cursor-pointer text-sm text-foreground">
Add selected users to teams
{t("users.bulkEdit.teamManagement.addToTeams")}
</label>
</div>
@ -315,12 +327,12 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
<>
<div>
<label htmlFor={selectedTeamsId} className="block text-sm font-medium text-foreground">
Select Teams:
{t("users.bulkEdit.teamManagement.selectTeams")}
</label>
<MultiSelect
id={selectedTeamsId}
className="mt-2"
placeholder="Select teams to add users to"
placeholder={t("users.bulkEdit.teamManagement.selectTeamsPlaceholder")}
value={selectedTeams}
onValueChange={setSelectedTeams}
options={
@ -334,12 +346,12 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
<div>
<label htmlFor={teamBudgetId} className="block text-sm font-medium text-foreground">
Team Budget (Optional):
{t("users.bulkEdit.teamManagement.teamBudget")}
</label>
<NumericalInput
id={teamBudgetId}
className="mt-2"
placeholder="Max budget per user in team"
placeholder={t("users.bulkEdit.teamManagement.teamBudgetPlaceholder")}
value={teamBudget ?? ""}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setTeamBudget(event.target.value === "" ? null : Number(event.target.value))
@ -348,13 +360,12 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
step={0.01}
/>
<span className="text-xs text-muted-foreground">
Leave empty for unlimited budget within team limits
{t("users.bulkEdit.teamManagement.teamBudgetHint")}
</span>
</div>
<span className="text-xs text-muted-foreground">
Users will be added with &quot;user&quot; role by default. All users will be added to each selected
team.
{t("users.bulkEdit.teamManagement.defaultRoleNote")}
</span>
</>
)}
@ -379,7 +390,9 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
{loading && (
<div className="mt-2.5 text-center">
<span className="text-sm text-foreground">
Updating {updateAllUsers ? "all users" : selectedUsers.length} user(s)...
{updateAllUsers
? t("users.bulkEdit.updatingAll")
: t("users.bulkEdit.updating", { count: selectedUsers.length })}
</span>
</div>
)}

View file

@ -1,8 +1,10 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ParseKeys } from "i18next";
import * as React from "react";
import { useFieldArray, type Control } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { ModelSelect, MODEL_SENTINEL_OPTIONS } from "@/components/ModelSelect/ModelSelect";
@ -29,21 +31,21 @@ import {
const NO_RESET = "never";
const BUDGET_DURATION_OPTIONS = [
{ value: NO_RESET, label: "No reset" },
{ value: "1h", label: "hourly" },
{ value: "24h", label: "daily" },
{ value: "7d", label: "weekly" },
{ value: "30d", label: "monthly" },
] as const;
const BUDGET_DURATION_OPTIONS: readonly { value: string; labelKey: ParseKeys }[] = [
{ value: NO_RESET, labelKey: "users.defaultSettings.budgetDurations.noReset" },
{ value: "1h", labelKey: "users.defaultSettings.budgetDurations.hourly" },
{ value: "24h", labelKey: "users.defaultSettings.budgetDurations.daily" },
{ value: "7d", labelKey: "users.defaultSettings.budgetDurations.weekly" },
{ value: "30d", labelKey: "users.defaultSettings.budgetDurations.monthly" },
];
const TEAM_ROLE_OPTIONS = [
{ value: "user", label: "User" },
{ value: "admin", label: "Admin" },
] as const;
const TEAM_ROLE_OPTIONS: readonly { value: "user" | "admin"; labelKey: ParseKeys }[] = [
{ value: "user", labelKey: "users.defaultSettings.teamRoles.user" },
{ value: "admin", labelKey: "users.defaultSettings.teamRoles.admin" },
];
const MODEL_SENTINEL_LABELS: ReadonlyMap<string, string> = new Map(
MODEL_SENTINEL_OPTIONS.map(({ value, label }) => [value, label]),
const MODEL_SENTINEL_LABEL_KEYS: ReadonlyMap<string, ParseKeys> = new Map(
MODEL_SENTINEL_OPTIONS.map(({ value, labelKey }) => [value, labelKey]),
);
const TEAMS_PAGE_SIZE = 50;
@ -71,6 +73,7 @@ interface RoleOption {
type SettingsControl = Control<DefaultUserSettingsFormValues, unknown, DefaultUserSettingsSubmitValues>;
const TeamPickerField = ({ control, index }: { control: SettingsControl; index: number }) => {
const { t } = useTranslation();
const [search, setSearch] = React.useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams(
TEAMS_PAGE_SIZE,
@ -90,7 +93,7 @@ const TeamPickerField = ({ control, index }: { control: SettingsControl; index:
);
return (
<FormField control={control} name={`teams.${index}.team_id`} label="Team">
<FormField control={control} name={`teams.${index}.team_id`} label={t("users.defaultSettings.teams.team")}>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<PaginatedSearchSelect
options={options}
@ -101,8 +104,8 @@ const TeamPickerField = ({ control, index }: { control: SettingsControl; index:
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
placeholder="Search a team"
emptyText="No teams found"
placeholder={t("users.defaultSettings.teams.teamPlaceholder")}
emptyText={t("users.defaultSettings.teams.teamEmpty")}
inputId={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
@ -113,42 +116,53 @@ const TeamPickerField = ({ control, index }: { control: SettingsControl; index:
};
const TeamsField = ({ control }: { control: SettingsControl }) => {
const { t } = useTranslation();
const { fields, append, remove } = useFieldArray({ control, name: "teams" });
const teamRoleItems = TEAM_ROLE_OPTIONS.map((option) => ({ value: option.value, label: t(option.labelKey) }));
return (
<div className="flex w-full flex-col gap-3">
<div>
<p className="text-sm font-medium">Default Teams</p>
<p className="text-sm text-muted-foreground">
New users are added to these teams. Only teams that already exist can be selected.
</p>
<p className="text-sm font-medium">{t("users.defaultSettings.fields.teams")}</p>
<p className="text-sm text-muted-foreground">{t("users.defaultSettings.teams.description")}</p>
</div>
{fields.map((field, index) => (
<div key={field.id} className="rounded-lg border border-border p-4">
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-medium">Team {index + 1}</p>
<p className="text-sm font-medium">{t("users.defaultSettings.teams.rowTitle", { number: index + 1 })}</p>
<Button type="button" variant="destructive" size="sm" onClick={() => remove(index)}>
Remove
{t("users.defaultSettings.teams.remove")}
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<TeamPickerField control={control} index={index} />
<FormField control={control} name={`teams.${index}.max_budget_in_team`} label="Max Budget in Team (USD)">
<FormField
control={control}
name={`teams.${index}.max_budget_in_team`}
label={t("users.defaultSettings.teams.maxBudget")}
>
{({ ref, ...budgetField }) => (
<Input {...budgetField} ref={ref} type="number" step="any" min={0} placeholder="Optional" />
<Input
{...budgetField}
ref={ref}
type="number"
step="any"
min={0}
placeholder={t("users.defaultSettings.teams.maxBudgetPlaceholder")}
/>
)}
</FormField>
<FormField control={control} name={`teams.${index}.user_role`} label="Team Role">
<FormField
control={control}
name={`teams.${index}.user_role`}
label={t("users.defaultSettings.teams.role")}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={TEAM_ROLE_OPTIONS}
value={value}
onValueChange={(selected) => onChange(selected ?? "user")}
>
<Select items={teamRoleItems} value={value} onValueChange={(selected) => onChange(selected ?? "user")}>
<SelectTrigger
id={id}
className="w-full"
@ -158,7 +172,7 @@ const TeamsField = ({ control }: { control: SettingsControl }) => {
<SelectValue />
</SelectTrigger>
<SelectContent>
{TEAM_ROLE_OPTIONS.map((option) => (
{teamRoleItems.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@ -172,7 +186,7 @@ const TeamsField = ({ control }: { control: SettingsControl }) => {
))}
<Button type="button" variant="outline" onClick={() => append(EMPTY_TEAM_ROW)}>
Add Team
{t("users.defaultSettings.teams.add")}
</Button>
</div>
);
@ -191,30 +205,41 @@ interface SettingsViewProps {
}
const SettingsView = ({ values, roleOptions }: SettingsViewProps) => {
const { t } = useTranslation();
const roleLabel = roleOptions.find((option) => option.value === values.user_role)?.label ?? values.user_role;
const durationValue = values.budget_duration === "" ? NO_RESET : values.budget_duration;
const durationLabel =
BUDGET_DURATION_OPTIONS.find((option) => option.value === durationValue)?.label ?? values.budget_duration;
const durationOption = BUDGET_DURATION_OPTIONS.find((option) => option.value === durationValue);
const durationLabel = durationOption === undefined ? values.budget_duration : t(durationOption.labelKey);
const notSet = t("users.defaultSettings.view.notSet");
return (
<div className="flex flex-col gap-4">
<ViewRow label="Default Role">{roleLabel === "" ? "Not set" : roleLabel}</ViewRow>
<ViewRow label="Max Budget (USD)">{values.max_budget === "" ? "Not set" : values.max_budget}</ViewRow>
<ViewRow label="Reset Budget">{durationLabel}</ViewRow>
<ViewRow label="Default Models">
<ViewRow label={t("users.defaultSettings.fields.role")}>{roleLabel === "" ? notSet : roleLabel}</ViewRow>
<ViewRow label={t("users.defaultSettings.fields.maxBudget")}>
{values.max_budget === "" ? notSet : values.max_budget}
</ViewRow>
<ViewRow label={t("users.defaultSettings.fields.resetBudget")}>{durationLabel}</ViewRow>
<ViewRow label={t("users.defaultSettings.fields.models")}>
{values.models.length === 0
? "Not set"
: values.models.map((model) => MODEL_SENTINEL_LABELS.get(model) ?? model).join(", ")}
? notSet
: values.models
.map((model) => {
const labelKey = MODEL_SENTINEL_LABEL_KEYS.get(model);
return labelKey === undefined ? model : t(labelKey);
})
.join(", ")}
</ViewRow>
<div>
<p className="text-sm font-medium">Default Teams</p>
<p className="text-sm font-medium">{t("users.defaultSettings.fields.teams")}</p>
{values.teams.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
<p className="text-sm text-muted-foreground">{t("users.defaultSettings.view.none")}</p>
) : (
values.teams.map((team) => (
<p key={team.team_id} className="text-sm text-muted-foreground">
{team.team_id}
{team.max_budget_in_team !== "" && <> · ${team.max_budget_in_team} max budget</>}
{team.max_budget_in_team !== "" && (
<> · {t("users.defaultSettings.view.teamMaxBudget", { budget: team.max_budget_in_team })}</>
)}
<> · {team.user_role}</>
</p>
))
@ -233,20 +258,25 @@ interface SettingsFormProps {
}
const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, onSaved }: SettingsFormProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const budgetDurationItems = BUDGET_DURATION_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}));
const form = useZodForm(defaultUserSettingsSchema, { defaultValues: initialValues });
const { isDirty } = form.formState;
const mutation = useMutation({
mutationFn: (values: DefaultUserSettingsSubmitValues) => updateSettings(buildBody(values)),
onSuccess: (_result, values) => {
toast.success("Default user settings updated successfully");
toast.success(t("users.defaultSettings.toasts.updated"));
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY });
form.reset(values);
onSaved();
},
onError: (error: unknown) =>
toast.fromError(error instanceof Error ? error.message : "Failed to update default user settings"),
toast.fromError(error instanceof Error ? error.message : t("users.defaultSettings.toasts.updateFailed")),
});
const onSubmit = form.handleSubmit((values) => mutation.mutate(values));
@ -257,8 +287,8 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
<FormField
control={form.control}
name="user_role"
label="Default Role"
description="Role assigned to new users"
label={t("users.defaultSettings.fields.role")}
description={t("users.defaultSettings.fields.roleDescription")}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
@ -267,7 +297,7 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
onValueChange={(selected) => onChange(selected ?? "")}
>
<SelectTrigger id={id} className="w-full" aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
<SelectValue placeholder="Not set" />
<SelectValue placeholder={t("users.defaultSettings.fields.rolePlaceholder")} />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
@ -286,8 +316,8 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
<FormField
control={form.control}
name="max_budget"
label="Max Budget (USD)"
description="Default maximum budget for new users"
label={t("users.defaultSettings.fields.maxBudget")}
description={t("users.defaultSettings.fields.maxBudgetDescription")}
>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step="any" min={0} />}
</FormField>
@ -295,12 +325,12 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
<FormField
control={form.control}
name="budget_duration"
label="Reset Budget"
description="How often the default budget resets"
label={t("users.defaultSettings.fields.resetBudget")}
description={t("users.defaultSettings.fields.resetBudgetDescription")}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={BUDGET_DURATION_OPTIONS}
items={budgetDurationItems}
value={value === "" ? NO_RESET : value}
onValueChange={(selected) => onChange(selected === null || selected === NO_RESET ? "" : selected)}
>
@ -308,7 +338,7 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
<SelectValue />
</SelectTrigger>
<SelectContent>
{BUDGET_DURATION_OPTIONS.map((option) => (
{budgetDurationItems.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@ -321,8 +351,8 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
<FormField
control={form.control}
name="models"
label="Default Models"
description="Models new users can access"
label={t("users.defaultSettings.fields.models")}
description={t("users.defaultSettings.fields.modelsDescription")}
>
{(field) => (
<ModelSelect
@ -347,28 +377,29 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
}}
disabled={mutation.isPending}
>
Cancel
{t("users.defaultSettings.cancel")}
</Button>
<Button type="submit" disabled={!isDirty || mutation.isPending}>
{mutation.isPending ? "Saving..." : "Save Changes"}
{mutation.isPending ? t("users.defaultSettings.saving") : t("users.defaultSettings.save")}
</Button>
</div>
</form>
);
};
const SettingsCard = ({ action, children }: { action?: React.ReactNode; children: React.ReactNode }) => (
<Card>
<CardHeader>
<CardTitle>Default User Settings</CardTitle>
<CardDescription>
Applied to every new internal user created through SSO or the user management APIs.
</CardDescription>
{action !== undefined && <CardAction>{action}</CardAction>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
const SettingsCard = ({ action, children }: { action?: React.ReactNode; children: React.ReactNode }) => {
const { t } = useTranslation();
return (
<Card>
<CardHeader>
<CardTitle>{t("users.defaultSettings.card.title")}</CardTitle>
<CardDescription>{t("users.defaultSettings.card.description")}</CardDescription>
{action !== undefined && <CardAction>{action}</CardAction>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
};
export interface DefaultUserSettingsFormProps {
possibleUIRoles?: Record<string, Record<string, string>> | null;
@ -381,6 +412,7 @@ export const DefaultUserSettingsForm = ({
fetchSettings = defaultFetchSettings,
updateSettings = defaultUpdateSettings,
}: DefaultUserSettingsFormProps) => {
const { t } = useTranslation();
const [isEditing, setIsEditing] = React.useState(false);
const { data, isPending, isError } = useQuery({ queryKey: SETTINGS_QUERY_KEY, queryFn: fetchSettings });
@ -405,7 +437,7 @@ export const DefaultUserSettingsForm = ({
if (isError || initialValues === undefined) {
return (
<SettingsCard>
<p role="alert">Could not load the default user settings.</p>
<p role="alert">{t("users.defaultSettings.loadError")}</p>
</SettingsCard>
);
}
@ -415,7 +447,7 @@ export const DefaultUserSettingsForm = ({
action={
isEditing ? undefined : (
<Button type="button" onClick={() => setIsEditing(true)}>
Edit Settings
{t("users.defaultSettings.editSettings")}
</Button>
)
}

View file

@ -1,19 +1,24 @@
import { z } from "zod";
import i18n from "@/i18n/i18n";
const isBlank = (value: string): boolean => value.trim() === "";
const amountOrEmpty = z
.string()
.refine(
(value) => isBlank(value) || (Number.isFinite(Number(value)) && Number(value) >= 0),
"Must be a non-negative number",
);
.refine((value) => isBlank(value) || (Number.isFinite(Number(value)) && Number(value) >= 0), {
error: () => i18n.t("users.defaultSettings.errors.nonNegative"),
});
const defaultTeamRowSchema = z.object({
team_id: z
.string()
.nullable()
.pipe(z.string({ error: "Select a team" }).min(1, "Select a team")),
.pipe(
z
.string({ error: () => i18n.t("users.defaultSettings.errors.selectTeam") })
.min(1, { error: () => i18n.t("users.defaultSettings.errors.selectTeam") }),
),
max_budget_in_team: amountOrEmpty,
user_role: z.enum(["user", "admin"]),
});
@ -38,7 +43,7 @@ export const defaultUserSettingsSchema = z.object(defaultUserSettingsShape).supe
repeatedRows.forEach((index) =>
ctx.addIssue({
code: "custom",
message: "This team is already listed",
message: i18n.t("users.defaultSettings.errors.duplicateTeam"),
path: ["teams", index, "team_id"],
}),
);

View file

@ -1,4 +1,6 @@
import type { TFunction } from "i18next";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { all_admin_roles } from "@/utils/roles";
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
@ -21,14 +23,16 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { useZodForm } from "@/lib/forms/useZodForm";
import { CircleHelp } from "lucide-react";
const RATE_LIMIT_ERROR = "Enter a non-negative whole number, or leave empty for unlimited";
const isBlank = (value: string | number | null | undefined): boolean =>
value === null || value === undefined || String(value).trim() === "";
const rateLimitField = z
.union([z.string(), z.number()])
.nullish()
.transform((value) => (isBlank(value) ? null : Number(value)))
.pipe(z.number({ error: RATE_LIMIT_ERROR }).int(RATE_LIMIT_ERROR).nonnegative(RATE_LIMIT_ERROR).nullable());
const rateLimitField = (t: TFunction) => {
const rateLimitError = t("users.editView.errors.rateLimit");
return z
.union([z.string(), z.number()])
.nullish()
.transform((value) => (isBlank(value) ? null : Number(value)))
.pipe(z.number({ error: rateLimitError }).int(rateLimitError).nonnegative(rateLimitError).nullable());
};
interface UserEditViewProps {
userData: any;
@ -54,7 +58,7 @@ const MCP_SELECTION_SHAPE = z.object({
// The proxy stores unset user fields as null rather than leaving them out, and
// antd forwarded whatever it was handed, so nullish is what keeps a loaded user
// editable at all.
const userEditShape = {
const userEditShape = (t: TFunction) => ({
user_id: z.string().nullish(),
user_email: z.string().nullish(),
user_alias: z.string().nullish(),
@ -62,19 +66,19 @@ const userEditShape = {
models: z.array(z.string()),
budget_duration: z.string().nullish(),
metadata: z.string().nullish(),
tpm_limit: rateLimitField,
rpm_limit: rateLimitField,
tpm_limit: rateLimitField(t),
rpm_limit: rateLimitField(t),
mcp_servers_and_groups: MCP_SELECTION_SHAPE.optional(),
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
};
});
const userEditSchema = (unlimitedBudget: boolean) =>
const userEditSchema = (unlimitedBudget: boolean, t: TFunction) =>
z.object({
...userEditShape,
...userEditShape(t),
max_budget: z
.union([z.string(), z.number()])
.nullish()
.refine((value) => unlimitedBudget || !isBlank(value), "Please enter a budget or select Unlimited Budget"),
.refine((value) => unlimitedBudget || !isBlank(value), t("users.editView.errors.budgetRequired")),
});
type UserEditFormInput = z.input<ReturnType<typeof userEditSchema>>;
@ -160,13 +164,14 @@ export function UserEditView({
objectPermission,
premiumUser = false,
}: UserEditViewProps) {
const { t } = useTranslation();
const canEditMcpPermissions = !isBulkEdit && all_admin_roles.includes(userRole || "");
const [unlimitedBudget, setUnlimitedBudget] = useState(false);
const [modelMaxBudget, setModelMaxBudget] = useSeededState<ModelMaxBudget>(
userData.user_id,
() => userData.user_info?.model_max_budget ?? {},
);
const schema = useMemo(() => userEditSchema(unlimitedBudget), [unlimitedBudget]);
const schema = useMemo(() => userEditSchema(unlimitedBudget, t), [unlimitedBudget, t]);
const form = useZodForm(schema, {
defaultValues: toFormValues(userData, objectPermission, isBulkEdit, canEditMcpPermissions),
});
@ -207,8 +212,8 @@ export function UserEditView({
};
const modelOptions = [
{ label: "All Proxy Models", value: "all-proxy-models" },
{ label: "No Default Models", value: "no-default-models" },
{ label: t("users.editView.modelOptions.allProxyModels"), value: "all-proxy-models" },
{ label: t("users.editView.modelOptions.noDefaultModels"), value: "no-default-models" },
...userModels.map((model) => ({ label: getModelDisplayName(model), value: model })),
];
@ -223,18 +228,18 @@ export function UserEditView({
<form onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
{!isBulkEdit && (
<FormField control={form.control} name="user_id" label="User ID">
<FormField control={form.control} name="user_id" label={t("users.editView.fields.userId")}>
{({ ref, value, ...control }) => <Input {...control} ref={ref} value={value ?? ""} disabled />}
</FormField>
)}
{!isBulkEdit && (
<FormField control={form.control} name="user_email" label="Email">
<FormField control={form.control} name="user_email" label={t("users.editView.fields.email")}>
{({ ref, value, ...control }) => <Input {...control} ref={ref} value={value ?? ""} />}
</FormField>
)}
<FormField control={form.control} name="user_alias" label="User Alias">
<FormField control={form.control} name="user_alias" label={t("users.editView.fields.userAlias")}>
{({ ref, value, ...control }) => <Input {...control} ref={ref} value={value ?? ""} />}
</FormField>
@ -242,8 +247,8 @@ export function UserEditView({
control={form.control}
name="user_role"
label={labelWithHint(
"Global Proxy Role",
"This is the role that the user will globally on the proxy. This role is independent of any team/org specific roles.",
t("users.editView.fields.globalProxyRole"),
t("users.editView.fields.globalProxyRoleHint"),
)}
>
{({ id, value, onChange }) => (
@ -271,8 +276,8 @@ export function UserEditView({
control={form.control}
name="models"
label={labelWithHint(
"Personal Models",
"Select which models this user can access outside of team-scope. Choose 'All Proxy Models' to grant access to all models available on the proxy.",
t("users.editView.fields.personalModels"),
t("users.editView.fields.personalModelsHint"),
)}
>
{({ value, onChange }) => (
@ -280,7 +285,7 @@ export function UserEditView({
options={modelOptions}
value={value}
onValueChange={onChange}
placeholder="Select models"
placeholder={t("users.editView.fields.modelsPlaceholder")}
disabled={!all_admin_roles.includes(userRole || "")}
/>
)}
@ -291,10 +296,10 @@ export function UserEditView({
name="max_budget"
label={
<>
Max Budget (USD)
{t("users.editView.fields.maxBudget")}
<label className="ml-3 inline-flex items-center gap-2 font-normal">
<Checkbox checked={unlimitedBudget} onCheckedChange={handleUnlimitedBudgetChange} />
Unlimited Budget
{t("users.editView.fields.unlimitedBudget")}
</label>
</>
}
@ -308,19 +313,19 @@ export function UserEditView({
value={value ?? ""}
onChange={(event) => onChange(event.target.value)}
onWheel={(event) => event.currentTarget.blur()}
placeholder="Enter a numerical value"
placeholder={t("users.editView.fields.maxBudgetPlaceholder")}
disabled={unlimitedBudget}
/>
)}
</FormField>
<FormField control={form.control} name="budget_duration" label="Reset Budget">
<FormField control={form.control} name="budget_duration" label={t("users.editView.fields.resetBudget")}>
{({ id, value, onChange }) => (
<BudgetDurationDropdown
id={id}
value={value}
onChange={onChange}
placeholder={isBulkEdit ? "No change" : undefined}
placeholder={isBulkEdit ? t("users.editView.fields.noChange") : undefined}
/>
)}
</FormField>
@ -330,10 +335,7 @@ export function UserEditView({
<FormField
control={form.control}
name="tpm_limit"
label={labelWithHint(
"TPM Limit",
"Applies across all keys owned by this user. Team and key limits still apply as ceilings.",
)}
label={labelWithHint(t("users.editView.fields.tpmLimit"), t("users.editView.fields.rateLimitHint"))}
>
{({ ref, value, onChange, ...control }) => (
<Input
@ -345,7 +347,7 @@ export function UserEditView({
value={value ?? ""}
onChange={(event) => onChange(event.target.value)}
onWheel={(event) => event.currentTarget.blur()}
placeholder="Unlimited"
placeholder={t("users.editView.fields.rateLimitPlaceholder")}
/>
)}
</FormField>
@ -353,10 +355,7 @@ export function UserEditView({
<FormField
control={form.control}
name="rpm_limit"
label={labelWithHint(
"RPM Limit",
"Applies across all keys owned by this user. Team and key limits still apply as ceilings.",
)}
label={labelWithHint(t("users.editView.fields.rpmLimit"), t("users.editView.fields.rateLimitHint"))}
>
{({ ref, value, onChange, ...control }) => (
<Input
@ -368,7 +367,7 @@ export function UserEditView({
value={value ?? ""}
onChange={(event) => onChange(event.target.value)}
onWheel={(event) => event.currentTarget.blur()}
placeholder="Unlimited"
placeholder={t("users.editView.fields.rateLimitPlaceholder")}
/>
)}
</FormField>
@ -385,13 +384,19 @@ export function UserEditView({
onChange={setModelMaxBudget}
availableModels={userModels}
usage={userData.user_info?.model_max_budget_usage}
hint="Cap this user's spend on individual models, each with its own reset window. Applies across every key the user holds."
hint={t("users.editView.fields.modelMaxBudgetHint")}
/>
)}
<FormField control={form.control} name="metadata" label="Metadata">
<FormField control={form.control} name="metadata" label={t("users.editView.fields.metadata")}>
{({ ref, value, ...control }) => (
<Textarea {...control} ref={ref} value={value ?? ""} rows={4} placeholder="Enter metadata as JSON" />
<Textarea
{...control}
ref={ref}
value={value ?? ""}
rows={4}
placeholder={t("users.editView.fields.metadataPlaceholder")}
/>
)}
</FormField>
@ -400,10 +405,7 @@ export function UserEditView({
<FormField
control={form.control}
name="mcp_servers_and_groups"
label={labelWithHint(
"MCP Servers / Access Groups",
"Caps which MCP servers, access groups, and tools this user may reach. Every key the user holds is limited to this set.",
)}
label={labelWithHint(t("users.editView.fields.mcpServers"), t("users.editView.fields.mcpServersHint"))}
>
{({ value, onChange }) => (
<MCPServerSelector
@ -411,7 +413,7 @@ export function UserEditView({
value={value}
accessToken={accessToken || ""}
userId={isBulkEdit ? null : userData.user_id}
placeholder="Select MCP servers or access groups (optional)"
placeholder={t("users.editView.fields.mcpServersPlaceholder")}
/>
)}
</FormField>
@ -430,9 +432,9 @@ export function UserEditView({
<div className="mt-6 flex justify-end gap-2">
<Button variant="secondary" type="button" onClick={onCancel}>
Cancel
{t("users.editView.actions.cancel")}
</Button>
<Button type="submit">Save Changes</Button>
<Button type="submit">{t("users.editView.actions.save")}</Button>
</div>
</form>
</TooltipProvider>

View file

@ -1,5 +1,6 @@
import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import BulkEditUserModal from "./BulkEditUsers";
import BulkCreateUsersButton from "@/components/bulk_create_users_button";
@ -58,6 +59,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
teams,
orgAdminOrgIds,
}) => {
const { t } = useTranslation();
const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false;
const queryClient = useQueryClient();
@ -157,19 +159,19 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
const handleResetPassword = useCallback(
async (userId: string) => {
if (!accessToken) {
toast.fromError("Access token not found");
toast.fromError(t("users.page.toasts.accessTokenMissing"));
return;
}
try {
toast.success("Generating password reset link...");
toast.success(t("users.page.toasts.generatingResetLink"));
const data = await invitationCreateCall(accessToken, userId);
setInvitationLinkData(data);
setIsInvitationLinkModalVisible(true);
} catch (error) {
toast.fromError("Failed to generate password reset link");
toast.fromError(t("users.page.toasts.resetLinkFailed"));
}
},
[accessToken],
[accessToken, t],
);
const confirmDelete = async () => {
@ -185,10 +187,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
return { ...previousData, users: updatedUsers };
});
toast.success("User deleted successfully");
toast.success(t("users.page.toasts.deleted"));
} catch (error) {
console.error("Error deleting user:", error);
toast.fromError("Failed to delete user");
toast.fromError(t("users.page.toasts.deleteFailed"));
} finally {
setIsDeleteModalOpen(false);
setUserToDelete(null);
@ -344,7 +346,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
variant={selectionMode ? "default" : "outline"}
data-testid="toggle-user-selection"
>
{selectionMode ? "Cancel Selection" : "Select Users"}
{selectionMode ? t("users.page.selection.cancel") : t("users.page.selection.start")}
</Button>
)}
@ -355,7 +357,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
disabled={selectedUsers.length === 0}
data-testid="bulk-edit-users"
>
Bulk Edit ({selectedUsers.length} selected)
{t("users.page.selection.bulkEdit", { count: selectedUsers.length })}
</Button>
)}
</>
@ -367,10 +369,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
<Tabs defaultValue="users" className="gap-0">
<TabsList variant="line" className="mb-4">
<TabsTrigger value="users" className="flex-none data-active:text-primary after:bg-primary">
Users
{t("users.page.tabs.users")}
</TabsTrigger>
<TabsTrigger value="default-settings" className="flex-none data-active:text-primary after:bg-primary">
Default User Settings
{t("users.page.tabs.defaultSettings")}
</TabsTrigger>
</TabsList>
@ -383,7 +385,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
<div
className="flex h-64 items-center justify-center"
role="status"
aria-label="Loading default user settings"
aria-label={t("users.page.defaultSettingsLoading")}
>
<div className="w-full max-w-lg space-y-3">
<Skeleton className="h-5 w-1/3" />
@ -404,20 +406,20 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
{/* Existing Modals */}
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete User?"
message="Are you sure you want to delete this user? This action cannot be undone."
resourceInformationTitle="User Information"
title={t("users.page.deleteModal.title")}
message={t("users.page.deleteModal.message")}
resourceInformationTitle={t("users.page.deleteModal.informationTitle")}
resourceInformation={[
{ label: "Email", value: userToDelete?.user_email },
{ label: "User ID", value: userToDelete?.user_id, code: true },
{ label: t("users.page.deleteModal.email"), value: userToDelete?.user_email },
{ label: t("users.page.deleteModal.userId"), value: userToDelete?.user_id, code: true },
{
label: "Global Proxy Role",
label: t("users.page.deleteModal.globalProxyRole"),
value:
(userToDelete?.user_role && possibleUIRoles?.[userToDelete.user_role]?.ui_label) ||
userToDelete?.user_role ||
"-",
},
{ label: "Total Spend (USD)", value: userToDelete?.spend?.toFixed(2) },
{ label: t("users.page.deleteModal.totalSpend"), value: userToDelete?.spend?.toFixed(2) },
]}
onCancel={cancelDelete}
onOk={confirmDelete}

View file

@ -7,8 +7,10 @@ import {
RowSelectionState,
SortingState,
} from "@tanstack/react-table";
import type { ParseKeys } from "i18next";
import { Users } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { UserInfo } from "@/components/networking";
import {
@ -50,21 +52,22 @@ interface UsersTableProps {
onResetPassword: (userId: string) => void;
}
const FILTER_LABELS: Record<string, string> = {
user_id: "User ID",
sso_user_id: "SSO ID",
user_role: "Role",
team: "Team",
const FILTER_LABEL_KEYS: Readonly<Record<string, ParseKeys>> = {
user_id: "users.table.filters.userId",
sso_user_id: "users.table.filters.ssoId",
user_role: "users.table.filters.role",
team: "users.table.filters.team",
};
function EmptyState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Users className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No users found</div>
<div className="text-sm text-muted-foreground">Try adjusting your search or filters.</div>
<div className="text-sm font-medium text-foreground">{t("users.table.emptyState.title")}</div>
<div className="text-sm text-muted-foreground">{t("users.table.emptyState.description")}</div>
</div>
);
}
@ -91,6 +94,7 @@ export function UsersTable({
onDeleteUser,
onResetPassword,
}: UsersTableProps) {
const { t } = useTranslation();
const [filtersOpen, setFiltersOpen] = useState(false);
const columns = useMemo(() => {
@ -101,9 +105,15 @@ export function UsersTable({
onUserClick,
onDeleteUser,
onResetPassword,
t,
};
return getUsersTableColumns(columnDeps);
}, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword]);
}, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword, t]);
const filterLabels = useMemo(
() => Object.fromEntries(Object.entries(FILTER_LABEL_KEYS).map(([columnId, key]) => [columnId, t(key)])),
[t],
);
const roleOptions = useMemo(
() =>
@ -152,7 +162,7 @@ export function UsersTable({
rowSelection={rowSelection}
onRowSelectionChange={onRowSelectionChange}
isLoading={isLoading}
loadingMessage="Loading users…"
loadingMessage={t("users.table.loading")}
noDataMessage={<EmptyState />}
size="compact"
toolbar={(table) => (
@ -161,52 +171,52 @@ export function UsersTable({
table={table}
searchValue={searchValue}
onSearchChange={onSearchChange}
searchPlaceholder="Search by email or ID…"
searchPlaceholder={t("users.table.searchPlaceholder")}
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={FILTER_LABELS}
filterLabels={filterLabels}
formatFilterValue={formatFilterValue}
/>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down your users"
title={t("users.table.filters.title")}
description={t("users.table.filters.description")}
>
{({ get, set }) => (
<>
<DataTableFilterField label="User ID">
<DataTableFilterField label={t("users.table.filters.userId")}>
<Input
value={(get("user_id") as string) ?? ""}
onChange={(event) => set("user_id", event.target.value)}
placeholder="Enter user ID…"
placeholder={t("users.table.filters.userIdPlaceholder")}
data-testid="users-filter-user-id"
/>
</DataTableFilterField>
<DataTableFilterField label="SSO ID">
<DataTableFilterField label={t("users.table.filters.ssoId")}>
<Input
value={(get("sso_user_id") as string) ?? ""}
onChange={(event) => set("sso_user_id", event.target.value)}
placeholder="Enter SSO ID…"
placeholder={t("users.table.filters.ssoIdPlaceholder")}
data-testid="users-filter-sso-id"
/>
</DataTableFilterField>
<DataTableFilterField label="Role">
<DataTableFilterField label={t("users.table.filters.role")}>
<SearchSelect
options={roleOptions}
value={(get("user_role") as string) || undefined}
onValueChange={(value) => set("user_role", value ?? undefined)}
placeholder="Select a role…"
emptyText="No roles found"
placeholder={t("users.table.filters.rolePlaceholder")}
emptyText={t("users.table.filters.roleEmpty")}
/>
</DataTableFilterField>
<DataTableFilterField label="Team">
<DataTableFilterField label={t("users.table.filters.team")}>
<SearchSelect
options={teamOptions}
value={(get("team") as string) || undefined}
onValueChange={(value) => set("team", value ?? undefined)}
placeholder="Select a team…"
emptyText="No teams found"
placeholder={t("users.table.filters.teamPlaceholder")}
emptyText={t("users.table.filters.teamEmpty")}
/>
</DataTableFilterField>
</>

View file

@ -1,8 +1,10 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { ChartColumn, Copy, Info, KeyRound, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { UserInfo } from "@/components/networking";
import { createSelectionColumn, DataTableSortHeader } from "@/components/shared/DataTable";
@ -20,11 +22,6 @@ import { cn } from "@/lib/cva.config";
import { usageHrefForUser } from "@/app/(dashboard)/usage/_components/usageUrlState";
import { copyToClipboard } from "@/utils/dataUtils";
const SSO_ID_HINT =
"SSO ID is the ID of the user in the SSO provider. If the user is not using SSO, this will be null.";
const SCIM_INACTIVE_HINT = "Deactivated via SCIM (external identity provider). The user's virtual keys are blocked.";
function isScimInactive(user: UserInfo): boolean {
return (user.metadata as Record<string, unknown> | null | undefined)?.scim_active === false;
}
@ -38,10 +35,11 @@ interface UserRowActionsProps {
}
function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open user actions"
aria-label={t("users.table.rowActions.open")}
data-testid={`user-actions-${user.user_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -50,18 +48,18 @@ function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onR
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onUserClick(user.user_id, true)} data-testid="user-action-edit">
<Pencil />
Edit user
{t("users.table.rowActions.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onResetPassword(user.user_id)} data-testid="user-action-reset-password">
<KeyRound />
Reset password
{t("users.table.rowActions.resetPassword")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void copyToClipboard(user.user_id, "User ID copied")}
onClick={() => void copyToClipboard(user.user_id, t("users.table.rowActions.copied"))}
data-testid="user-action-copy"
>
<Copy />
Copy user ID
{t("users.table.rowActions.copyId")}
</DropdownMenuItem>
{canViewUserUsage && (
<DropdownMenuItem
@ -69,13 +67,13 @@ function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onR
data-testid="user-action-view-usage"
>
<ChartColumn />
View usage
{t("users.table.rowActions.viewUsage")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDeleteUser(user)} data-testid="user-action-delete">
<Trash2 />
Delete user
{t("users.table.rowActions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -89,6 +87,7 @@ export interface UsersTableColumnsDeps {
onUserClick: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser: (user: UserInfo) => void;
onResetPassword: (userId: string) => void;
t: TFunction;
}
export const getUsersTableColumns = ({
@ -98,13 +97,16 @@ export const getUsersTableColumns = ({
onUserClick,
onDeleteUser,
onResetPassword,
t,
}: UsersTableColumnsDeps): ColumnDef<UserInfo>[] => {
const baseColumns: ColumnDef<UserInfo>[] = [
{
id: "user_id",
accessorKey: "user_id",
meta: { title: "User ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="User ID" variant="header-cycle" />,
meta: { title: t("users.table.columns.userId") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("users.table.columns.userId")} variant="header-cycle" />
),
size: 220,
enableSorting: true,
cell: ({ row }) => (
@ -118,8 +120,10 @@ export const getUsersTableColumns = ({
{
id: "user_email",
accessorKey: "user_email",
meta: { title: "Email" },
header: ({ column }) => <DataTableSortHeader column={column} title="Email" variant="header-cycle" />,
meta: { title: t("users.table.columns.email") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("users.table.columns.email")} variant="header-cycle" />
),
size: 220,
enableSorting: true,
cell: ({ row }) => (
@ -131,8 +135,8 @@ export const getUsersTableColumns = ({
{
id: "status",
accessorFn: (user) => (isScimInactive(user) ? "Inactive" : "Active"),
meta: { title: "Status", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Status" />,
meta: { title: t("users.table.columns.status"), skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title={t("users.table.columns.status")} />,
sortDescFirst: false,
size: 110,
enableSorting: true,
@ -141,20 +145,28 @@ export const getUsersTableColumns = ({
return (
<StatusBadge
tone="error"
label="Inactive"
tooltip={SCIM_INACTIVE_HINT}
label={t("users.table.status.inactive")}
tooltip={t("users.table.status.scimInactiveHint")}
dataTestId={`user-status-${row.original.user_id}`}
/>
);
}
return <StatusBadge tone="success" label="Active" dataTestId={`user-status-${row.original.user_id}`} />;
return (
<StatusBadge
tone="success"
label={t("users.table.status.active")}
dataTestId={`user-status-${row.original.user_id}`}
/>
);
},
},
{
id: "user_role",
accessorKey: "user_role",
meta: { title: "Global Proxy Role" },
header: ({ column }) => <DataTableSortHeader column={column} title="Global Proxy Role" variant="header-cycle" />,
meta: { title: t("users.table.columns.globalProxyRole") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("users.table.columns.globalProxyRole")} variant="header-cycle" />
),
size: 160,
enableSorting: true,
cell: ({ row }) => (
@ -166,8 +178,8 @@ export const getUsersTableColumns = ({
{
id: "user_alias",
accessorKey: "user_alias",
meta: { title: "User Alias" },
header: ({ column }) => <DataTableSortHeader column={column} title="User Alias" />,
meta: { title: t("users.table.columns.userAlias") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("users.table.columns.userAlias")} />,
sortDescFirst: false,
size: 150,
enableSorting: true,
@ -180,8 +192,10 @@ export const getUsersTableColumns = ({
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend (USD)", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" variant="header-cycle" />,
meta: { title: t("users.table.columns.spend"), numeric: true },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("users.table.columns.spend")} variant="header-cycle" />
),
size: 130,
enableSorting: true,
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
@ -189,23 +203,27 @@ export const getUsersTableColumns = ({
{
id: "max_budget",
accessorKey: "max_budget",
meta: { title: "Budget (USD)", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Budget (USD)" />,
meta: { title: t("users.table.columns.budget"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("users.table.columns.budget")} />,
sortDescFirst: true,
size: 130,
enableSorting: true,
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} emptyText="Unlimited" showZero />,
cell: ({ row }) => (
<MoneyCell value={row.original.max_budget} decimals={2} emptyText={t("users.table.unlimitedBudget")} showZero />
),
},
{
id: "sso_user_id",
accessorKey: "sso_user_id",
meta: { title: "SSO ID" },
meta: { title: t("users.table.columns.ssoId") },
header: ({ column }) => (
<span className="flex items-center gap-1.5">
<DataTableSortHeader column={column} title="SSO ID" />
<DataTableSortHeader column={column} title={t("users.table.columns.ssoId")} />
<CellTooltip
content={SSO_ID_HINT}
trigger={<Info className="size-3.5 shrink-0 text-muted-foreground" aria-label="About SSO ID" />}
content={t("users.table.ssoIdHint")}
trigger={
<Info className="size-3.5 shrink-0 text-muted-foreground" aria-label={t("users.table.ssoIdHintLabel")} />
}
/>
</span>
),
@ -221,8 +239,8 @@ export const getUsersTableColumns = ({
{
id: "key_count",
accessorKey: "key_count",
meta: { title: "Virtual Keys", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Virtual Keys" />,
meta: { title: t("users.table.columns.virtualKeys"), skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title={t("users.table.columns.virtualKeys")} />,
sortDescFirst: true,
size: 120,
enableSorting: true,
@ -234,7 +252,7 @@ export const getUsersTableColumns = ({
variant="outline"
className="whitespace-nowrap border-indigo-200 bg-indigo-50 font-normal text-indigo-600 dark:border-indigo-800 dark:bg-indigo-950 dark:text-indigo-300"
>
{keyCount} {keyCount === 1 ? "Key" : "Keys"}
{keyCount} {t("users.table.keyCount", { count: keyCount })}
</Badge>
);
}
@ -243,7 +261,7 @@ export const getUsersTableColumns = ({
variant="outline"
className="whitespace-nowrap border-border bg-muted font-normal text-muted-foreground"
>
No Keys
{t("users.table.noKeys")}
</Badge>
);
},
@ -251,8 +269,10 @@ export const getUsersTableColumns = ({
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" variant="header-cycle" />,
meta: { title: t("users.table.columns.createdAt") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("users.table.columns.createdAt")} variant="header-cycle" />
),
size: 130,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
@ -260,8 +280,8 @@ export const getUsersTableColumns = ({
{
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
meta: { title: t("users.table.columns.updatedAt") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("users.table.columns.updatedAt")} />,
sortDescFirst: false,
size: 130,
enableSorting: true,
@ -269,8 +289,8 @@ export const getUsersTableColumns = ({
},
{
id: "actions",
meta: { title: "Actions", className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
meta: { title: t("users.table.columns.actions"), className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">{t("users.table.columns.actions")}</span>,
size: 60,
enableSorting: false,
enableHiding: false,
@ -294,7 +314,7 @@ export const getUsersTableColumns = ({
return [
createSelectionColumn<UserInfo>({
rowAriaLabel: (row) => `Select ${row.original.user_email || row.original.user_id}`,
rowAriaLabel: (row) => t("users.table.selectRow", { user: row.original.user_email || row.original.user_id }),
}),
...baseColumns,
];

View file

@ -1,4 +1,6 @@
import type { ParseKeys } from "i18next";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@ -74,10 +76,10 @@ interface TeamOption {
team_alias: string;
}
const MEMBER_ROLE_OPTIONS = [
{ value: "user", hint: "Can view team info, but not manage it" },
{ value: "admin", hint: "Can create team keys, add members, and manage settings" },
] as const;
const MEMBER_ROLE_OPTIONS: ReadonlyArray<{ value: string; hintKey: ParseKeys }> = [
{ value: "user", hintKey: "users.userInfo.memberRoles.userHint" },
{ value: "admin", hintKey: "users.userInfo.memberRoles.adminHint" },
];
export default function UserInfoView({
userId,
@ -89,6 +91,7 @@ export default function UserInfoView({
initialTab = 0,
startInEditMode = false,
}: UserInfoViewProps) {
const { t } = useTranslation();
const { premiumUser, userId: signedInUserId } = useAuthorized();
const canViewAutoRouterUsage = hasProxyWideSpendView(userRole);
const canViewSavings = canViewAutoRouterUsage || (Boolean(userId.trim()) && userId === signedInUserId);
@ -159,14 +162,14 @@ export default function UserInfoView({
setUserModels(availableModels);
} catch (error) {
console.error("Error fetching user data:", error);
toast.fromError("Failed to fetch user data");
toast.fromError(t("users.userInfo.toasts.fetchFailed"));
} finally {
setIsLoading(false);
}
};
fetchData();
}, [accessToken, userId, userRole]);
}, [accessToken, userId, userRole, t]);
const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin";
@ -204,7 +207,7 @@ export default function UserInfoView({
user_id: userId,
};
await teamMemberAddCall(accessToken, selectedTeamId, member);
toast.success("User added to team successfully");
toast.success(t("users.userInfo.toasts.addedToTeam"));
setIsAddTeamModalOpen(false);
// Re-fetch user data to refresh teams
const data = await userGetInfoV2(accessToken, userId);
@ -224,7 +227,7 @@ export default function UserInfoView({
}
} catch (error: any) {
console.error("Error adding user to team:", error);
toast.fromError(error?.message || "Failed to add user to team");
toast.fromError(error?.message || t("users.userInfo.toasts.addToTeamFailed"));
} finally {
setIsAddingTeam(false);
}
@ -244,7 +247,7 @@ export default function UserInfoView({
user_id: userId,
};
await teamMemberDeleteCall(accessToken, teamToRemove.team_id, member);
toast.success("User removed from team successfully");
toast.success(t("users.userInfo.toasts.removedFromTeam"));
setIsRemoveTeamModalOpen(false);
setTeamToRemove(null);
// Re-fetch user data to refresh teams
@ -265,7 +268,7 @@ export default function UserInfoView({
}
} catch (error: any) {
console.error("Error removing user from team:", error);
toast.fromError(error?.message || "Failed to remove user from team");
toast.fromError(error?.message || t("users.userInfo.toasts.removeFromTeamFailed"));
} finally {
setIsRemovingTeam(false);
}
@ -282,16 +285,16 @@ export default function UserInfoView({
const handleResetPassword = async () => {
if (!accessToken) {
toast.fromError("Access token not found");
toast.fromError(t("users.userInfo.toasts.accessTokenMissing"));
return;
}
try {
toast.success("Generating password reset link...");
toast.success(t("users.userInfo.toasts.generatingResetLink"));
const data = await invitationCreateCall(accessToken, userId);
setInvitationLinkData(data);
setIsInvitationLinkModalVisible(true);
} catch (error) {
toast.fromError("Failed to generate password reset link");
toast.fromError(t("users.userInfo.toasts.resetLinkFailed"));
}
};
@ -300,14 +303,14 @@ export default function UserInfoView({
if (!accessToken) return;
setIsDeletingUser(true);
await userDeleteCall(accessToken, [userId]);
toast.success("User deleted successfully");
toast.success(t("users.userInfo.toasts.deleted"));
if (onDelete) {
onDelete();
}
onClose();
} catch (error) {
console.error("Error deleting user:", error);
toast.fromError("Failed to delete user");
toast.fromError(t("users.userInfo.toasts.deleteFailed"));
} finally {
setIsDeleteModalOpen(false);
setIsDeletingUser(false);
@ -353,11 +356,11 @@ export default function UserInfoView({
: userData.object_permission,
});
toast.success("User updated successfully");
toast.success(t("users.userInfo.toasts.updated"));
setIsEditing(false);
} catch (error) {
console.error("Error updating user:", error);
toast.fromError("Failed to update user");
toast.fromError(t("users.userInfo.toasts.updateFailed"));
}
};
@ -366,9 +369,9 @@ export default function UserInfoView({
<div className="p-4">
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft />
Back to Users
{t("users.userInfo.backToUsers")}
</Button>
<p className="text-sm">Loading user data...</p>
<p className="text-sm">{t("users.userInfo.loading")}</p>
</div>
);
}
@ -378,9 +381,9 @@ export default function UserInfoView({
<div className="p-4">
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft />
Back to Users
{t("users.userInfo.backToUsers")}
</Button>
<p className="text-sm">User not found</p>
<p className="text-sm">{t("users.userInfo.notFound")}</p>
</div>
);
}
@ -395,6 +398,12 @@ export default function UserInfoView({
}
};
const unknownTimestamp = t("users.userInfo.details.unknown");
const unlimitedLabel = t("users.userInfo.unlimited");
const budgetLimit =
userData.max_budget !== null ? `$${formatNumberWithCommas(userData.max_budget, 2)}` : unlimitedLabel;
const teamPickerPlaceholder = t("users.userInfo.addTeamDialog.teamPlaceholder");
// Build a legacy-compatible shape for UserEditView
const userDataForEdit = {
user_id: userData.user_id,
@ -421,9 +430,9 @@ export default function UserInfoView({
<div>
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft />
Back to Users
{t("users.userInfo.backToUsers")}
</Button>
<h2 className="text-xl font-semibold">{userData.user_email || "User"}</h2>
<h2 className="text-xl font-semibold">{userData.user_email || t("users.userInfo.fallbackTitle")}</h2>
<div className="flex items-center cursor-pointer">
<span className="text-sm text-muted-foreground font-mono">{userData.user_id}</span>
<Button
@ -444,7 +453,7 @@ export default function UserInfoView({
<div className="flex items-center space-x-2">
<Button variant="secondary" onClick={handleResetPassword} className="flex items-center">
<RefreshCw />
Reset Password
{t("users.userInfo.actions.resetPassword")}
</Button>
<Button
variant="secondary"
@ -452,7 +461,7 @@ export default function UserInfoView({
className="flex items-center text-destructive border-destructive hover:bg-destructive/10"
>
<Trash2 />
Delete User
{t("users.userInfo.actions.deleteUser")}
</Button>
</div>
)}
@ -460,18 +469,18 @@ export default function UserInfoView({
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete User?"
message="Are you sure you want to delete this user? This action cannot be undone."
resourceInformationTitle="User Information"
title={t("users.userInfo.deleteModal.title")}
message={t("users.userInfo.deleteModal.message")}
resourceInformationTitle={t("users.userInfo.deleteModal.informationTitle")}
resourceInformation={[
{ label: "Email", value: userData.user_email },
{ label: "User ID", value: userData.user_id, code: true },
{ label: t("users.userInfo.deleteModal.email"), value: userData.user_email },
{ label: t("users.userInfo.deleteModal.userId"), value: userData.user_id, code: true },
{
label: "Global Proxy Role",
label: t("users.userInfo.deleteModal.globalProxyRole"),
value: (userData.user_role && possibleUIRoles?.[userData.user_role]?.ui_label) || userData.user_role || "-",
},
{
label: "Total Spend (USD)",
label: t("users.userInfo.deleteModal.totalSpend"),
value: userData.spend !== null && userData.spend !== undefined ? userData.spend.toFixed(2) : undefined,
},
]}
@ -487,19 +496,19 @@ export default function UserInfoView({
>
<TabsList variant="line" className="mb-4">
<TabsTrigger value="overview" className="flex-none data-active:text-primary after:bg-primary">
Overview
{t("users.userInfo.tabs.overview")}
</TabsTrigger>
<TabsTrigger value="details" className="flex-none data-active:text-primary after:bg-primary">
Details
{t("users.userInfo.tabs.details")}
</TabsTrigger>
{canViewSavings && (
<TabsTrigger value="savings" className="flex-none data-active:text-primary after:bg-primary">
Savings
{t("users.userInfo.tabs.savings")}
</TabsTrigger>
)}
{canViewAutoRouterUsage && (
<TabsTrigger value="auto-router-usage" className="flex-none data-active:text-primary after:bg-primary">
Auto-router usage
{t("users.userInfo.tabs.autoRouterUsage")}
</TabsTrigger>
)}
</TabsList>
@ -509,7 +518,7 @@ export default function UserInfoView({
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
<p>
Spend
{t("users.userInfo.overview.spend")}
<BudgetPeriodTooltip
spend={userData.spend}
maxBudget={userData.max_budget}
@ -519,19 +528,17 @@ export default function UserInfoView({
</p>
<div className="mt-2">
<h3 className="text-lg font-medium">${formatNumberWithCommas(userData.spend || 0, 2)}</h3>
<p>
of {userData.max_budget !== null ? `$${formatNumberWithCommas(userData.max_budget, 2)}` : "Unlimited"}
</p>
<p>{t("users.userInfo.overview.budgetOf", { budget: budgetLimit })}</p>
</div>
</Card>
<Card className="block p-6">
<div className="flex justify-between items-center mb-2">
<p>Teams</p>
<p>{t("users.userInfo.overview.teams")}</p>
{isProxyAdmin && (
<Button variant="ghost" size="sm" onClick={handleOpenAddTeamModal}>
<Plus />
Add Team
{t("users.userInfo.overview.addTeam")}
</Button>
)}
</div>
@ -541,8 +548,10 @@ export default function UserInfoView({
<Table>
<TableHeader>
<TableRow>
<TableHead>Team Name</TableHead>
{isProxyAdmin && <TableHead className="text-right">Actions</TableHead>}
<TableHead>{t("users.userInfo.overview.teamName")}</TableHead>
{isProxyAdmin && (
<TableHead className="text-right">{t("users.userInfo.overview.actions")}</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
@ -558,7 +567,9 @@ export default function UserInfoView({
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove from ${team.team_alias || team.team_id}`}
aria-label={t("users.userInfo.overview.removeFromTeam", {
team: team.team_alias || team.team_id,
})}
onClick={() => handleOpenRemoveTeamModal(team)}
className="text-destructive"
>
@ -572,28 +583,28 @@ export default function UserInfoView({
</Table>
</div>
) : (
<p>No teams</p>
<p>{t("users.userInfo.overview.noTeams")}</p>
)}
{!isTeamsExpanded && teamDetails.length > 20 && (
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setIsTeamsExpanded(true)}>
+{teamDetails.length - 20} more
{t("users.userInfo.overview.moreTeams", { count: teamDetails.length - 20 })}
</Button>
)}
{isTeamsExpanded && teamDetails.length > 20 && (
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setIsTeamsExpanded(false)}>
Show Less
{t("users.userInfo.overview.showLess")}
</Button>
)}
</div>
</Card>
<Card className="block p-6">
<p>Personal Models</p>
<p>{t("users.userInfo.personalModels")}</p>
<div className="mt-2">
{userData.models?.length && userData.models?.length > 0 ? (
userData.models?.map((model, index) => <p key={index}>{model}</p>)
) : (
<p>All proxy models</p>
<p>{t("users.userInfo.allProxyModels")}</p>
)}
</div>
</Card>
@ -604,9 +615,9 @@ export default function UserInfoView({
<TabsContent value="details" keepMounted>
<Card className="block p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">User Settings</h3>
<h3 className="text-lg font-medium">{t("users.userInfo.details.title")}</h3>
{!isEditing && userRole && rolesWithWriteAccess.includes(userRole) && (
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
<Button onClick={() => setIsEditing(true)}>{t("users.userInfo.details.editSettings")}</Button>
)}
</div>
@ -627,7 +638,7 @@ export default function UserInfoView({
) : (
<div className="space-y-4">
<div>
<p className="font-medium">User ID</p>
<p className="font-medium">{t("users.userInfo.details.userId")}</p>
<div className="flex items-center cursor-pointer">
<span className="font-mono">{userData.user_id}</span>
<Button
@ -646,32 +657,32 @@ export default function UserInfoView({
</div>
<div>
<p className="font-medium">Email</p>
<p>{userData.user_email || "Not Set"}</p>
<p className="font-medium">{t("users.userInfo.details.email")}</p>
<p>{userData.user_email || t("users.userInfo.details.notSet")}</p>
</div>
<div>
<p className="font-medium">User Alias</p>
<p>{userData.user_alias || "Not Set"}</p>
<p className="font-medium">{t("users.userInfo.details.userAlias")}</p>
<p>{userData.user_alias || t("users.userInfo.details.notSet")}</p>
</div>
<div>
<p className="font-medium">Global Proxy Role</p>
<p>{userData.user_role || "Not Set"}</p>
<p className="font-medium">{t("users.userInfo.details.globalProxyRole")}</p>
<p>{userData.user_role || t("users.userInfo.details.notSet")}</p>
</div>
<div>
<p className="font-medium">Created</p>
<p>{userData.created_at ? new Date(userData.created_at).toLocaleString() : "Unknown"}</p>
<p className="font-medium">{t("users.userInfo.details.created")}</p>
<p>{userData.created_at ? new Date(userData.created_at).toLocaleString() : unknownTimestamp}</p>
</div>
<div>
<p className="font-medium">Last Updated</p>
<p>{userData.updated_at ? new Date(userData.updated_at).toLocaleString() : "Unknown"}</p>
<p className="font-medium">{t("users.userInfo.details.lastUpdated")}</p>
<p>{userData.updated_at ? new Date(userData.updated_at).toLocaleString() : unknownTimestamp}</p>
</div>
<div>
<p className="font-medium">Personal Models</p>
<p className="font-medium">{t("users.userInfo.personalModels")}</p>
<div className="flex flex-wrap gap-2 mt-1">
{userData.models?.length && userData.models?.length > 0 ? (
userData.models?.map((model, index) => (
@ -680,34 +691,34 @@ export default function UserInfoView({
</span>
))
) : (
<p>All proxy models</p>
<p>{t("users.userInfo.allProxyModels")}</p>
)}
</div>
</div>
<div>
<p className="font-medium">Max Budget</p>
<p className="font-medium">{t("users.userInfo.details.maxBudget")}</p>
<p>
{userData.max_budget !== null && userData.max_budget !== undefined
? `$${formatNumberWithCommas(userData.max_budget, 4)}`
: "Unlimited"}
: unlimitedLabel}
</p>
</div>
<div>
<p className="font-medium">Budget Reset</p>
<p className="font-medium">{t("users.userInfo.details.budgetReset")}</p>
<p>{getBudgetDurationLabel(userData.budget_duration ?? null)}</p>
</div>
<div>
<p className="font-medium">Metadata</p>
<p className="font-medium">{t("users.userInfo.details.metadata")}</p>
<pre className="bg-muted p-2 rounded-sm text-xs overflow-auto mt-1">
{JSON.stringify(userData.metadata || {}, null, 2)}
</pre>
</div>
<div>
<p className="font-medium mb-2">MCP Permissions</p>
<p className="font-medium mb-2">{t("users.userInfo.details.mcpPermissions")}</p>
<MCPServerPermissions
mcpServers={userData.object_permission?.mcp_servers || []}
mcpAccessGroups={userData.object_permission?.mcp_access_groups || []}
@ -730,10 +741,10 @@ export default function UserInfoView({
scope={{ userId }}
activity={activityDateRange}
entityType="user"
scopeNote="Savings for this user across API keys and JWT-authenticated requests."
scopeNote={t("users.userInfo.savings.scopeNote")}
/>
) : (
<p role="alert">Savings are unavailable because this user has no ID.</p>
<p role="alert">{t("users.userInfo.savings.unavailable")}</p>
))}
</TabsContent>
)}
@ -748,7 +759,7 @@ export default function UserInfoView({
activity={activityDateRange}
/>
) : (
<p role="alert">Auto-router usage is unavailable because this user has no ID.</p>
<p role="alert">{t("users.userInfo.autoRouter.unavailable")}</p>
))}
</TabsContent>
)}
@ -764,14 +775,14 @@ export default function UserInfoView({
{/* Delete Team Member Modal */}
<DeleteResourceModal
isOpen={isRemoveTeamModalOpen}
title="Remove from Team"
alertMessage="Removing this user from the team will also delete any keys the user created for this team."
message="Are you sure you want to remove this user from the team? This action cannot be undone."
resourceInformationTitle="Team Membership"
title={t("users.userInfo.removeTeamModal.title")}
alertMessage={t("users.userInfo.removeTeamModal.alert")}
message={t("users.userInfo.removeTeamModal.message")}
resourceInformationTitle={t("users.userInfo.removeTeamModal.informationTitle")}
resourceInformation={[
{ label: "Team", value: teamToRemove?.team_alias || teamToRemove?.team_id },
{ label: "User ID", value: userData?.user_id, code: true },
{ label: "Email", value: userData?.user_email },
{ label: t("users.userInfo.removeTeamModal.team"), value: teamToRemove?.team_alias || teamToRemove?.team_id },
{ label: t("users.userInfo.removeTeamModal.userId"), value: userData?.user_id, code: true },
{ label: t("users.userInfo.removeTeamModal.email"), value: userData?.user_email },
]}
onCancel={handleRemoveTeamCancel}
onOk={handleRemoveTeamConfirm}
@ -786,7 +797,7 @@ export default function UserInfoView({
>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Add User to Team</DialogTitle>
<DialogTitle>{t("users.userInfo.addTeamDialog.title")}</DialogTitle>
</DialogHeader>
<form
onSubmit={(event) => {
@ -796,7 +807,7 @@ export default function UserInfoView({
>
<FieldGroup>
<Field>
<FieldLabel htmlFor={ADD_TEAM_FIELD_ID}>Team</FieldLabel>
<FieldLabel htmlFor={ADD_TEAM_FIELD_ID}>{t("users.userInfo.addTeamDialog.team")}</FieldLabel>
<Combobox
items={availableTeamsForAdd}
value={selectedTeamOption}
@ -804,9 +815,9 @@ export default function UserInfoView({
itemToStringLabel={(team: TeamOption) => team.team_alias}
isItemEqualToValue={(team: TeamOption, value: TeamOption) => team.team_id === value.team_id}
>
<ComboboxInput id={ADD_TEAM_FIELD_ID} placeholder="Select a team" className="w-full" />
<ComboboxInput id={ADD_TEAM_FIELD_ID} placeholder={teamPickerPlaceholder} className="w-full" />
<ComboboxContent>
<ComboboxEmpty>No teams found</ComboboxEmpty>
<ComboboxEmpty>{t("users.userInfo.addTeamDialog.noTeams")}</ComboboxEmpty>
<ComboboxList>
{(team: TeamOption) => (
<ComboboxItem key={team.team_id} value={team} title={team.team_alias}>
@ -819,7 +830,7 @@ export default function UserInfoView({
</Field>
<Field>
<FieldLabel htmlFor={ADD_TEAM_ROLE_FIELD_ID}>Member Role</FieldLabel>
<FieldLabel htmlFor={ADD_TEAM_ROLE_FIELD_ID}>{t("users.userInfo.addTeamDialog.memberRole")}</FieldLabel>
<Select value={selectedRole} onValueChange={(value) => value !== null && setSelectedRole(value)}>
<SelectTrigger id={ADD_TEAM_ROLE_FIELD_ID} className="w-full">
<SelectValue />
@ -827,9 +838,9 @@ export default function UserInfoView({
<SelectContent>
{MEMBER_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} title={option.value}>
<SimpleTooltip content={option.hint}>
<SimpleTooltip content={t(option.hintKey)}>
<span className="font-medium">{option.value}</span>
<span className="ml-2 text-muted-foreground text-sm">- {option.hint}</span>
<span className="ml-2 text-muted-foreground text-sm">- {t(option.hintKey)}</span>
</SimpleTooltip>
</SelectItem>
))}
@ -840,7 +851,7 @@ export default function UserInfoView({
<div className="text-right mt-4">
<Button type="submit" disabled={isAddingTeam || !selectedTeamId} aria-busy={isAddingTeam}>
{isAddingTeam ? "Adding..." : "Add to Team"}
{isAddingTeam ? t("users.userInfo.addTeamDialog.submitting") : t("users.userInfo.addTeamDialog.submit")}
</Button>
</div>
</form>

View file

@ -15,6 +15,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { ChevronRight, CircleHelp, Info, UserPlus } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import TeamDropdown from "./common_components/team_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import { toast } from "@/lib/toast";
@ -134,18 +135,21 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => (
</>
);
const EmailInvitationsNotice: React.FC = () => (
<Alert variant="info" className="mb-4">
<Info />
<AlertTitle>Email invitations</AlertTitle>
<AlertDescription>
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured.{" "}
<a href="https://docs.litellm.ai/docs/proxy/email" target="_blank" rel="noreferrer">
Learn how to set up email notifications
</a>
</AlertDescription>
</Alert>
);
const EmailInvitationsNotice: React.FC = () => {
const { t } = useTranslation();
return (
<Alert variant="info" className="mb-4">
<Info />
<AlertTitle>{t("users.createUser.emailNotice.title")}</AlertTitle>
<AlertDescription>
{t("users.createUser.emailNotice.body")}{" "}
<a href="https://docs.litellm.ai/docs/proxy/email" target="_blank" rel="noreferrer">
{t("users.createUser.emailNotice.docsLink")}
</a>
</AlertDescription>
</Alert>
);
};
export const CreateUserButton: React.FC<CreateuserProps> = ({
userID,
@ -154,6 +158,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
onUserCreated,
isEmbedded = false,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
const defaultValues = isEmbedded ? EMBEDDED_DEFAULTS : STANDALONE_DEFAULTS;
@ -201,7 +206,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
const handleCreate = async (formValues: CreateUserFormValues) => {
try {
toast.info("Making API Call");
toast.info(t("users.createUser.toasts.makingApiCall"));
if (!isEmbedded) {
setIsModalVisible(true);
}
@ -228,11 +233,11 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
setIsInvitationLinkModalVisible(true);
}
toast.success("API user Created");
toast.success(t("users.createUser.toasts.created"));
form.reset(defaultValues);
localStorage.removeItem("userData" + userID);
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error?.message || "Error creating the user";
const errorMessage = error.response?.data?.detail || error?.message || t("users.createUser.toasts.createFailed");
toast.fromError(errorMessage);
console.error("Error creating the user:", error);
}
@ -245,7 +250,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
}));
const userEmailField = (
<FormField control={form.control} name="user_email" label="User Email">
<FormField control={form.control} name="user_email" label={t("users.createUser.fields.userEmail")}>
{({ ref, value, ...control }) => <Input {...control} ref={ref} value={value ?? ""} />}
</FormField>
);
@ -254,23 +259,34 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
<FormField
control={form.control}
name="team_id"
label="Team"
description="If selected, user will be added as a 'user' role to the team."
label={t("users.createUser.fields.team")}
description={t("users.createUser.fields.teamDescription")}
>
{({ id, value, onChange }) => <TeamDropdown id={id} value={value} onChange={onChange} />}
</FormField>
);
const metadataField = (
<FormField control={form.control} name="metadata" label="Metadata">
<FormField control={form.control} name="metadata" label={t("users.createUser.fields.metadata")}>
{({ ref, value, ...control }) => (
<Textarea {...control} ref={ref} value={value ?? ""} rows={4} placeholder="Enter metadata as JSON" />
<Textarea
{...control}
ref={ref}
value={value ?? ""}
rows={4}
placeholder={t("users.createUser.fields.metadataPlaceholder")}
/>
)}
</FormField>
);
const sendInviteEmailField = (
<FormField control={form.control} name="send_invite_email" label="Send invitation email" orientation="horizontal">
<FormField
control={form.control}
name="send_invite_email"
label={t("users.createUser.fields.sendInviteEmail")}
orientation="horizontal"
>
{({ id, value, onChange, onBlur }) => (
<Checkbox id={id} checked={value} onCheckedChange={onChange} onBlur={onBlur} />
)}
@ -309,13 +325,13 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
<EmailInvitationsNotice />
<FieldGroup>
{userEmailField}
{roleField("User Role")}
{roleField(t("users.createUser.fields.userRole"))}
{teamField}
{metadataField}
{sendInviteEmailField}
</FieldGroup>
<div className="mt-4 text-right">
<Button type="submit">Create User</Button>
<Button type="submit">{t("users.createUser.createButton")}</Button>
</div>
</form>
</TooltipProvider>
@ -326,15 +342,15 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
return (
<>
<Button type="button" onClick={() => setIsModalVisible(true)}>
+ Invite User
{t("users.createUser.openButton")}
</Button>
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogTitle>{t("users.createUser.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<p className="mb-1 text-sm text-foreground">Create a User who can own keys</p>
<p className="mb-1 text-sm text-foreground">{t("users.createUser.subtitle")}</p>
<EmailInvitationsNotice />
</div>
<TooltipProvider>
@ -342,18 +358,15 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
<FieldGroup>
{userEmailField}
{roleField(
labelWithHint(
"Global Proxy Role",
"This role is independent of any team/org specific roles. Configure Team / Organization Admins in the Settings",
),
labelWithHint(t("users.createUser.fields.globalRole"), t("users.createUser.fields.globalRoleHint")),
)}
{teamField}
<FormField
control={form.control}
name="organization_ids"
label="Organization"
description="The user will be added to the selected organization(s)."
label={t("users.createUser.fields.organization")}
description={t("users.createUser.fields.organizationDescription")}
>
{({ id, value, onChange }) => (
<Select
@ -363,10 +376,10 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
onValueChange={(selected: string[]) => onChange(selected.length === 0 ? undefined : selected)}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder="Select Organization">
<SelectValue placeholder={t("users.createUser.fields.organizationPlaceholder")}>
{(selected: string[]) =>
selected.length === 0
? "Select Organization"
? t("users.createUser.fields.organizationPlaceholder")
: organizationOptions
.filter((option) => selected.includes(option.value))
.map((option) => option.label)
@ -394,25 +407,28 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
className={`size-4 transition-transform ${isPersonalKeyOpen ? "rotate-90" : ""}`}
aria-hidden
/>
Personal Key Creation
{t("users.createUser.fields.personalKeyCreation")}
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<FormField
control={form.control}
name="models"
label={labelWithHint("Models", "Models user has access to, outside of team scope.")}
description="Models user has access to, outside of team scope."
label={labelWithHint(
t("users.createUser.fields.models"),
t("users.createUser.fields.modelsHint"),
)}
description={t("users.createUser.fields.modelsDescription")}
>
{({ value, onChange }) => (
<MultiSelect
options={[
{ label: "All Proxy Models", value: "all-proxy-models" },
{ label: "No Default Models", value: "no-default-models" },
{ label: t("users.createUser.fields.allProxyModels"), value: "all-proxy-models" },
{ label: t("users.createUser.fields.noDefaultModels"), value: "no-default-models" },
...userModels.map((model) => ({ label: getModelDisplayName(model), value: model })),
]}
value={value ?? []}
onValueChange={onChange}
placeholder="Select models"
placeholder={t("users.createUser.fields.modelsPlaceholder")}
/>
)}
</FormField>
@ -423,7 +439,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
<div className="mt-4 text-right">
<Button type="submit">
<UserPlus />
Invite User
{t("users.createUser.submitButton")}
</Button>
</div>
</form>

View file

@ -17,18 +17,20 @@ import {
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { Team } from "@/components/key_team_helpers/key_list";
import { splitWildcardModels } from "./modelUtils";
const MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE = {
label: "All Proxy Models",
labelKey: "modelSelect.specialOptions.allProxyModels",
value: "all-proxy-models",
} as const;
const MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE = {
label: "No Default Models",
labelKey: "modelSelect.specialOptions.noDefaultModels",
value: "no-default-models",
} as const;
@ -137,14 +139,16 @@ const unavailableGroups = (
selectedOptions: ModelOption[],
offeredByValue: Map<string, ModelOption>,
offeredListKnown: boolean,
t: TFunction,
): ModelOptionGroup[] => {
if (!offeredListKnown) return [];
const items = selectedOptions.filter((option) => !offeredByValue.has(option.value));
if (items.length === 0) return [];
return [{ label: "Unavailable", items }];
return [{ label: t("modelSelect.groups.unavailable"), items }];
};
export const ModelSelect = (props: ModelSelectProps) => {
const { t } = useTranslation();
const anchor = useComboboxAnchor();
const { id, teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props;
const { showAllProxyModelsOverride, includeSpecialOptions } = options || {};
@ -186,12 +190,12 @@ export const ModelSelect = (props: ModelSelectProps) => {
...(includeSpecialOptions
? [
{
label: "Special Options",
label: t("modelSelect.groups.special"),
items: [
...(shouldShowAllProxyModels
? [
{
label: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.label,
label: t(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.labelKey),
value: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
@ -202,7 +206,7 @@ export const ModelSelect = (props: ModelSelectProps) => {
]
: []),
{
label: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.label,
label: t(MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.labelKey),
value: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
@ -215,13 +219,13 @@ export const ModelSelect = (props: ModelSelectProps) => {
...(wildcard.length > 0
? [
{
label: "Wildcard Options",
label: t("modelSelect.groups.wildcard"),
items: wildcard.map((model) => {
const provider = model.replace("/*", "");
const capitalizedProvider = provider.charAt(0).toUpperCase() + provider.slice(1);
return {
label: `All ${capitalizedProvider} models`,
label: t("modelSelect.wildcardOption", { provider: capitalizedProvider }),
value: model,
disabled: hasSpecialOptionSelected,
};
@ -230,7 +234,7 @@ export const ModelSelect = (props: ModelSelectProps) => {
]
: []),
{
label: "Models",
label: t("modelSelect.groups.models"),
items: regular.map((model) => ({
label: model,
value: model,
@ -240,15 +244,17 @@ export const ModelSelect = (props: ModelSelectProps) => {
];
const offeredByValue = new Map(offeredGroups.flatMap((group) => group.items).map((option) => [option.value, option]));
const selectedOptions = value.map(
(v) =>
offeredByValue.get(v) ?? MODEL_SENTINEL_OPTIONS.find((option) => option.value === v) ?? { label: v, value: v },
);
const sentinelOption = (v: string): ModelOption | undefined => {
const sentinel = MODEL_SENTINEL_OPTIONS.find((option) => option.value === v);
return sentinel === undefined ? undefined : { label: t(sentinel.labelKey), value: sentinel.value };
};
const selectedOptions = value.map((v) => offeredByValue.get(v) ?? sentinelOption(v) ?? { label: v, value: v });
const groups: ModelOptionGroup[] = [
...unavailableGroups(
selectedOptions,
offeredByValue,
isOfferedListKnown(allProxyModels !== undefined, context, organizationID, organizationModels),
t,
),
...offeredGroups,
];
@ -275,19 +281,24 @@ export const ModelSelect = (props: ModelSelectProps) => {
))}
{overflowOptions.length > 0 && (
<Tooltip>
<TooltipTrigger
render={<span className="px-1 text-xs text-muted-foreground" />}
>{`+${overflowOptions.length} more`}</TooltipTrigger>
<TooltipTrigger render={<span className="px-1 text-xs text-muted-foreground" />}>
{t("modelSelect.moreSelected", { count: overflowOptions.length })}
</TooltipTrigger>
<TooltipContent>{overflowOptions.map((option) => option.value).join(", ")}</TooltipContent>
</Tooltip>
)}
</>
)}
</ComboboxValue>
<ComboboxChipsInput id={id} placeholder="Select Models" aria-label="Select Models" className="min-w-24" />
<ComboboxChipsInput
id={id}
placeholder={t("modelSelect.placeholder")}
aria-label={t("modelSelect.placeholder")}
className="min-w-24"
/>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxEmpty>{t("modelSelect.empty")}</ComboboxEmpty>
<ComboboxList>
{(group: ModelOptionGroup) => (
<ComboboxGroup key={group.label} items={group.items}>

View file

@ -1,5 +1,6 @@
import { Edit, Save } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { Badge } from "@/components/ui/badge";
@ -70,7 +71,10 @@ const SettingRow: React.FC<SettingRowProps> = ({ label, description, isEditing,
</div>
);
const NotSet = () => <span className="italic text-muted-foreground">Not set</span>;
const NotSet = () => {
const { t } = useTranslation();
return <span className="italic text-muted-foreground">{t("teams.ssoSettings.notSet")}</span>;
};
const renderTags = (values: string[], displayFn?: (v: string) => string) => {
if (!values || values.length === 0) return <NotSet />;
@ -111,6 +115,7 @@ const DEFAULT_VALUES: SettingsValues = {
};
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
const { t } = useTranslation();
const anchor = useComboboxAnchor();
const [loading, setLoading] = useState<boolean>(true);
const [values, setValues] = useState<SettingsValues>(DEFAULT_VALUES);
@ -135,14 +140,14 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
} catch (error) {
console.error("Error fetching team SSO settings:", error);
setFetchError(true);
toast.fromError("Failed to fetch team settings");
toast.fromError(t("teams.ssoSettings.toasts.fetchFailed"));
} finally {
setLoading(false);
}
};
fetchSettings();
}, [accessToken]);
}, [accessToken, t]);
const handleSave = async () => {
if (!accessToken) return;
@ -154,10 +159,10 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
setValues(newValues);
setEditedValues(newValues);
setIsEditing(false);
toast.success("Default team settings updated successfully");
toast.success(t("teams.ssoSettings.toasts.updated"));
} catch (error) {
console.error("Error updating team settings:", error);
toast.fromError("Failed to update team settings");
toast.fromError(t("teams.ssoSettings.toasts.updateFailed"));
} finally {
setSaving(false);
}
@ -175,7 +180,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
if (loading) {
return (
<div className="flex h-64 items-center justify-center" aria-busy="true">
<UiLoadingSpinner aria-label="Loading default team settings" />
<UiLoadingSpinner aria-label={t("teams.ssoSettings.loading")} />
</div>
);
}
@ -184,7 +189,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
return (
<Card>
<CardContent>
<p>No team settings available or you do not have permission to view them.</p>
<p>{t("teams.ssoSettings.noAccess")}</p>
</CardContent>
</Card>
);
@ -195,17 +200,15 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
<CardHeader className="gap-4 border-b border-border pb-6">
<div>
<CardTitle>
<h3 className="text-lg font-semibold text-foreground">Default Team Settings</h3>
<h3 className="text-lg font-semibold text-foreground">{t("teams.ssoSettings.title")}</h3>
</CardTitle>
<CardDescription className="mt-1">
These settings will be applied by default when creating new teams.
</CardDescription>
<CardDescription className="mt-1">{t("teams.ssoSettings.description")}</CardDescription>
</div>
<CardAction>
{isEditing ? (
<div className="flex gap-3">
<Button type="button" variant="outline" onClick={handleCancel} disabled={saving}>
Cancel
{t("teams.ssoSettings.actions.cancel")}
</Button>
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? (
@ -213,13 +216,13 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
) : (
<Save data-icon="inline-start" />
)}
Save Changes
{t("teams.ssoSettings.actions.save")}
</Button>
</div>
) : (
<Button type="button" variant="outline" onClick={() => setIsEditing(true)}>
<Edit data-icon="inline-start" />
Edit Settings
{t("teams.ssoSettings.actions.edit")}
</Button>
)}
</CardAction>
@ -228,12 +231,12 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
<CardContent className="pt-8">
<section className="mb-8">
<h4 className="mb-2 text-xs font-bold tracking-wider text-muted-foreground uppercase">
Budget & Rate Limits
{t("teams.ssoSettings.sections.budget")}
</h4>
<div className="border-t border-border">
<SettingRow
label="Max Budget"
description="Maximum budget (in USD) for new automatically created teams."
label={t("teams.ssoSettings.fields.maxBudget.label")}
description={t("teams.ssoSettings.fields.maxBudget.description")}
isEditing={isEditing}
viewContent={
values.max_budget != null ? <span>${Number(values.max_budget).toLocaleString()}</span> : <NotSet />
@ -249,16 +252,16 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
onChange={(event) =>
update("max_budget", event.target.value === "" ? null : Number(event.target.value))
}
placeholder="Not set"
aria-label="Max Budget"
placeholder={t("teams.ssoSettings.notSet")}
aria-label={t("teams.ssoSettings.fields.maxBudget.label")}
/>
</InputGroup>
}
/>
<SettingRow
label="Budget Duration"
description="How frequently the team's budget resets."
label={t("teams.ssoSettings.fields.budgetDuration.label")}
description={t("teams.ssoSettings.fields.budgetDuration.description")}
isEditing={isEditing}
viewContent={
values.budget_duration ? <span>{getBudgetDurationLabel(values.budget_duration)}</span> : <NotSet />
@ -273,8 +276,8 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
/>
<SettingRow
label="TPM Limit"
description="Maximum tokens per minute allowed across all models."
label={t("teams.ssoSettings.fields.tpmLimit.label")}
description={t("teams.ssoSettings.fields.tpmLimit.description")}
isEditing={isEditing}
viewContent={values.tpm_limit != null ? <span>{values.tpm_limit.toLocaleString()}</span> : <NotSet />}
editContent={
@ -286,16 +289,16 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
onChange={(event) =>
update("tpm_limit", event.target.value === "" ? null : Number(event.target.value))
}
placeholder="Not set"
placeholder={t("teams.ssoSettings.notSet")}
min={0}
aria-label="TPM Limit"
aria-label={t("teams.ssoSettings.fields.tpmLimit.label")}
/>
}
/>
<SettingRow
label="RPM Limit"
description="Maximum requests per minute allowed across all models."
label={t("teams.ssoSettings.fields.rpmLimit.label")}
description={t("teams.ssoSettings.fields.rpmLimit.description")}
isEditing={isEditing}
viewContent={values.rpm_limit != null ? <span>{values.rpm_limit.toLocaleString()}</span> : <NotSet />}
editContent={
@ -307,9 +310,9 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
onChange={(event) =>
update("rpm_limit", event.target.value === "" ? null : Number(event.target.value))
}
placeholder="Not set"
placeholder={t("teams.ssoSettings.notSet")}
min={0}
aria-label="RPM Limit"
aria-label={t("teams.ssoSettings.fields.rpmLimit.label")}
/>
}
/>
@ -318,12 +321,12 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
<section>
<h4 className="mb-2 text-xs font-bold tracking-wider text-muted-foreground uppercase">
Access & Permissions
{t("teams.ssoSettings.sections.access")}
</h4>
<div className="border-t border-border">
<SettingRow
label="Default Organization"
description="Teams created without an explicit organization are assigned to this organization."
label={t("teams.ssoSettings.fields.organization.label")}
description={t("teams.ssoSettings.fields.organization.description")}
isEditing={isEditing}
viewContent={
values.organization_id ? (
@ -339,15 +342,15 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
loading={isOrganizationsLoading}
value={editedValues.organization_id ?? undefined}
onChange={(organizationId) => update("organization_id", organizationId || null)}
placeholder="Select an organization"
placeholder={t("teams.ssoSettings.fields.organization.placeholder")}
/>
</div>
}
/>
<SettingRow
label="Models"
description="Default list of models that new teams can access."
label={t("teams.ssoSettings.fields.models.label")}
description={t("teams.ssoSettings.fields.models.description")}
isEditing={isEditing}
viewContent={renderTags(values.models, getModelDisplayName)}
editContent={
@ -363,8 +366,8 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
/>
<SettingRow
label="Team Member Permissions"
description="Default permissions granted to members of newly created teams. /key/info and /key/health are always included."
label={t("teams.ssoSettings.fields.permissions.label")}
description={t("teams.ssoSettings.fields.permissions.description")}
isEditing={isEditing}
viewContent={renderTags(values.team_member_permissions)}
editContent={
@ -384,7 +387,10 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
))
}
</ComboboxValue>
<ComboboxChipsInput placeholder="Select permissions" aria-label="Team Member Permissions" />
<ComboboxChipsInput
placeholder={t("teams.ssoSettings.fields.permissions.placeholder")}
aria-label={t("teams.ssoSettings.fields.permissions.label")}
/>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxList>

View file

@ -18,7 +18,9 @@ import { useZodForm } from "@/lib/forms/useZodForm";
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
import { TabsContent } from "@/components/ui/tabs";
import { ChevronDown, Plus, Users } from "lucide-react";
import type { TFunction } from "i18next";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
@ -74,43 +76,44 @@ const SUPPRESSED_BY_DESCRIPTION = "";
const numericInputSchema = z.union([z.string(), z.number()]).optional();
const teamCreateFieldsSchema = z.object({
team_alias: z.string().min(1, "Please input a team name"),
organization_id: z.string().nullish(),
models: z.array(z.string()).optional(),
max_budget: numericInputSchema,
budget_duration: z.string().nullish(),
tpm_limit: numericInputSchema,
rpm_limit: numericInputSchema,
tpd_limit: numericInputSchema,
metadata: metadataPairsSchema.optional(),
team_id: z.string().optional(),
team_member_budget: z.number().optional(),
team_member_key_duration: z.string().optional(),
team_member_rpm_limit: numericInputSchema,
team_member_tpm_limit: numericInputSchema,
secret_manager_settings: z.string().optional(),
guardrails: z.array(z.string()).optional(),
disable_global_guardrails: z.boolean().optional(),
require_trace_id: z.boolean().optional(),
policies: z.array(z.string()).optional(),
access_group_ids: z.array(z.string()).optional(),
allowed_vector_store_ids: z.array(z.string()).optional(),
allowed_passthrough_routes: z.array(z.string()).optional(),
allowed_mcp_servers_and_groups: z
.object({
servers: z.array(z.string()),
accessGroups: z.array(z.string()),
toolsets: z.array(z.string()).optional(),
})
.optional(),
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
allowed_agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
object_permission_search_tools: z.array(z.string()).optional(),
object_permission_skills: z.array(z.string()).optional(),
});
const buildTeamCreateFieldsSchema = (t: TFunction) =>
z.object({
team_alias: z.string().min(1, t("teams.page.create.errors.teamNameRequired")),
organization_id: z.string().nullish(),
models: z.array(z.string()).optional(),
max_budget: numericInputSchema,
budget_duration: z.string().nullish(),
tpm_limit: numericInputSchema,
rpm_limit: numericInputSchema,
tpd_limit: numericInputSchema,
metadata: metadataPairsSchema.optional(),
team_id: z.string().optional(),
team_member_budget: z.number().optional(),
team_member_key_duration: z.string().optional(),
team_member_rpm_limit: numericInputSchema,
team_member_tpm_limit: numericInputSchema,
secret_manager_settings: z.string().optional(),
guardrails: z.array(z.string()).optional(),
disable_global_guardrails: z.boolean().optional(),
require_trace_id: z.boolean().optional(),
policies: z.array(z.string()).optional(),
access_group_ids: z.array(z.string()).optional(),
allowed_vector_store_ids: z.array(z.string()).optional(),
allowed_passthrough_routes: z.array(z.string()).optional(),
allowed_mcp_servers_and_groups: z
.object({
servers: z.array(z.string()),
accessGroups: z.array(z.string()),
toolsets: z.array(z.string()).optional(),
})
.optional(),
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
allowed_agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
object_permission_search_tools: z.array(z.string()).optional(),
object_permission_skills: z.array(z.string()).optional(),
});
type TeamCreateFormValues = z.infer<typeof teamCreateFieldsSchema>;
type TeamCreateFormValues = z.infer<ReturnType<typeof buildTeamCreateFieldsSchema>>;
const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = {
team_alias: "",
@ -216,6 +219,7 @@ const getAdminOrganizations = (
// @deprecated
const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly = false, premiumUser = false }) => {
const { t } = useTranslation();
const { data: organizationsData } = useOrganizations();
const organizations = organizationsData ?? null;
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
@ -237,7 +241,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
const teamCreateSchema = useMemo(
() =>
teamCreateFieldsSchema.superRefine((values, ctx) => {
buildTeamCreateFieldsSchema(t).superRefine((values, ctx) => {
if (isOrgAdmin && !values.organization_id) {
ctx.addIssue({ code: "custom", message: SUPPRESSED_BY_DESCRIPTION, path: ["organization_id"] });
}
@ -248,7 +252,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
if (!organizationIsStillPickable) {
ctx.addIssue({
code: "custom",
message: "You can no longer create teams in this organization",
message: t("teams.page.create.errors.organizationNotPickable"),
path: ["organization_id"],
});
}
@ -256,7 +260,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
ctx.addIssue({ code: "custom", message: SUPPRESSED_BY_DESCRIPTION, path: ["secret_manager_settings"] });
}
}),
[isOrgAdmin, additionalSettingsOpen, adminOrgs, organizations],
[isOrgAdmin, additionalSettingsOpen, adminOrgs, organizations, t],
);
const form = useZodForm(teamCreateSchema, { defaultValues: EMPTY_TEAM_CREATE_VALUES });
@ -294,7 +298,10 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
});
const defaultBudgetDuration: string | undefined = defaultTeamSettings?.values?.budget_duration ?? undefined;
const budgetDurationPlaceholder = defaultBudgetDuration
? `Default: ${getBudgetDurationLabel(defaultBudgetDuration)} (${defaultBudgetDuration})`
? t("teams.page.create.fields.budgetDuration.defaultPlaceholder", {
label: getBudgetDurationLabel(defaultBudgetDuration),
duration: defaultBudgetDuration,
})
: undefined;
// Add this useEffect to fetch guardrails
@ -385,9 +392,9 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
setIsTeamDeleting(true);
await teamDeleteCall(accessToken, teamToDelete.team_id);
await refreshTeams();
toast.success("Team deleted successfully");
toast.success(t("teams.page.toasts.deleted"));
} catch (error) {
toast.fromError("Error deleting the team: " + error);
toast.fromError(t("teams.page.toasts.deleteFailed", { error: String(error) }));
} finally {
setIsTeamDeleting(false);
setIsDeleteModalOpen(false);
@ -432,7 +439,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
formValues.budget_duration = null;
}
toast.info("Creating Team");
toast.info(t("teams.page.toasts.creating"));
const metadataObject = {
...metadataPairsToObject(formValues.metadata),
@ -561,14 +568,14 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
...(teamMemberModelBudgets !== undefined ? { team_member_model_max_budget: teamMemberModelBudgets } : {}),
models: normalizeTeamModelSelection(formValues.models),
});
toast.success("Team created");
toast.success(t("teams.page.toasts.created"));
await refreshTeams();
resetCreateForm();
setIsTeamModalVisible(false);
}
} catch (error) {
console.error("Error creating the team:", error);
toast.fromError("Error creating the team: " + extractProxyErrorMessage(error));
toast.fromError(t("teams.page.toasts.createFailed", { error: extractProxyErrorMessage(error) }));
}
};
@ -602,7 +609,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
const tabItems = [
{
key: "your-teams",
label: "Your Teams",
label: t("teams.page.tabs.yourTeams"),
className: "flex min-h-0 flex-1 flex-col",
children: (
<>
@ -624,23 +631,23 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Team?"
title={t("teams.page.deleteModal.title")}
alertMessage={(() => {
const deleteKeyCount = teamToDelete?.keys_count ?? teamToDelete?.keys?.length ?? 0;
return deleteKeyCount === 0
? undefined
: `Warning: This team has ${deleteKeyCount} keys associated with it. Deleting the team will also delete all associated keys, along with any models created for this team. This action is irreversible.`;
: t("teams.page.deleteModal.keysWarning", { count: deleteKeyCount });
})()}
message="Are you sure you want to delete this team, all its keys, and any models created for it? This action cannot be undone."
resourceInformationTitle="Team Information"
message={t("teams.page.deleteModal.message")}
resourceInformationTitle={t("teams.page.deleteModal.informationTitle")}
resourceInformation={[
{ label: "Team ID", value: teamToDelete?.team_id, code: true },
{ label: "Team Name", value: teamToDelete?.team_alias },
{ label: t("teams.page.deleteModal.teamId"), value: teamToDelete?.team_id, code: true },
{ label: t("teams.page.deleteModal.teamName"), value: teamToDelete?.team_alias },
{
label: "Keys",
label: t("teams.page.deleteModal.keys"),
value: teamToDelete?.keys_count ?? teamToDelete?.keys?.length ?? 0,
},
{ label: "Members", value: teamToDelete?.members_with_roles?.length },
{ label: t("teams.page.deleteModal.members"), value: teamToDelete?.members_with_roles?.length },
]}
requiredConfirmation={teamToDelete?.team_alias}
onCancel={cancelDelete}
@ -652,7 +659,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
},
{
key: "available-teams",
label: "Available Teams",
label: t("teams.page.tabs.availableTeams"),
className: "min-h-0 flex-1 overflow-y-auto",
children: <AvailableTeamsPanel accessToken={accessToken} userID={userID} />,
},
@ -660,7 +667,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
? [
{
key: "default-settings",
label: "Default Team Settings",
label: t("teams.page.tabs.defaultSettings"),
className: "min-h-0 flex-1 overflow-y-auto",
children: <TeamSSOSettings accessToken={accessToken} userID={userID || ""} userRole={userRole || ""} />,
},
@ -693,16 +700,16 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
<PageHeader>
<PageHeaderTitle>
<Users />
Teams
{t("teams.page.header.title")}
</PageHeaderTitle>
<PageHeaderDescription>Manage teams, members, and their access to models and budgets</PageHeaderDescription>
<PageHeaderDescription>{t("teams.page.header.description")}</PageHeaderDescription>
<PageHeaderControls>
<PageTabsList>
{canCreateOrManageTeams(userRole, userID, organizations) && (
<>
<UIButton onClick={openCreateTeamModal} data-testid="create-team-button">
<Plus className="size-4" />
Create Team
{t("teams.page.header.createTeam")}
</UIButton>
<ToolbarSeparator className="mx-0 h-6" />
</>
@ -727,12 +734,12 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
<Dialog open={isTeamModalVisible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]">
<DialogHeader>
<DialogTitle>Create Team</DialogTitle>
<DialogTitle>{t("teams.page.create.title")}</DialogTitle>
</DialogHeader>
<TooltipProvider>
<form onSubmit={form.handleSubmit(onCreateSubmit)}>
<FieldGroup>
<FormField control={form.control} name="team_alias" label="Team Name">
<FormField control={form.control} name="team_alias" label={t("teams.page.create.fields.teamName")}>
{({ ref, value, ...field }) => (
<UIInput {...field} ref={ref} value={value ?? ""} data-testid="team-name-input" />
)}
@ -749,15 +756,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="organization_id"
className="mt-8"
label={labelWithDocsHint(
"Organization",
"Organizations can have multiple teams. Learn more about the user management hierarchy",
t("teams.page.create.fields.organization.label"),
t("teams.page.create.fields.organization.hint"),
"https://docs.litellm.ai/docs/proxy/user_management_heirarchy",
)}
description={
isOrgAdmin && isSingleOrg
? "You can only create teams within this organization"
? t("teams.page.create.fields.organization.singleOrgDescription")
: isOrgAdmin
? "required"
? t("teams.page.create.fields.organization.requiredDescription")
: undefined
}
>
@ -773,9 +780,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
disabled={isOrgAdmin && soleOrganizationId !== null && value === soleOrganizationId}
allowClear={!isOrgAdmin}
placeholder={
hasNoOrgs ? "No organizations available" : "Search or select an Organization"
hasNoOrgs
? t("teams.page.create.fields.organization.noOrganizations")
: t("teams.page.create.fields.organization.placeholder")
}
emptyText="No organizations available"
emptyText={t("teams.page.create.fields.organization.noOrganizations")}
onValueChange={(next) => selectCreateTeamOrganization(next, value ?? null, onChange)}
/>
)}
@ -784,8 +793,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
{isOrgAdmin && !isSingleOrg && adminOrgs.length > 1 && (
<div className="mb-8 rounded-md border border-info/20 bg-info/10 p-4">
<span className="text-sm text-info">
Please select an organization to create a team for. You can only create teams within
organizations where you are an admin.
{t("teams.page.create.fields.organization.selectPrompt")}
</span>
</div>
)}
@ -796,8 +804,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
control={form.control}
name="models"
label={labelWithHint(
"Models",
"These are the models that your selected team has access to. Leave empty to grant no models directly, e.g. when the team gets its models from access groups",
t("teams.page.create.fields.models.label"),
t("teams.page.create.fields.models.hint"),
)}
>
{({ id, value, onChange }) => (
@ -816,12 +824,17 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
)}
</FormField>
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
<FormField control={form.control} name="max_budget" label={t("teams.page.create.fields.maxBudget")}>
{({ ref, value, ...field }) => (
<NumericalInput {...field} ref={ref} value={value ?? ""} step={0.01} precision={2} width={200} />
)}
</FormField>
<FormField control={form.control} name="budget_duration" className="mt-8" label="Reset Budget">
<FormField
control={form.control}
name="budget_duration"
className="mt-8"
label={t("teams.page.create.fields.budgetDuration.label")}
>
{({ id, value, onChange }) => (
<BudgetDurationDropdown
id={id}
@ -838,14 +851,14 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
value={modelMaxBudget}
onChange={setModelMaxBudget}
availableModels={userModels}
hint="Cap this team's spend on individual models, each with its own reset window. Every key on the team shares the cap unless the key sets its own budget for that model."
hint={t("teams.page.create.fields.modelMaxBudgetHint")}
/>
<FormField control={form.control} name="tpm_limit" label="Tokens per minute Limit (TPM)">
<FormField control={form.control} name="tpm_limit" label={t("teams.page.create.fields.tpmLimit")}>
{({ ref, value, ...field }) => (
<NumericalInput {...field} ref={ref} value={value ?? ""} step={1} width={400} />
)}
</FormField>
<FormField control={form.control} name="rpm_limit" label="Requests per minute Limit (RPM)">
<FormField control={form.control} name="rpm_limit" label={t("teams.page.create.fields.rpmLimit")}>
{({ ref, value, ...field }) => (
<NumericalInput {...field} ref={ref} value={value ?? ""} step={1} width={400} />
)}
@ -854,8 +867,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
control={form.control}
name="tpd_limit"
label={labelWithHint(
"Tokens per day Limit (TPD)",
"Daily token budget for batch submissions (/v1/batches). When set, batch input files are charged against this 24h window instead of the team's TPM/RPM limits. Online requests keep using TPM/RPM.",
t("teams.page.create.fields.tpdLimit.label"),
t("teams.page.create.fields.tpdLimit.hint"),
)}
>
{({ ref, value, ...field }) => (
@ -863,7 +876,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
)}
</FormField>
<Field>
<FieldLabel>Metadata</FieldLabel>
<FieldLabel>{t("teams.page.create.fields.metadata.label")}</FieldLabel>
<MetadataKeyValueFields
control={form.control}
getValues={form.getValues}
@ -872,7 +885,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
schemaLoading={isTeamMetadataSchemaLoading}
/>
<FieldDescription>
Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {'{"region": "us"}'}.
{t("teams.page.create.fields.metadata.description", { example: '{"region": "us"}' })}
</FieldDescription>
</Field>
@ -882,7 +895,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
className="mt-20 mb-8 overflow-hidden rounded-lg border"
>
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Additional Settings</b>
<b>{t("teams.page.create.sections.additional")}</b>
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
@ -890,8 +903,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
<FormField
control={form.control}
name="team_id"
label="Team ID"
description="ID of the team you want to create. If not provided, it will be generated automatically."
label={t("teams.page.create.fields.teamId.label")}
description={t("teams.page.create.fields.teamId.description")}
>
{({ ref, value, ...field }) => <UIInput {...field} ref={ref} value={value ?? ""} />}
</FormField>
@ -899,8 +912,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
control={form.control}
name="team_member_budget"
label={labelWithHint(
"Team Member Budget (USD)",
"This is the individual budget for a user in the team.",
t("teams.page.create.fields.teamMemberBudget.label"),
t("teams.page.create.fields.teamMemberBudget.hint"),
)}
>
{({ ref, value, onChange, ...field }) => (
@ -919,31 +932,36 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
</FormField>
<ModelMaxBudgetField
key={`team-member-model-max-budget-${routerSettingsKey}`}
label="Default Per-Model Budget"
label={t("teams.page.create.fields.teamMemberModelMaxBudget.label")}
premiumUser={premiumUser}
value={teamMemberModelMaxBudget}
onChange={setTeamMemberModelMaxBudget}
availableModels={userModels}
hint="Set a default per-model spend cap for each team member, with an independent reset window."
hint={t("teams.page.create.fields.teamMemberModelMaxBudget.hint")}
/>
<FormField
control={form.control}
name="team_member_key_duration"
label={labelWithHint(
"Team Member Key Duration (eg: 1d, 1mo)",
"Set a limit to the duration of a team member's key. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days), 1mo (month)",
t("teams.page.create.fields.teamMemberKeyDuration.label"),
t("teams.page.create.fields.teamMemberKeyDuration.hint"),
)}
>
{({ ref, value, ...field }) => (
<UIInput {...field} ref={ref} value={value ?? ""} placeholder="e.g., 30d" />
<UIInput
{...field}
ref={ref}
value={value ?? ""}
placeholder={t("teams.page.create.fields.teamMemberKeyDuration.placeholder")}
/>
)}
</FormField>
<FormField
control={form.control}
name="team_member_rpm_limit"
label={labelWithHint(
"Team Member RPM Limit",
"The RPM (Requests Per Minute) limit for individual team members",
t("teams.page.create.fields.teamMemberRpmLimit.label"),
t("teams.page.create.fields.teamMemberRpmLimit.hint"),
)}
>
{({ ref, value, ...field }) => (
@ -954,8 +972,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
control={form.control}
name="team_member_tpm_limit"
label={labelWithHint(
"Team Member TPM Limit",
"The TPM (Tokens Per Minute) limit for individual team members",
t("teams.page.create.fields.teamMemberTpmLimit.label"),
t("teams.page.create.fields.teamMemberTpmLimit.hint"),
)}
>
{({ ref, value, ...field }) => (
@ -965,11 +983,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
<FormField
control={form.control}
name="secret_manager_settings"
label="Secret Manager Settings"
label={t("teams.page.create.fields.secretManagerSettings.label")}
description={
premiumUser
? "Enter secret manager configuration as a JSON object."
: "Premium feature - Upgrade to manage secret manager settings."
? t("teams.page.create.fields.secretManagerSettings.premiumDescription")
: t("teams.page.create.fields.secretManagerSettings.upgradeDescription")
}
>
{({ ref, value, ...field }) => (
@ -988,11 +1006,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="guardrails"
className="mt-8"
label={labelWithDocsHint(
"Guardrails",
"Setup your first guardrail",
t("teams.page.create.fields.guardrails.label"),
t("teams.page.create.fields.guardrails.hint"),
"https://docs.litellm.ai/docs/proxy/guardrails/quick_start",
)}
description="Select existing guardrails or enter new ones"
description={t("teams.page.create.fields.guardrails.description")}
>
{({ id, value, onChange }) => (
<TagsInput
@ -1000,7 +1018,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
value={value ?? []}
onValueChange={onChange}
options={guardrailsList.map((name) => ({ value: name, label: name }))}
placeholder="Select or enter guardrails"
placeholder={t("teams.page.create.fields.guardrails.placeholder")}
/>
)}
</FormField>
@ -1010,13 +1028,13 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="disable_global_guardrails"
className="mt-4"
label={labelWithHint(
"Disable Global Guardrails",
"When enabled, this team will bypass any guardrails configured to run on every request (global guardrails)",
t("teams.page.create.fields.disableGlobalGuardrails.label"),
t("teams.page.create.fields.disableGlobalGuardrails.hint"),
)}
description={
premiumUser
? "Bypass global guardrails for this team"
: "Premium feature - Upgrade to disable global guardrails by team"
? t("teams.page.create.fields.disableGlobalGuardrails.premiumDescription")
: t("teams.page.create.fields.disableGlobalGuardrails.upgradeDescription")
}
>
{({ id, value, onChange }) => (
@ -1035,8 +1053,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="require_trace_id"
className="mt-4"
label={labelWithHint(
"Require Trace ID",
"Reject LLM, MCP and agent requests from this team that do not send a trace ID (x-litellm-trace-id header, LLM requests can also use traceparent or metadata.trace_id)",
t("teams.page.create.fields.requireTraceId.label"),
t("teams.page.create.fields.requireTraceId.hint"),
)}
>
{({ id, value, onChange }) => (
@ -1050,11 +1068,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="policies"
className="mt-8"
label={labelWithDocsHint(
"Policies",
"Apply policies to this team to control guardrails and other settings",
t("teams.page.create.fields.policies.label"),
t("teams.page.create.fields.policies.hint"),
"https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies",
)}
description="Select existing policies or enter new ones"
description={t("teams.page.create.fields.policies.description")}
>
{({ id, value, onChange }) => (
<TagsInput
@ -1062,7 +1080,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
value={value ?? []}
onValueChange={onChange}
options={policiesList.map((name) => ({ value: name, label: name }))}
placeholder="Select or enter policies"
placeholder={t("teams.page.create.fields.policies.placeholder")}
/>
)}
</FormField>
@ -1072,16 +1090,16 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="access_group_ids"
className="mt-8"
label={labelWithHint(
"Access Groups",
"Assign access groups to this team. Access groups control which models, MCP servers, and agents this team can use",
t("teams.page.create.fields.accessGroups.label"),
t("teams.page.create.fields.accessGroups.hint"),
)}
description="Select access groups to assign to this team"
description={t("teams.page.create.fields.accessGroups.description")}
>
{({ value, onChange }) => (
<AccessGroupSelector
value={value}
onChange={onChange}
placeholder="Select access groups (optional)"
placeholder={t("teams.page.create.fields.accessGroups.placeholder")}
/>
)}
</FormField>
@ -1090,17 +1108,17 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="allowed_vector_store_ids"
className="mt-8"
label={labelWithHint(
"Allowed Vector Stores",
"Select which vector stores this team can access by default. Leave empty for access to all vector stores",
t("teams.page.create.fields.vectorStores.label"),
t("teams.page.create.fields.vectorStores.hint"),
)}
description="Select vector stores this team can access. Leave empty for access to all vector stores"
description={t("teams.page.create.fields.vectorStores.description")}
>
{({ value, onChange }) => (
<VectorStoreSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select vector stores (optional)"
placeholder={t("teams.page.create.fields.vectorStores.placeholder")}
/>
)}
</FormField>
@ -1111,15 +1129,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
label={
!premiumUser
? labelWithHint(
"Allowed Pass Through Routes",
"Premium feature - Upgrade to set allowed pass through routes",
t("teams.page.create.fields.passThroughRoutes.label"),
t("teams.page.create.fields.passThroughRoutes.upgradeHint"),
)
: !isProxyAdminRole(userRole || "")
? labelWithHint(
"Allowed Pass Through Routes",
"Only proxy admins can set allowed pass through routes",
t("teams.page.create.fields.passThroughRoutes.label"),
t("teams.page.create.fields.passThroughRoutes.proxyAdminOnlyHint"),
)
: "Allowed Pass Through Routes"
: t("teams.page.create.fields.passThroughRoutes.label")
}
>
{({ value, onChange }) => (
@ -1127,7 +1145,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
value={value}
onChange={onChange}
accessToken={accessToken || ""}
placeholder="Select pass through routes (optional)"
placeholder={t("teams.page.create.fields.passThroughRoutes.placeholder")}
disabled={!premiumUser || !isProxyAdminRole(userRole || "")}
/>
)}
@ -1142,7 +1160,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
className="mt-8 mb-8 overflow-hidden rounded-lg border"
>
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>MCP Settings</b>
<b>{t("teams.page.create.sections.mcp")}</b>
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
@ -1151,17 +1169,17 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="allowed_mcp_servers_and_groups"
className="mt-4"
label={labelWithHint(
"Allowed MCP Servers",
"Select which MCP servers or access groups this team can access",
t("teams.page.create.fields.mcpServers.label"),
t("teams.page.create.fields.mcpServers.hint"),
)}
description="Select MCP servers or access groups this team can access"
description={t("teams.page.create.fields.mcpServers.description")}
>
{({ value, onChange }) => (
<MCPServerSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select MCP servers or access groups (optional)"
placeholder={t("teams.page.create.fields.mcpServers.placeholder")}
allowAllProxyMcpServers={isProxyAdminRole(userRole || "")}
/>
)}
@ -1186,7 +1204,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
className="mt-8 mb-8 overflow-hidden rounded-lg border"
>
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Agent Settings</b>
<b>{t("teams.page.create.sections.agent")}</b>
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
@ -1195,17 +1213,17 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="allowed_agents_and_groups"
className="mt-4"
label={labelWithHint(
"Allowed Agents",
"Select which agents or access groups this team can access",
t("teams.page.create.fields.agents.label"),
t("teams.page.create.fields.agents.hint"),
)}
description="Select agents or access groups this team can access"
description={t("teams.page.create.fields.agents.description")}
>
{({ value, onChange }) => (
<AgentSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select agents or access groups (optional)"
placeholder={t("teams.page.create.fields.agents.placeholder")}
/>
)}
</FormField>
@ -1218,7 +1236,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
className="mt-8 mb-8 overflow-hidden rounded-lg border"
>
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Search Tool Settings</b>
<b>{t("teams.page.create.sections.searchTool")}</b>
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
@ -1227,17 +1245,17 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="object_permission_search_tools"
className="mt-4"
label={labelWithHint(
"Allowed Search Tools",
"Select which search tools this team can access. Leave empty to allow all search tools.",
t("teams.page.create.fields.searchTools.label"),
t("teams.page.create.fields.searchTools.hint"),
)}
description="Restrict which configured search tools keys on this team may call."
description={t("teams.page.create.fields.searchTools.description")}
>
{({ value, onChange }) => (
<SearchToolSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select search tools (optional, empty = all allowed)"
placeholder={t("teams.page.create.fields.searchTools.placeholder")}
/>
)}
</FormField>
@ -1250,7 +1268,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
className="mt-8 mb-8 overflow-hidden rounded-lg border"
>
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Skill Settings</b>
<b>{t("teams.page.create.sections.skill")}</b>
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
@ -1259,17 +1277,17 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
name="object_permission_skills"
className="mt-4"
label={labelWithHint(
"Allowed Skills",
"Enabled skills are visible to every team. Grant disabled (private) Claude Code plugins to this team here.",
t("teams.page.create.fields.skills.label"),
t("teams.page.create.fields.skills.hint"),
)}
description="Private skills keys on this team may see in the Claude Code marketplace."
description={t("teams.page.create.fields.skills.description")}
>
{({ value, onChange }) => (
<SkillSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select skills (optional)"
placeholder={t("teams.page.create.fields.skills.placeholder")}
/>
)}
</FormField>
@ -1278,7 +1296,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Logging Settings</b>
<b>{t("teams.page.create.sections.logging")}</b>
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
@ -1297,7 +1315,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
className="mt-8 mb-8 overflow-hidden rounded-lg border"
>
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Router Settings</b>
<b>{t("teams.page.create.sections.router")}</b>
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
@ -1319,14 +1337,13 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
<Collapsible className="mt-8 mb-8 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Model Aliases</b>
<b>{t("teams.page.create.sections.modelAliases")}</b>
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<div className="mt-4">
<p className="mb-4 block text-sm text-muted-foreground">
Create custom aliases for models that can be used by team members in API calls. This allows
you to create shortcuts for specific models.
{t("teams.page.create.fields.modelAliasesDescription")}
</p>
<ModelAliasManager
accessToken={accessToken || ""}
@ -1340,7 +1357,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
</FieldGroup>
<div className="mt-[10px] text-right">
<UIButton type="submit" data-testid="create-team-submit">
Create Team
{t("teams.page.create.submit")}
</UIButton>
</div>
</form>

View file

@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { Meter, MeterIndicator, MeterTrack } from "@/components/shared/Meter";
import { getSpendBudgetMeterTone } from "@/components/shared/table_cells/spend_budget_cell";
import type { components } from "@/lib/http/schema";
@ -15,17 +17,19 @@ interface TeamMemberSpendBudgetCellProps {
}
interface BudgetLineProps {
label: "Team" | "My Spend";
label: string;
spend: number | null | undefined;
maxBudget: number | null | undefined;
resetAt?: string | null;
}
function BudgetLine({ label, spend, maxBudget, resetAt }: BudgetLineProps) {
const { t } = useTranslation();
const spendValue = typeof spend === "number" && Number.isFinite(spend) ? spend : 0;
const budget = typeof maxBudget === "number" && Number.isFinite(maxBudget) ? maxBudget : null;
const spendText = spendValue === 0 ? "$0.00" : getSpendString(spendValue, 2);
const budgetText = budget === null ? "Unlimited" : `$${formatNumberWithCommas(budget, 2)}`;
const budgetText =
budget === null ? t("teams.memberSpendBudgetCell.unlimited") : `$${formatNumberWithCommas(budget, 2)}`;
const resetText = formatBudgetResetUtc(resetAt);
return (
@ -38,13 +42,25 @@ function BudgetLine({ label, spend, maxBudget, resetAt }: BudgetLineProps) {
</span>
</div>
{budget !== null && budget > 0 && (
<Meter value={spendValue} max={budget} aria-valuetext={`${label} ${spendText} of ${budgetText}`}>
<Meter
value={spendValue}
max={budget}
aria-valuetext={t("teams.memberSpendBudgetCell.meterValueText", {
label,
spend: spendText,
budget: budgetText,
})}
>
<MeterTrack>
<MeterIndicator tone={getSpendBudgetMeterTone((spendValue / budget) * 100)} />
</MeterTrack>
</Meter>
)}
{resetText && <div className="text-[10px] text-muted-foreground">Resets {resetText} UTC</div>}
{resetText && (
<div className="text-[10px] text-muted-foreground">
{t("teams.memberSpendBudgetCell.resets", { date: resetText })}
</div>
)}
</div>
);
}
@ -54,12 +70,13 @@ export function TeamMemberSpendBudgetCell({
teamMaxBudget,
callerMembership,
}: TeamMemberSpendBudgetCellProps) {
const { t } = useTranslation();
return (
<div className="flex min-w-[130px] flex-col gap-2">
<BudgetLine label="Team" spend={teamSpend} maxBudget={teamMaxBudget} />
<BudgetLine label={t("teams.memberSpendBudgetCell.team")} spend={teamSpend} maxBudget={teamMaxBudget} />
{callerMembership && (
<BudgetLine
label="My Spend"
label={t("teams.memberSpendBudgetCell.mySpend")}
spend={callerMembership.spend}
maxBudget={callerMembership.max_budget}
resetAt={callerMembership.budget_reset_at}

View file

@ -15,8 +15,10 @@ import { Input } from "@/components/ui/input";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import type { ParseKeys } from "i18next";
import { Download } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Team } from "../key_team_helpers/key_list";
import { getTeamTableColumns, TEAM_TABLE_HIDDEN_COLUMNS } from "./teamTableColumns";
@ -38,13 +40,14 @@ const toSortOrder = (sorting: SortingState): "asc" | "desc" | undefined => {
return active.desc ? "desc" : "asc";
};
const FILTER_LABELS: Record<string, string> = {
org_id: "Organization",
alias: "Team alias",
team_id: "Team ID",
const FILTER_LABEL_KEYS: Record<string, ParseKeys> = {
org_id: "teams.table.filters.organization",
alias: "teams.table.filters.teamAlias",
team_id: "teams.table.filters.teamId",
};
export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDeleteTeam }: TeamsTableProps) {
const { t } = useTranslation();
const { data: fetchedOrganizations } = useOrganizations();
const organizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]);
@ -118,9 +121,14 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
}, [accessToken, isExporting, teamListOptions]);
const columns = useMemo(() => {
const columnDeps = { organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam };
const columnDeps = { organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam, t };
return getTeamTableColumns(columnDeps);
}, [organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam]);
}, [organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam, t]);
const filterLabels = useMemo(
() => Object.fromEntries(Object.entries(FILTER_LABEL_KEYS).map(([columnId, key]) => [columnId, t(key)])),
[t],
);
const orgOptions = useMemo(
() =>
@ -163,8 +171,8 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
enableColumnResizing
columnResizeMode="onChange"
isLoading={isPending || isPlaceholderData}
loadingMessage="Loading teams..."
noDataMessage="No teams found"
loadingMessage={t("teams.table.loading")}
noDataMessage={t("teams.table.empty")}
fillHeight
size="compact"
toolbar={(table) => (
@ -173,11 +181,11 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
table={table}
searchValue={searchInput}
onSearchChange={handleSearchChange}
searchPlaceholder="Search teams by name or ID…"
searchPlaceholder={t("teams.table.searchPlaceholder")}
onRefresh={() => refetch?.()}
isRefreshing={isFetching}
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={FILTER_LABELS}
filterLabels={filterLabels}
formatFilterValue={formatFilterValue}
>
<Button
@ -188,39 +196,39 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
data-testid="teams-export-csv"
>
<Download />
{isExporting ? "Exporting..." : "Export CSV"}
{isExporting ? t("teams.table.exporting") : t("teams.table.exportCsv")}
</Button>
</DataTableToolbar>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down your teams"
title={t("teams.table.filters.title")}
description={t("teams.table.filters.description")}
>
{({ get, set }) => (
<>
<DataTableFilterField label="Organization">
<DataTableFilterField label={t("teams.table.filters.organization")}>
<SearchSelect
options={orgOptions}
value={(get("org_id") as string) || undefined}
onValueChange={(value) => set("org_id", value ?? undefined)}
placeholder="Select an organization…"
emptyText="No organizations found"
placeholder={t("teams.table.filters.organizationPlaceholder")}
emptyText={t("teams.table.filters.organizationEmpty")}
/>
</DataTableFilterField>
<DataTableFilterField label="Team alias">
<DataTableFilterField label={t("teams.table.filters.teamAlias")}>
<Input
value={(get("alias") as string) ?? ""}
onChange={(event) => set("alias", event.target.value)}
placeholder="Enter team alias…"
placeholder={t("teams.table.filters.teamAliasPlaceholder")}
/>
</DataTableFilterField>
<DataTableFilterField label="Team ID">
<DataTableFilterField label={t("teams.table.filters.teamId")}>
<Input
value={(get("team_id") as string) ?? ""}
onChange={(event) => set("team_id", event.target.value)}
placeholder="Enter team ID…"
placeholder={t("teams.table.filters.teamIdPlaceholder")}
/>
</DataTableFilterField>
</>

View file

@ -1,7 +1,9 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { ParseKeys, TFunction } from "i18next";
import { Copy, KeyRound, Layers, MoreHorizontal, Pencil, Trash2, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell } from "@/components/shared/table_cells";
@ -41,11 +43,18 @@ const teamMemberCount = (team: Team): number => team.members_count ?? team.membe
const teamModelCount = (team: Team): number => team.models?.length ?? 0;
const teamKeyCount = (team: Team): number => team.keys_count ?? team.keys?.length ?? 0;
const RESOURCE_COUNT_KEYS: Record<"members" | "models" | "keys", ParseKeys> = {
members: "teams.table.resourceCounts.members",
models: "teams.table.resourceCounts.models",
keys: "teams.table.resourceCounts.keys",
};
function ResourcesCell({ team }: { team: Team }) {
const { t } = useTranslation();
const items = [
{ key: "members" as const, label: "members", count: teamMemberCount(team) },
{ key: "models" as const, label: "models", count: teamModelCount(team) },
{ key: "keys" as const, label: "keys", count: teamKeyCount(team) },
{ key: "members" as const, count: teamMemberCount(team) },
{ key: "models" as const, count: teamModelCount(team) },
{ key: "keys" as const, count: teamKeyCount(team) },
];
return (
@ -56,7 +65,7 @@ function ResourcesCell({ team }: { team: Team }) {
return (
<span
key={item.key}
title={`${item.count} ${item.label}`}
title={t(RESOURCE_COUNT_KEYS[item.key], { count: item.count })}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset [&_svg]:size-3.5",
tone.className,
@ -72,10 +81,11 @@ function ResourcesCell({ team }: { team: Team }) {
}
function RateLimitLine({ label, value }: { label: string; value: number | null }) {
const { t } = useTranslation();
return (
<div>
<span className="text-[10px] font-semibold text-muted-foreground">{label} </span>
<span className="tabular-nums">{value != null ? formatNumberWithCommas(value) : "Unlimited"}</span>
<span className="tabular-nums">{value != null ? formatNumberWithCommas(value) : t("teams.table.unlimited")}</span>
</div>
);
}
@ -88,14 +98,15 @@ interface TeamRowActionsProps {
}
function TeamRowActions({ team, canManage, onEditTeam, onDeleteTeam }: TeamRowActionsProps) {
const { t } = useTranslation();
const handleCopy = () => {
void copyToClipboard(team.team_id, "Team ID copied");
void copyToClipboard(team.team_id, t("teams.table.actions.copied"));
};
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open team actions"
aria-label={t("teams.table.actions.open")}
data-testid={`team-actions-${team.team_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -105,19 +116,19 @@ function TeamRowActions({ team, canManage, onEditTeam, onDeleteTeam }: TeamRowAc
{canManage && (
<DropdownMenuItem onClick={() => onEditTeam(team)} data-testid="team-action-edit">
<Pencil />
Edit team
{t("teams.table.actions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleCopy} data-testid="team-action-copy">
<Copy />
Copy team ID
{t("teams.table.actions.copyId")}
</DropdownMenuItem>
{canManage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDeleteTeam(team)} data-testid="team-action-delete">
<Trash2 />
Delete team
{t("teams.table.actions.delete")}
</DropdownMenuItem>
</>
)}
@ -132,6 +143,7 @@ interface TeamTableColumnsDeps {
onSelectTeam: (team: Team) => void;
onEditTeam: (team: Team) => void;
onDeleteTeam: (team: Team) => void;
t: TFunction;
}
export const getTeamTableColumns = ({
@ -140,6 +152,7 @@ export const getTeamTableColumns = ({
onSelectTeam,
onEditTeam,
onDeleteTeam,
t,
}: TeamTableColumnsDeps): ColumnDef<Team>[] => {
const canManage = userRole === "Admin";
@ -148,7 +161,7 @@ export const getTeamTableColumns = ({
id: "team_alias",
accessorKey: "team_alias",
meta: {
title: "Team",
title: t("teams.table.columns.team"),
renderSkeleton: () => (
<div className="flex flex-col gap-2 py-1">
<Skeleton className="h-4 w-32" />
@ -156,7 +169,9 @@ export const getTeamTableColumns = ({
</div>
),
},
header: ({ column }) => <DataTableSortHeader column={column} title="Team" variant="header-cycle" />,
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("teams.table.columns.team")} variant="header-cycle" />
),
size: 260,
enableSorting: true,
cell: ({ row }) => {
@ -174,8 +189,8 @@ export const getTeamTableColumns = ({
{
id: "organization_alias",
accessorKey: "organization_id",
meta: { title: "Organization" },
header: ({ column }) => <DataTableSortHeader column={column} title="Organization" />,
meta: { title: t("teams.table.columns.organization") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("teams.table.columns.organization")} />,
sortDescFirst: false,
size: 160,
enableSorting: true,
@ -195,7 +210,7 @@ export const getTeamTableColumns = ({
{
id: "resources",
meta: {
title: "Resources",
title: t("teams.table.columns.resources"),
renderSkeleton: () => (
<div className="flex items-center gap-1.5">
<Skeleton className="h-6 w-12 rounded-md" />
@ -208,9 +223,9 @@ export const getTeamTableColumns = ({
<DataTableMultiSortHeader
table={table}
fields={[
{ id: "members", label: "Members" },
{ id: "models", label: "Models" },
{ id: "keys_count", label: "Keys" },
{ id: "members", label: t("teams.table.columns.members") },
{ id: "models", label: t("teams.table.columns.models") },
{ id: "keys_count", label: t("teams.table.columns.keys") },
]}
/>
),
@ -221,13 +236,13 @@ export const getTeamTableColumns = ({
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend / Budget", skeleton: "meter", numeric: true },
meta: { title: t("teams.table.columns.spendBudget"), skeleton: "meter", numeric: true },
header: ({ table }) => (
<DataTableMultiSortHeader
table={table}
fields={[
{ id: "spend", label: "Spend" },
{ id: "max_budget", label: "Budget" },
{ id: "spend", label: t("teams.table.columns.spend") },
{ id: "max_budget", label: t("teams.table.columns.budget") },
]}
/>
),
@ -244,8 +259,10 @@ export const getTeamTableColumns = ({
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" variant="header-cycle" />,
meta: { title: t("teams.table.columns.created") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("teams.table.columns.created")} variant="header-cycle" />
),
size: 130,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
@ -253,8 +270,8 @@ export const getTeamTableColumns = ({
{
id: "members",
accessorFn: teamMemberCount,
meta: { title: "Members", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Members" />,
meta: { title: t("teams.table.columns.members"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("teams.table.columns.members")} />,
sortDescFirst: true,
size: 110,
enableSorting: true,
@ -263,8 +280,8 @@ export const getTeamTableColumns = ({
{
id: "models",
accessorFn: teamModelCount,
meta: { title: "Models", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Models" />,
meta: { title: t("teams.table.columns.models"), numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={t("teams.table.columns.models")} />,
sortDescFirst: true,
size: 100,
enableSorting: true,
@ -272,13 +289,13 @@ export const getTeamTableColumns = ({
},
{
id: "rate_limits",
meta: { title: "Rate Limits", skeleton: "twoLine" },
meta: { title: t("teams.table.columns.rateLimits"), skeleton: "twoLine" },
header: ({ table }) => (
<DataTableMultiSortHeader
table={table}
fields={[
{ id: "tpm_limit", label: "TPM" },
{ id: "rpm_limit", label: "RPM" },
{ id: "tpm_limit", label: t("teams.table.columns.tpm") },
{ id: "rpm_limit", label: t("teams.table.columns.rpm") },
]}
/>
),
@ -286,25 +303,27 @@ export const getTeamTableColumns = ({
enableSorting: false,
cell: ({ row }) => (
<div className="text-xs leading-tight">
<RateLimitLine label="TPM" value={row.original.tpm_limit} />
<RateLimitLine label="RPM" value={row.original.rpm_limit} />
<RateLimitLine label={t("teams.table.columns.tpm")} value={row.original.tpm_limit} />
<RateLimitLine label={t("teams.table.columns.rpm")} value={row.original.rpm_limit} />
</div>
),
},
{
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated" },
header: ({ column }) => <DataTableSortHeader column={column} title="Updated" />,
meta: { title: t("teams.table.columns.updated") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("teams.table.columns.updated")} />,
sortDescFirst: false,
size: 130,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
cell: (info) => (
<DateCell value={info.getValue() as string | null} precision="date" fallback={t("teams.table.never")} />
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
header: () => <span className="sr-only">{t("teams.table.columns.actions")}</span>,
size: 60,
enableSorting: false,
enableHiding: false,

View file

@ -17,6 +17,7 @@ import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/
import { CopyToClipboard } from "react-copy-to-clipboard";
import { toast } from "@/lib/toast";
import { downloadBlob } from "@/components/EntityUsageExport/utils";
import { Trans, useTranslation } from "react-i18next";
interface BulkCreateUsersProps {
accessToken: string;
@ -57,6 +58,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
possibleUIRoles,
onUsersCreated,
}) => {
const { t } = useTranslation();
const [isModalVisible, setIsModalVisible] = useState(false);
const [parsedData, setParsedData] = useState<UserData[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
@ -108,16 +110,14 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
// Check file type
if (file.type !== "text/csv" && !file.name.endsWith(".csv")) {
setFileError(`Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`);
toast.fromError("Invalid file type. Please upload a CSV file.");
setFileError(t("users.bulkCreate.errors.invalidFileType", { name: file.name }));
toast.fromError(t("users.bulkCreate.errors.invalidFileTypeToast"));
return;
}
// Check file size (limit to 5MB)
if (file.size > 5 * 1024 * 1024) {
setFileError(
`File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`,
);
setFileError(t("users.bulkCreate.errors.fileTooLarge", { size: (file.size / (1024 * 1024)).toFixed(1) }));
return;
}
@ -125,16 +125,14 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
complete: (results) => {
// Check if file is empty
if (!results.data || results.data.length === 0) {
setCsvStructureError("The CSV file appears to be empty. Please upload a file with data.");
setCsvStructureError(t("users.bulkCreate.errors.emptyFile"));
setParsedData([]);
return;
}
// Check if there's only header row
if (results.data.length === 1) {
setCsvStructureError(
"The CSV file only contains headers but no user data. Please add user data to your CSV.",
);
setCsvStructureError(t("users.bulkCreate.errors.headersOnly"));
setParsedData([]);
return;
}
@ -143,9 +141,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
// Check if headers exist
if (headers.length === 0 || (headers.length === 1 && headers[0] === "")) {
setCsvStructureError(
"The CSV file doesn't contain any column headers. Please make sure your CSV has headers.",
);
setCsvStructureError(t("users.bulkCreate.errors.noHeaders"));
setParsedData([]);
return;
}
@ -155,9 +151,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
// Check if all required columns are present
const missingColumns = requiredColumns.filter((col) => !headers.includes(col));
if (missingColumns.length > 0) {
setCsvStructureError(
`Your CSV is missing these required columns: ${missingColumns.join(", ")}. Please add these columns to your CSV file.`,
);
setCsvStructureError(t("users.bulkCreate.errors.missingColumns", { columns: missingColumns.join(", ") }));
setParsedData([]);
return;
}
@ -176,7 +170,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
return {
rowNumber: index + 2,
isValid: false,
error: `Row ${index + 2} has fewer columns than the header row. Please ensure all data is properly formatted.`,
error: t("users.bulkCreate.errors.fewerColumns", { row: index + 2 }),
user_email: "",
user_role: "",
} as UserData;
@ -199,36 +193,39 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
// Email validation
if (!user.user_email) {
errors.push("Email is required");
errors.push(t("users.bulkCreate.validation.emailRequired"));
} else if (!user.user_email.includes("@") || !user.user_email.includes(".")) {
errors.push("Invalid email format (must contain @ and domain)");
errors.push(t("users.bulkCreate.validation.emailInvalid"));
}
// Role validation
if (!user.user_role) {
errors.push("Role is required");
errors.push(t("users.bulkCreate.validation.roleRequired"));
} else {
// Validate user role
const validRoles = ["proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer"];
if (!validRoles.includes(user.user_role)) {
errors.push(`Invalid role "${user.user_role}". Must be one of: ${validRoles.join(", ")}`);
errors.push(
t("users.bulkCreate.validation.roleInvalid", {
role: user.user_role,
roles: validRoles.join(", "),
}),
);
}
}
// Budget validation
if (user.max_budget && user.max_budget.toString().trim() !== "") {
if (isNaN(parseFloat(user.max_budget.toString()))) {
errors.push(`Max budget "${user.max_budget}" must be a number`);
errors.push(t("users.bulkCreate.validation.budgetNotNumber", { budget: user.max_budget }));
} else if (parseFloat(user.max_budget.toString()) <= 0) {
errors.push("Max budget must be greater than 0");
errors.push(t("users.bulkCreate.validation.budgetNotPositive"));
}
}
// Budget duration validation
if (user.budget_duration && !user.budget_duration.match(/^\d+[dhmwy]$|^\d+mo$/)) {
errors.push(
`Invalid budget duration format "${user.budget_duration}". Use format like "30d", "1mo", "2w", "6h"`,
);
errors.push(t("users.bulkCreate.validation.durationInvalid", { duration: user.budget_duration }));
}
// Teams validation
@ -239,7 +236,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
const userTeams = user.teams.split(",").map((t) => t.trim());
const invalidTeams = userTeams.filter((t) => !teamIds.includes(t));
if (invalidTeams.length > 0) {
errors.push(`Unknown team(s): ${invalidTeams.join(", ")}`);
errors.push(t("users.bulkCreate.validation.unknownTeams", { teams: invalidTeams.join(", ") }));
}
}
}
@ -257,24 +254,27 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
setParsedData(userData);
if (userData.length === 0) {
setCsvStructureError("No valid data rows found in the CSV file. Please check your file format.");
setCsvStructureError(t("users.bulkCreate.errors.noDataRows"));
} else if (validData.length === 0) {
setParseError("No valid users found in the CSV. Please check the errors below and fix your CSV file.");
setParseError(t("users.bulkCreate.errors.noValidUsers"));
} else if (validData.length < userData.length) {
setParseError(
`Found ${userData.length - validData.length} row(s) with errors out of ${userData.length} total rows. Please correct them before proceeding.`,
t("users.bulkCreate.errors.rowsWithErrors", {
count: userData.length - validData.length,
total: userData.length,
}),
);
} else {
toast.success(`Successfully parsed ${validData.length} users`);
toast.success(t("users.bulkCreate.toasts.parsed", { count: validData.length }));
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setParseError(`Error parsing CSV: ${errorMessage}`);
const errorMessage = error instanceof Error ? error.message : t("users.bulkCreate.errors.unknown");
setParseError(t("users.bulkCreate.errors.parseFailed", { message: errorMessage }));
setParsedData([]);
}
},
error: (error) => {
setParseError(`Failed to parse CSV file: ${error.message}`);
setParseError(t("users.bulkCreate.errors.readFailed", { message: error.message }));
setParsedData([]);
},
header: false,
@ -428,14 +428,14 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
...u,
status: "success",
key: response.key || response.user_id,
error: "User created but failed to generate invitation link",
error: t("users.bulkCreate.errors.inviteLinkFailed"),
}
: u,
),
);
}
} else {
const errorMessage = response?.error || "Failed to create user";
const errorMessage = response?.error || t("users.bulkCreate.errors.createFailed");
setParsedData((current) =>
current.map((u, i) => (i === index ? { ...u, status: "failed", error: errorMessage } : u)),
);
@ -479,34 +479,38 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
window.URL.revokeObjectURL(url);
};
const onLinkCopied = () => toast.success(t("users.bulkCreate.toasts.linkCopied"));
const renderStatusCell = (record: UserData) => {
if (!record.isValid) {
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-destructive mr-2" />
<span className="text-destructive">Invalid</span>
<span className="text-destructive">{t("users.bulkCreate.status.invalid")}</span>
</div>
{record.error && <span className="text-sm text-destructive ml-7">{record.error}</span>}
</div>
);
}
if (!record.status || record.status === "pending") {
return <span className="text-muted-foreground">Pending</span>;
return <span className="text-muted-foreground">{t("users.bulkCreate.status.pending")}</span>;
}
if (record.status === "success") {
return (
<div>
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-success mr-2" />
<span className="text-success">Success</span>
<span className="text-success">{t("users.bulkCreate.status.success")}</span>
</div>
{record.invitation_link && (
<div className="mt-1">
<div className="flex items-center">
<span className="text-xs text-muted-foreground truncate max-w-[150px]">{record.invitation_link}</span>
<CopyToClipboard text={record.invitation_link} onCopy={() => toast.success("Invitation link copied!")}>
<button className="ml-1 text-info text-xs hover:text-info/80">Copy</button>
<CopyToClipboard text={record.invitation_link} onCopy={onLinkCopied}>
<button className="ml-1 text-info text-xs hover:text-info/80">
{t("users.bulkCreate.status.copy")}
</button>
</CopyToClipboard>
</div>
</div>
@ -518,7 +522,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-destructive mr-2" />
<span className="text-destructive">Failed</span>
<span className="text-destructive">{t("users.bulkCreate.status.failed")}</span>
</div>
{record.error && <span className="text-sm text-destructive ml-7">{JSON.stringify(record.error)}</span>}
</div>
@ -528,17 +532,23 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
const pageCount = Math.max(1, Math.ceil(parsedData.length / PREVIEW_PAGE_SIZE));
const currentPage = Math.min(pageIndex, pageCount - 1);
const visibleRows = parsedData.slice(currentPage * PREVIEW_PAGE_SIZE, (currentPage + 1) * PREVIEW_PAGE_SIZE);
const validCount = parsedData.filter((d) => d.isValid).length;
const successCount = parsedData.filter((d) => d.status === "success").length;
const failedCount = parsedData.filter((d) => d.status === "failed").length;
const createLabel = isProcessing
? t("users.bulkCreate.preview.creating")
: t("users.bulkCreate.preview.create", { count: validCount });
return (
<>
<Button className="mb-0" onClick={() => setIsModalVisible(true)}>
+ Bulk Invite Users
{t("users.bulkCreate.openButton")}
</Button>
<Dialog open={isModalVisible} onOpenChange={(open) => !open && setIsModalVisible(false)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Bulk Invite Users</DialogTitle>
<DialogTitle>{t("users.bulkCreate.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
{/* Step indicator */}
@ -548,45 +558,40 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div className="w-8 h-8 rounded-full bg-info text-info-foreground flex items-center justify-center mr-3">
1
</div>
<h3 className="text-lg font-medium">Download and fill the template</h3>
<h3 className="text-lg font-medium">{t("users.bulkCreate.steps.download")}</h3>
</div>
<div className="ml-11 mb-6">
<p className="mb-4">Add multiple users at once by following these steps:</p>
<p className="mb-4">{t("users.bulkCreate.instructions.intro")}</p>
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
<li>Download our CSV template</li>
<li>Add your users&apos; information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the Virtual Keys for each user</li>
<li>{t("users.bulkCreate.instructions.downloadTemplate")}</li>
<li>{t("users.bulkCreate.instructions.addUsers")}</li>
<li>{t("users.bulkCreate.instructions.saveAndUpload")}</li>
<li>{t("users.bulkCreate.instructions.downloadResults")}</li>
</ol>
<div className="bg-muted p-4 rounded-md border border-border mb-4">
<h4 className="font-medium mb-2">Template Column Names</h4>
<h4 className="font-medium mb-2">{t("users.bulkCreate.templateColumns.title")}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-destructive mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_email</p>
<p className="text-sm text-muted-foreground">User&apos;s email address (required)</p>
<p className="text-sm text-muted-foreground">{t("users.bulkCreate.templateColumns.email")}</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-destructive mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_role</p>
<p className="text-sm text-muted-foreground">
User&apos;s role (one of: &quot;proxy_admin&quot;, &quot;proxy_admin_viewer&quot;,
&quot;internal_user&quot;, &quot;internal_user_viewer&quot;)
</p>
<p className="text-sm text-muted-foreground">{t("users.bulkCreate.templateColumns.role")}</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-border mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">teams</p>
<p className="text-sm text-muted-foreground">
Comma-separated team IDs (e.g., &quot;team-1,team-2&quot;)
</p>
<p className="text-sm text-muted-foreground">{t("users.bulkCreate.templateColumns.teams")}</p>
</div>
</div>
<div className="flex items-start">
@ -594,7 +599,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div>
<p className="font-medium">max_budget</p>
<p className="text-sm text-muted-foreground">
Maximum budget as a number (e.g., &quot;100&quot;)
{t("users.bulkCreate.templateColumns.maxBudget")}
</p>
</div>
</div>
@ -603,7 +608,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div>
<p className="font-medium">budget_duration</p>
<p className="text-sm text-muted-foreground">
Budget reset period (e.g., &quot;30d&quot;, &quot;1mo&quot;)
{t("users.bulkCreate.templateColumns.budgetDuration")}
</p>
</div>
</div>
@ -612,7 +617,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div>
<p className="font-medium">models</p>
<p className="text-sm text-muted-foreground">
Comma-separated allowed models (e.g., &quot;gpt-3.5-turbo,gpt-4&quot;)
{t("users.bulkCreate.templateColumns.models")}
</p>
</div>
</div>
@ -621,7 +626,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<Button size="lg" className="w-full md:w-auto" onClick={downloadTemplate}>
<Download className="size-4" />
Download CSV Template
{t("users.bulkCreate.downloadTemplate")}
</Button>
</div>
@ -629,7 +634,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div className="w-8 h-8 rounded-full bg-info text-info-foreground flex items-center justify-center mr-3">
2
</div>
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
<h3 className="text-lg font-medium">{t("users.bulkCreate.steps.upload")}</h3>
</div>
<div className="ml-11">
@ -649,13 +654,16 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
{selectedFile.name}
</strong>
<span className={`block text-xs ${fileError ? "text-destructive" : "text-info"}`}>
{(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()}
{t("users.bulkCreate.upload.fileMeta", {
size: (selectedFile.size / 1024).toFixed(1),
date: new Date().toLocaleDateString(),
})}
</span>
</div>
</div>
<Button variant="outline" size="sm" onClick={removeSelectedFile} className="flex items-center">
<Trash2 className="size-4" />
Remove
{t("users.bulkCreate.upload.remove")}
</Button>
</div>
@ -670,7 +678,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div className="w-full bg-border rounded-full h-1.5">
<div className="bg-info h-1.5 rounded-full w-full animate-pulse"></div>
</div>
<span className="ml-2 text-xs text-info">Processing...</span>
<span className="ml-2 text-xs text-info">{t("users.bulkCreate.upload.processing")}</span>
</div>
)
)}
@ -694,10 +702,12 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
onChange={handleFileInputChange}
/>
<Upload className="size-[30px] text-muted-foreground mb-2" />
<p className="mb-1">Drag and drop your CSV file here</p>
<p className="text-sm text-muted-foreground mb-3">or</p>
<span className={buttonVariants({ variant: "outline", size: "sm" })}>Browse files</span>
<p className="text-xs text-muted-foreground mt-4">Only CSV files (.csv) are supported</p>
<p className="mb-1">{t("users.bulkCreate.upload.dropHere")}</p>
<p className="text-sm text-muted-foreground mb-3">{t("users.bulkCreate.upload.or")}</p>
<span className={buttonVariants({ variant: "outline", size: "sm" })}>
{t("users.bulkCreate.upload.browse")}
</span>
<p className="text-xs text-muted-foreground mt-4">{t("users.bulkCreate.upload.csvOnly")}</p>
</div>
</label>
)}
@ -707,11 +717,9 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div className="flex items-start">
<ExclamationIcon className="h-5 w-5 shrink-0 text-warning mr-2 mt-0.5" />
<div className="min-w-0">
<strong className="text-warning">CSV Structure Error</strong>
<strong className="text-warning">{t("users.bulkCreate.upload.structureErrorTitle")}</strong>
<p className="text-warning mt-1 mb-0 break-words">{csvStructureError}</p>
<p className="text-warning mt-2 mb-0">
Please download our template and ensure your CSV follows the required format.
</p>
<p className="text-warning mt-2 mb-0">{t("users.bulkCreate.upload.structureErrorHint")}</p>
</div>
</div>
</div>
@ -726,8 +734,8 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
</div>
<h3 className="text-lg font-medium">
{parsedData.some((user) => user.status === "success" || user.status === "failed")
? "User Creation Results"
: "Review and create users"}
? t("users.bulkCreate.steps.results")
: t("users.bulkCreate.steps.review")}
</h3>
</div>
@ -739,12 +747,9 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<p className="text-destructive font-medium break-words">{parseError}</p>
{parsedData.some((user) => !user.isValid) && (
<ul className="mt-2 list-disc list-inside text-destructive text-sm">
<li>Check the table below for specific errors in each row</li>
<li>
Common issues include invalid email formats, missing required fields, or incorrect role
values
</li>
<li>Fix these issues in your CSV file and upload again</li>
<li>{t("users.bulkCreate.preview.errorTips.checkTable")}</li>
<li>{t("users.bulkCreate.preview.errorTips.commonIssues")}</li>
<li>{t("users.bulkCreate.preview.errorTips.fixAndReupload")}</li>
</ul>
)}
</div>
@ -757,21 +762,21 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div className="flex items-center">
{parsedData.some((user) => user.status === "success" || user.status === "failed") ? (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">Creation Summary</p>
<p className="text-lg font-medium mr-3">{t("users.bulkCreate.preview.creationSummary")}</p>
<p className="text-sm bg-success/15 text-success px-2 py-1 rounded-sm mr-2">
{parsedData.filter((d) => d.status === "success").length} Successful
{t("users.bulkCreate.preview.successful", { count: successCount })}
</p>
{parsedData.some((d) => d.status === "failed") && (
<p className="text-sm bg-destructive/15 text-destructive px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.status === "failed").length} Failed
{t("users.bulkCreate.preview.failed", { count: failedCount })}
</p>
)}
</div>
) : (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">User Preview</p>
<p className="text-lg font-medium mr-3">{t("users.bulkCreate.preview.userPreview")}</p>
<p className="text-sm bg-info/15 text-info px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid
{t("users.bulkCreate.preview.validCount", { valid: validCount, count: parsedData.length })}
</p>
</div>
)}
@ -780,13 +785,13 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex space-x-3">
<Button variant="outline" onClick={resetParsedData}>
Back
{t("users.bulkCreate.preview.back")}
</Button>
<Button
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
{createLabel}
</Button>
</div>
)}
@ -799,11 +804,12 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<CheckCircleIcon className="h-5 w-5 text-info" />
</div>
<div>
<p className="font-medium text-info">User creation complete</p>
<p className="font-medium text-info">{t("users.bulkCreate.preview.completeTitle")}</p>
<p className="block text-sm text-info mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
through LiteLLM.
<Trans
i18nKey="users.bulkCreate.preview.nextStep"
components={{ emphasis: <span className="font-medium" /> }}
/>
</p>
</div>
</div>
@ -814,12 +820,14 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Row</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Teams</TableHead>
<TableHead className={NUMERIC_CELL_CLASS}>Budget</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-20">{t("users.bulkCreate.preview.table.row")}</TableHead>
<TableHead>{t("users.bulkCreate.preview.table.email")}</TableHead>
<TableHead>{t("users.bulkCreate.preview.table.role")}</TableHead>
<TableHead>{t("users.bulkCreate.preview.table.teams")}</TableHead>
<TableHead className={NUMERIC_CELL_CLASS}>
{t("users.bulkCreate.preview.table.budget")}
</TableHead>
<TableHead>{t("users.bulkCreate.preview.table.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -840,7 +848,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
{pageCount > 1 && (
<div className="flex items-center justify-end gap-3 mt-2">
<span className="text-sm text-muted-foreground">
Page {currentPage + 1} of {pageCount}
{t("users.bulkCreate.preview.page", { page: currentPage + 1, pageCount })}
</span>
<Button
variant="outline"
@ -848,7 +856,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
onClick={() => setPageIndex(currentPage - 1)}
disabled={currentPage === 0}
>
Previous
{t("users.bulkCreate.preview.previous")}
</Button>
<Button
variant="outline"
@ -856,7 +864,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
onClick={() => setPageIndex(currentPage + 1)}
disabled={currentPage >= pageCount - 1}
>
Next
{t("users.bulkCreate.preview.next")}
</Button>
</div>
)}
@ -864,13 +872,13 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={resetParsedData} className="mr-3">
Back
{t("users.bulkCreate.preview.back")}
</Button>
<Button
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
{createLabel}
</Button>
</div>
)}
@ -878,11 +886,11 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
{parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={resetParsedData} className="mr-3">
Start New Bulk Import
{t("users.bulkCreate.preview.startNew")}
</Button>
<Button onClick={downloadResults}>
<Download className="size-4" />
Download User Credentials
{t("users.bulkCreate.preview.downloadCredentials")}
</Button>
</div>
)}

View file

@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { Filter } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
interface FiltersButtonProps {
onClick: () => void;
@ -10,17 +11,13 @@ interface FiltersButtonProps {
label?: string;
}
export const FiltersButton: React.FC<FiltersButtonProps> = ({
onClick,
active,
hasActiveFilters,
label = "Filters",
}) => {
export const FiltersButton: React.FC<FiltersButtonProps> = ({ onClick, active, hasActiveFilters, label }) => {
const { t } = useTranslation();
return (
<span className="relative inline-flex">
<Button variant="outline" onClick={onClick} className={cn(active && "bg-muted")}>
<Filter className="size-4" />
{label}
{label ?? t("filters.button.label")}
</Button>
{hasActiveFilters && (
<sup aria-hidden="true" className="absolute -top-0.5 -right-0.5 size-1.5 rounded-full bg-primary" />

View file

@ -1,17 +1,19 @@
import { Button } from "@/components/ui/button";
import { RotateCcw } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
interface ResetFiltersButtonProps {
onClick: () => void;
label?: string;
}
export const ResetFiltersButton: React.FC<ResetFiltersButtonProps> = ({ onClick, label = "Reset Filters" }) => {
export const ResetFiltersButton: React.FC<ResetFiltersButtonProps> = ({ onClick, label }) => {
const { t } = useTranslation();
return (
<Button variant="outline" onClick={onClick}>
<RotateCcw className="size-4" />
{label}
{label ?? t("filters.reset.label")}
</Button>
);
};

View file

@ -1,6 +1,8 @@
import type { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { Crown, Info, User, UserPlus } from "lucide-react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Member } from "@/components/networking";
import {
@ -70,10 +72,11 @@ function RoleHeaderTitle({ title, tooltip }: { title: string; tooltip?: string }
}
function ResetSpendAction({ disabledReason, onClick }: { disabledReason: string | null; onClick: () => void }) {
const { t } = useTranslation();
return (
<TableIconActionButton
variant="Reset"
tooltipText="Reset spend"
tooltipText={t("memberTable.actions.resetSpend")}
disabled={disabledReason !== null}
disabledTooltipText={disabledReason ?? undefined}
dataTestId="reset-member-spend"
@ -85,6 +88,7 @@ function ResetSpendAction({ disabledReason, onClick }: { disabledReason: string
const ACTIONS_COLUMN_WIDTH = 120;
interface MemberColumnDeps {
t: TFunction;
canEdit: boolean;
onEdit: (member: Member) => void;
onDelete: (member: Member) => void;
@ -121,6 +125,7 @@ const extraColumnDef = (column: MemberTableColumn): ColumnDef<Member> => {
};
const buildColumns = ({
t,
canEdit,
onEdit,
onDelete,
@ -134,32 +139,32 @@ const buildColumns = ({
{
id: "user_alias",
accessorFn: (member) => member.user_alias || undefined,
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
header: ({ column }) => <DataTableSortHeader column={column} title={t("memberTable.columns.name")} />,
sortingFn: "text",
sortUndefined: "last",
enableGlobalFilter: true,
meta: { title: "Name" },
meta: { title: t("memberTable.columns.name") },
cell: ({ row }) => row.original.user_alias || <span className="text-muted-foreground">-</span>,
},
{
id: "user_email",
accessorFn: (member) => member.user_email || undefined,
header: ({ column }) => <DataTableSortHeader column={column} title="User Email" />,
header: ({ column }) => <DataTableSortHeader column={column} title={t("memberTable.columns.userEmail")} />,
sortingFn: "text",
sortUndefined: "last",
enableGlobalFilter: true,
meta: { title: "User Email" },
meta: { title: t("memberTable.columns.userEmail") },
cell: ({ row }) => row.original.user_email || "-",
},
{
id: "user_id",
accessorFn: (member) => member.user_id ?? undefined,
header: "User ID",
header: t("memberTable.columns.userId"),
enableSorting: false,
enableGlobalFilter: true,
cell: ({ row }) =>
row.original.user_id === "default_user_id" ? (
<StatusBadge tone="info" label="Default Proxy Admin" />
<StatusBadge tone="info" label={t("memberTable.defaultProxyAdmin")} />
) : (
row.original.user_id || "-"
),
@ -184,7 +189,7 @@ const buildColumns = ({
...extraColumns.map(extraColumnDef),
{
id: "actions",
header: "Actions",
header: t("memberTable.columns.actions"),
size: ACTIONS_COLUMN_WIDTH,
enableSorting: false,
enableGlobalFilter: false,
@ -194,7 +199,7 @@ const buildColumns = ({
<span className="inline-flex items-center gap-2">
<TableIconActionButton
variant="Edit"
tooltipText="Edit member"
tooltipText={t("memberTable.actions.edit")}
dataTestId="edit-member"
onClick={() => onEdit(row.original)}
/>
@ -207,7 +212,7 @@ const buildColumns = ({
{(!showDeleteForMember || showDeleteForMember(row.original)) && (
<TableIconActionButton
variant="Delete"
tooltipText="Delete member"
tooltipText={t("memberTable.actions.delete")}
dataTestId="delete-member"
onClick={() => onDelete(row.original)}
/>
@ -223,7 +228,7 @@ export default function MemberTable({
onEdit,
onDelete,
onAddMember,
roleColumnTitle = "Role",
roleColumnTitle,
roleTooltip,
extraColumns = [],
showDeleteForMember,
@ -231,6 +236,8 @@ export default function MemberTable({
resetSpendDisabledReason,
emptyText,
}: MemberTableProps) {
const { t } = useTranslation();
const roleTitle = roleColumnTitle ?? t("memberTable.columns.role");
const [globalFilter, setGlobalFilter] = useState("");
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
@ -249,10 +256,11 @@ export default function MemberTable({
const changeSearch = backToFirstPage(setGlobalFilter);
const columnDeps: MemberColumnDeps = {
t,
canEdit,
onEdit,
onDelete,
roleColumnTitle,
roleColumnTitle: roleTitle,
roleTooltip,
extraColumns,
showDeleteForMember,
@ -261,7 +269,7 @@ export default function MemberTable({
};
const columns = buildColumns(columnDeps);
const roleFilterItems = [
{ value: ALL_ROLES, label: "All Roles" },
{ value: ALL_ROLES, label: t("memberTable.filters.allRoles") },
...memberRoleOptions(members).map((role) => ({ value: role, label: role })),
];
@ -270,7 +278,7 @@ export default function MemberTable({
return (
<div className="flex w-full flex-col gap-2">
<span className="inline-flex text-sm text-foreground">
{members.length} Member{members.length !== 1 ? "s" : ""}
{t("memberTable.memberCount", { count: members.length })}
</span>
<DataTable
data={members}
@ -289,7 +297,7 @@ export default function MemberTable({
onGlobalFilterChange={changeSearch}
noDataMessage={
<span className="text-muted-foreground">
{isNarrowed ? "No members match your search or filters" : emptyText ?? "No data"}
{isNarrowed ? t("memberTable.noMatches") : emptyText ?? t("memberTable.noData")}
</span>
}
toolbar={(table) => (
@ -298,7 +306,7 @@ export default function MemberTable({
table={table}
searchValue={globalFilter}
onSearchChange={changeSearch}
searchPlaceholder="Search by name, email, or user ID"
searchPlaceholder={t("memberTable.searchPlaceholder")}
onOpenFilters={() => setFiltersOpen(true)}
showViewOptions={false}
/>
@ -306,18 +314,18 @@ export default function MemberTable({
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down members"
title={t("memberTable.filters.title")}
description={t("memberTable.filters.description")}
>
{({ get, set }) => (
<DataTableFilterField label={roleColumnTitle}>
<DataTableFilterField label={roleTitle}>
<Select
items={roleFilterItems}
value={(get("role") as string | undefined) ?? ALL_ROLES}
onValueChange={(value) => set("role", value === ALL_ROLES ? undefined : value)}
>
<SelectTrigger className="w-full" data-testid="filter-role">
<SelectValue placeholder="All Roles" />
<SelectValue placeholder={t("memberTable.filters.allRoles")} />
</SelectTrigger>
<SelectContent>
{roleFilterItems.map((item) => (
@ -336,7 +344,7 @@ export default function MemberTable({
{onAddMember && canEdit && (
<Button onClick={onAddMember} className="self-start">
<UserPlus className="size-4" />
Add Member
{t("memberTable.addMember")}
</Button>
)}
</div>

View file

@ -1,5 +1,7 @@
import { useRef, useState } from "react";
import type { TFunction } from "i18next";
import { Info, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Alert, AlertTitle } from "@/components/shared/Alert";
import { useForm } from "react-hook-form";
import { userFilterUICall } from "@/components/networking";
@ -47,23 +49,23 @@ interface UserSearchModalProps {
teamId?: string;
}
const defaultRoles = (t: TFunction): Role[] => [
{ label: "admin", value: "admin", description: t("userSearchModal.defaultRoles.adminDescription") },
{ label: "user", value: "user", description: t("userSearchModal.defaultRoles.userDescription") },
];
const UserSearchModal: React.FC<UserSearchModalProps> = ({
isVisible,
onCancel,
onSubmit,
accessToken,
title = "Add Team Member",
roles = [
{
label: "admin",
value: "admin",
description: "Admin role. Can create team keys, add members, and manage settings.",
},
{ label: "user", value: "user", description: "User role. Can view team info, but not manage it." },
],
title,
roles,
defaultRole = "user",
teamId,
}) => {
const { t } = useTranslation();
const roleOptions = roles ?? defaultRoles(t);
const emptyValues: FormValues = { user_email: undefined, user_id: undefined, role: defaultRole };
const form = useForm<FormValues>({ defaultValues: emptyValues });
const selectedUserId = form.watch("user_id");
@ -172,8 +174,8 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
autoHighlight="always"
isLoading={loading}
placeholder={placeholder}
emptyText="No results"
loadingText="Loading..."
emptyText={t("userSearchModal.search.noResults")}
loadingText={t("userSearchModal.search.loading")}
inputId={controlProps.id}
/>
</div>
@ -184,39 +186,43 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
<Dialog open={isVisible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal={isSubmitting}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogTitle>{title ?? t("userSearchModal.defaultTitle")}</DialogTitle>
</DialogHeader>
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate>
<Alert variant="info" className="mb-4" data-testid="member-existing-users-notice">
<Info />
<AlertTitle>
Search selects from users that already exist. To add someone new, ask a proxy admin to create their
account first.
</AlertTitle>
<AlertTitle>{t("userSearchModal.existingUsersNotice")}</AlertTitle>
</Alert>
<FieldGroup>
<FormField control={form.control} name="user_email" label="Email">
<FormField control={form.control} name="user_email" label={t("userSearchModal.fields.email")}>
{({ id, value, onChange }) =>
renderUserSearch("user_email", "Search by email", { id, value, onChange }, "member-email-search")
renderUserSearch(
"user_email",
t("userSearchModal.fields.emailPlaceholder"),
{ id, value, onChange },
"member-email-search",
)
}
</FormField>
<div className="text-center">OR</div>
<div className="text-center">{t("userSearchModal.fields.or")}</div>
<FormField control={form.control} name="user_id" label="User ID">
{({ id, value, onChange }) => renderUserSearch("user_id", "Search by user ID", { id, value, onChange })}
<FormField control={form.control} name="user_id" label={t("userSearchModal.fields.userId")}>
{({ id, value, onChange }) =>
renderUserSearch("user_id", t("userSearchModal.fields.userIdPlaceholder"), { id, value, onChange })
}
</FormField>
<FormField control={form.control} name="role" label="Member Role">
<FormField control={form.control} name="role" label={t("userSearchModal.fields.role")}>
{({ id, value, onChange }) => (
<Select items={roles} value={value} onValueChange={(next) => onChange(next as string)}>
<Select items={roleOptions} value={value} onValueChange={(next) => onChange(next as string)}>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
{roleOptions.map((role) => (
<SelectItem key={role.value} value={role.value}>
<Tooltip>
<TooltipTrigger
@ -240,7 +246,7 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
<div className="mt-4 text-right">
<Button type="submit" disabled={isSubmitting || (!selectedUserId && !selectedUserEmail)}>
{isSubmitting ? <UiLoadingSpinner className="size-4" /> : <UserPlus />}
{isSubmitting ? "Adding..." : "Add Member"}
{isSubmitting ? t("userSearchModal.submit.adding") : t("userSearchModal.submit.add")}
</Button>
</div>
</form>

View file

@ -2,6 +2,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
@ -18,8 +19,8 @@ import VectorStoreSelector from "@/components/vector_store_management/VectorStor
import { useZodForm } from "@/lib/forms/useZodForm";
import { fetchClient } from "@/lib/http/api";
import { BUDGET_DURATION_OPTIONS, NO_RESET } from "../org-settings/OrgSettingsForm";
import { orgSettingsSchema } from "../org-settings/schema";
import { budgetDurationItems, NO_RESET } from "../org-settings/OrgSettingsForm";
import { buildOrgSettingsSchema } from "../org-settings/schema";
import { buildOrgCreateBody, emptyOrgFormValues, type OrgCreateBody } from "./mapper";
const defaultCreateOrganization = async (body: OrgCreateBody): Promise<unknown> => {
@ -40,8 +41,11 @@ export const OrgCreateDialog = ({
accessToken,
createOrganization = defaultCreateOrganization,
}: OrgCreateDialogProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const form = useZodForm(orgSettingsSchema, { defaultValues: emptyOrgFormValues });
const schema = React.useMemo(() => buildOrgSettingsSchema(t), [t]);
const form = useZodForm(schema, { defaultValues: emptyOrgFormValues });
const budgetDurationOptions = budgetDurationItems(t);
const closeAndReset = () => {
form.reset(emptyOrgFormValues);
@ -51,12 +55,12 @@ export const OrgCreateDialog = ({
const mutation = useMutation({
mutationFn: (body: OrgCreateBody) => createOrganization(body),
onSuccess: () => {
toast.success("Organization created successfully");
toast.success(t("organizations.createDialog.toasts.created"));
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
closeAndReset();
},
onError: (error: unknown) =>
toast.fromError(error instanceof Error ? error.message : "Failed to create organization"),
toast.fromError(error instanceof Error ? error.message : t("organizations.createDialog.toasts.createFailed")),
});
const handleOpenChange = (nextOpen: boolean) => {
@ -76,16 +80,20 @@ export const OrgCreateDialog = ({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Organization</DialogTitle>
<DialogTitle>{t("organizations.createDialog.title")}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} noValidate>
<FieldGroup>
<FormField control={form.control} name="organization_alias" label="Organization Name">
<FormField
control={form.control}
name="organization_alias"
label={t("organizations.createDialog.fields.name")}
>
{({ ref, ...field }) => <Input {...field} ref={ref} />}
</FormField>
<FormField control={form.control} name="models" label="Models">
<FormField control={form.control} name="models" label={t("organizations.createDialog.fields.models")}>
{(field) => (
<ModelSelect
value={field.value}
@ -96,14 +104,22 @@ export const OrgCreateDialog = ({
)}
</FormField>
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
<FormField
control={form.control}
name="max_budget"
label={t("organizations.createDialog.fields.maxBudget")}
>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step="any" min={0} />}
</FormField>
<FormField control={form.control} name="budget_duration" label="Reset Budget">
<FormField
control={form.control}
name="budget_duration"
label={t("organizations.createDialog.fields.budgetDuration")}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={BUDGET_DURATION_OPTIONS}
items={budgetDurationOptions}
value={value === "" ? NO_RESET : value}
onValueChange={(selected) => onChange(selected === NO_RESET ? "" : selected)}
>
@ -111,7 +127,7 @@ export const OrgCreateDialog = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{BUDGET_DURATION_OPTIONS.map((option) => (
{budgetDurationOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@ -121,26 +137,26 @@ export const OrgCreateDialog = ({
)}
</FormField>
<FormField control={form.control} name="tpm_limit" label="Tokens per minute Limit (TPM)">
<FormField control={form.control} name="tpm_limit" label={t("organizations.createDialog.fields.tpmLimit")}>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
</FormField>
<FormField control={form.control} name="rpm_limit" label="Requests per minute Limit (RPM)">
<FormField control={form.control} name="rpm_limit" label={t("organizations.createDialog.fields.rpmLimit")}>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
</FormField>
<FormField
control={form.control}
name="vector_stores"
label="Allowed Vector Stores"
description="Select vector stores this organization can access. Leave empty for access to all vector stores"
label={t("organizations.createDialog.fields.vectorStores")}
description={t("organizations.createDialog.fields.vectorStoresDescription")}
>
{(field) => (
<VectorStoreSelector
value={field.value}
onChange={field.onChange}
accessToken={accessToken}
placeholder="Select vector stores (optional)"
placeholder={t("organizations.createDialog.fields.vectorStoresPlaceholder")}
/>
)}
</FormField>
@ -148,20 +164,20 @@ export const OrgCreateDialog = ({
<FormField
control={form.control}
name="mcp"
label="Allowed MCP Servers"
description="Select MCP servers, access groups, and toolsets this organization can access. Leave empty for access to all"
label={t("organizations.createDialog.fields.mcp")}
description={t("organizations.createDialog.fields.mcpDescription")}
>
{(field) => (
<MCPServerSelector
value={field.value}
onChange={field.onChange}
accessToken={accessToken}
placeholder="Select MCP servers and access groups (optional)"
placeholder={t("organizations.createDialog.fields.mcpPlaceholder")}
/>
)}
</FormField>
<FormField control={form.control} name="metadata" label="Metadata">
<FormField control={form.control} name="metadata" label={t("organizations.createDialog.fields.metadata")}>
{({ ref, ...field }) => <Textarea {...field} ref={ref} rows={4} />}
</FormField>
</FieldGroup>
@ -173,10 +189,10 @@ export const OrgCreateDialog = ({
onClick={() => handleOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
{t("organizations.createDialog.cancel")}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create Organization"}
{mutation.isPending ? t("organizations.createDialog.creating") : t("organizations.createDialog.submit")}
</Button>
</DialogFooter>
</form>

View file

@ -1,7 +1,9 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ParseKeys, TFunction } from "i18next";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
@ -20,16 +22,19 @@ import { useZodForm } from "@/lib/forms/useZodForm";
import { fetchClient } from "@/lib/http/api";
import { buildOrgPatch, orgToForm, type OrgPatchBody } from "./mapper";
import { orgSettingsSchema } from "./schema";
import { buildOrgSettingsSchema } from "./schema";
export const NO_RESET = "never";
export const BUDGET_DURATION_OPTIONS = [
{ value: NO_RESET, label: "No reset" },
{ value: "24h", label: "daily" },
{ value: "7d", label: "weekly" },
{ value: "30d", label: "monthly" },
] as const;
const BUDGET_DURATION_OPTIONS: readonly { value: string; labelKey: ParseKeys }[] = [
{ value: NO_RESET, labelKey: "organizations.settingsForm.budgetDuration.noReset" },
{ value: "24h", labelKey: "organizations.settingsForm.budgetDuration.daily" },
{ value: "7d", labelKey: "organizations.settingsForm.budgetDuration.weekly" },
{ value: "30d", labelKey: "organizations.settingsForm.budgetDuration.monthly" },
];
export const budgetDurationItems = (t: TFunction): { value: string; label: string }[] =>
BUDGET_DURATION_OPTIONS.map((option) => ({ value: option.value, label: t(option.labelKey) }));
const defaultPatchOrganization = async (organizationId: string, body: OrgPatchBody): Promise<unknown> => {
const { data } = await fetchClient.PATCH("/v2/organization/{organization_id}", {
@ -56,19 +61,22 @@ export const OrgSettingsForm = ({
onSaved,
patchOrganization = defaultPatchOrganization,
}: OrgSettingsFormProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const form = useZodForm(orgSettingsSchema, { defaultValues: orgToForm(org) });
const schema = React.useMemo(() => buildOrgSettingsSchema(t), [t]);
const form = useZodForm(schema, { defaultValues: orgToForm(org) });
const budgetDurationOptions = budgetDurationItems(t);
const { isDirty } = form.formState;
const mutation = useMutation({
mutationFn: (body: OrgPatchBody) => patchOrganization(organizationId, body),
onSuccess: () => {
toast.success("Organization settings updated successfully");
toast.success(t("organizations.settingsForm.toasts.updated"));
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
onSaved();
},
onError: (error: unknown) =>
toast.fromError(error instanceof Error ? error.message : "Failed to update organization settings"),
toast.fromError(error instanceof Error ? error.message : t("organizations.settingsForm.toasts.updateFailed")),
});
const onSubmit = form.handleSubmit((values) => {
@ -78,11 +86,11 @@ export const OrgSettingsForm = ({
return (
<form onSubmit={onSubmit} noValidate>
<FieldGroup>
<FormField control={form.control} name="organization_alias" label="Organization Name">
<FormField control={form.control} name="organization_alias" label={t("organizations.settingsForm.fields.name")}>
{({ ref, ...field }) => <Input {...field} ref={ref} />}
</FormField>
<FormField control={form.control} name="models" label="Models">
<FormField control={form.control} name="models" label={t("organizations.settingsForm.fields.models")}>
{(field) => (
<ModelSelect
value={field.value}
@ -93,14 +101,18 @@ export const OrgSettingsForm = ({
)}
</FormField>
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
<FormField control={form.control} name="max_budget" label={t("organizations.settingsForm.fields.maxBudget")}>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step="any" min={0} />}
</FormField>
<FormField control={form.control} name="budget_duration" label="Reset Budget">
<FormField
control={form.control}
name="budget_duration"
label={t("organizations.settingsForm.fields.budgetDuration")}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={BUDGET_DURATION_OPTIONS}
items={budgetDurationOptions}
value={value === "" ? NO_RESET : value}
onValueChange={(selected) => onChange(selected === NO_RESET ? "" : selected)}
>
@ -108,7 +120,7 @@ export const OrgSettingsForm = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{BUDGET_DURATION_OPTIONS.map((option) => (
{budgetDurationOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@ -118,37 +130,41 @@ export const OrgSettingsForm = ({
)}
</FormField>
<FormField control={form.control} name="tpm_limit" label="Tokens per minute Limit (TPM)">
<FormField control={form.control} name="tpm_limit" label={t("organizations.settingsForm.fields.tpmLimit")}>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
</FormField>
<FormField control={form.control} name="rpm_limit" label="Requests per minute Limit (RPM)">
<FormField control={form.control} name="rpm_limit" label={t("organizations.settingsForm.fields.rpmLimit")}>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
</FormField>
<FormField control={form.control} name="vector_stores" label="Vector Stores">
<FormField
control={form.control}
name="vector_stores"
label={t("organizations.settingsForm.fields.vectorStores")}
>
{(field) => (
<VectorStoreSelector
value={field.value}
onChange={field.onChange}
accessToken={accessToken}
placeholder="Select vector stores"
placeholder={t("organizations.settingsForm.fields.vectorStoresPlaceholder")}
/>
)}
</FormField>
<FormField control={form.control} name="mcp" label="MCP Servers & Access Groups">
<FormField control={form.control} name="mcp" label={t("organizations.settingsForm.fields.mcp")}>
{(field) => (
<MCPServerSelector
value={field.value}
onChange={field.onChange}
accessToken={accessToken}
placeholder="Select MCP servers and access groups"
placeholder={t("organizations.settingsForm.fields.mcpPlaceholder")}
/>
)}
</FormField>
<FormField control={form.control} name="metadata" label="Metadata">
<FormField control={form.control} name="metadata" label={t("organizations.settingsForm.fields.metadata")}>
{({ ref, ...field }) => <Textarea {...field} ref={ref} rows={4} />}
</FormField>
</FieldGroup>
@ -156,10 +172,10 @@ export const OrgSettingsForm = ({
<div className="sticky z-chrome bg-card p-4 border-t border-border -bottom-6 -inset-x-6 mt-6">
<div className="flex justify-end items-center gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={mutation.isPending}>
Cancel
{t("organizations.settingsForm.cancel")}
</Button>
<Button type="submit" disabled={!isDirty || mutation.isPending}>
{mutation.isPending ? "Saving..." : "Save Changes"}
{mutation.isPending ? t("organizations.settingsForm.saving") : t("organizations.settingsForm.save")}
</Button>
</div>
</div>

View file

@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import { z } from "zod";
const isBlank = (value: string): boolean => value.trim() === "";
@ -11,33 +12,40 @@ const isJsonObject = (value: string): boolean => {
}
};
const wholeNumberOrEmpty = z
.string()
.refine((value) => isBlank(value) || /^\d+$/.test(value.trim()), "Must be a non-negative whole number");
const wholeNumberOrEmpty = (t: TFunction) =>
z
.string()
.refine(
(value) => isBlank(value) || /^\d+$/.test(value.trim()),
t("organizations.settingsForm.errors.wholeNumber"),
);
const amountOrEmpty = z
.string()
.refine(
(value) => isBlank(value) || (Number.isFinite(Number(value)) && Number(value) >= 0),
"Must be a non-negative number",
);
const amountOrEmpty = (t: TFunction) =>
z
.string()
.refine(
(value) => isBlank(value) || (Number.isFinite(Number(value)) && Number(value) >= 0),
t("organizations.settingsForm.errors.nonNegativeNumber"),
);
const orgSettingsShape = {
organization_alias: z.string().min(1, "Please input an organization name"),
const buildOrgSettingsShape = (t: TFunction) => ({
organization_alias: z.string().min(1, t("organizations.settingsForm.errors.nameRequired")),
models: z.array(z.string()),
max_budget: amountOrEmpty,
max_budget: amountOrEmpty(t),
budget_duration: z.string(),
tpm_limit: wholeNumberOrEmpty,
rpm_limit: wholeNumberOrEmpty,
tpm_limit: wholeNumberOrEmpty(t),
rpm_limit: wholeNumberOrEmpty(t),
vector_stores: z.array(z.string()),
mcp: z.object({
servers: z.array(z.string()),
accessGroups: z.array(z.string()),
toolsets: z.array(z.string()),
}),
metadata: z.string().refine((value) => isBlank(value) || isJsonObject(value), "Metadata must be a valid JSON object"),
};
metadata: z
.string()
.refine((value) => isBlank(value) || isJsonObject(value), t("organizations.settingsForm.errors.invalidMetadata")),
});
export const orgSettingsSchema = z.object(orgSettingsShape);
export const buildOrgSettingsSchema = (t: TFunction) => z.object(buildOrgSettingsShape(t));
export type OrgSettingsFormValues = z.output<typeof orgSettingsSchema>;
export type OrgSettingsFormValues = z.output<ReturnType<typeof buildOrgSettingsSchema>>;

View file

@ -14,6 +14,7 @@ import { createTeamAliasMap } from "@/utils/teamUtils";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { ArrowLeft } from "lucide-react";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import MemberTable, { type MemberTableColumn } from "../common_components/MemberTable";
import UserSearchModal from "../common_components/user_search_modal";
import { toast } from "@/lib/toast";
@ -46,6 +47,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
is_proxy_admin,
userModels,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: orgData, isLoading: loading } = useOrganization(organizationId);
const [isEditing, setIsEditing] = useState(false);
@ -77,11 +79,11 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
};
await organizationMemberAddCall(accessToken, organizationId, member);
toast.success("Organization member added successfully");
toast.success(t("organizations.view.toasts.memberAdded"));
setIsAddMemberModalVisible(false);
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
} catch (error) {
toast.fromError("Failed to add organization member");
toast.fromError(t("organizations.view.toasts.memberAddFailed"));
console.error("Error adding organization member:", error);
}
};
@ -97,11 +99,11 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
};
await organizationMemberUpdateCall(accessToken, organizationId, member);
toast.success("Organization member updated successfully");
toast.success(t("organizations.view.toasts.memberUpdated"));
setIsEditMemberModalVisible(false);
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
} catch (error) {
toast.fromError("Failed to update organization member");
toast.fromError(t("organizations.view.toasts.memberUpdateFailed"));
console.error("Error updating organization member:", error);
}
};
@ -111,21 +113,21 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
if (!accessToken) return;
await organizationMemberDeleteCall(accessToken, organizationId, values.user_id);
toast.success("Organization member deleted successfully");
toast.success(t("organizations.view.toasts.memberDeleted"));
setIsEditMemberModalVisible(false);
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
} catch (error) {
toast.fromError("Failed to delete organization member");
toast.fromError(t("organizations.view.toasts.memberDeleteFailed"));
console.error("Error deleting organization member:", error);
}
};
if (loading) {
return <div className="p-4">Loading...</div>;
return <div className="p-4">{t("organizations.view.loading")}</div>;
}
if (!orgData) {
return <div className="p-4">Organization not found</div>;
return <div className="p-4">{t("organizations.view.notFound")}</div>;
}
const orgMemberById = new Map((orgData.members || []).map((m) => [m.user_id, m]));
@ -133,14 +135,14 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
const orgExtraColumns: MemberTableColumn[] = [
{
title: "Spend (USD)",
title: t("organizations.view.members.spendColumn"),
key: "spend",
numeric: true,
sortValue: (record: Member) => orgMemberFor(record)?.spend ?? null,
render: (record: Member) => <MoneyCell value={orgMemberFor(record)?.spend} decimals={4} />,
},
{
title: "Created At",
title: t("organizations.view.members.createdAtColumn"),
key: "created_at",
sortValue: (record: Member) => orgMemberFor(record)?.created_at ?? null,
render: (record: Member) => {
@ -156,12 +158,12 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<div>
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="size-4" />
Back to Organizations
{t("organizations.view.backToList")}
</Button>
<h1 className="text-xl font-semibold tracking-tight text-foreground">{orgData.organization_alias}</h1>
<div className="flex items-center gap-1">
<span className="font-mono text-sm text-muted-foreground">{orgData.organization_id}</span>
<CopyButton value={orgData.organization_id} label="Copy organization ID" iconClassName="size-3" />
<CopyButton value={orgData.organization_id} label={t("organizations.view.copyId")} iconClassName="size-3" />
</div>
</div>
</div>
@ -169,13 +171,13 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<Tabs value={tab} onValueChange={handleTabChange} className="mb-4">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
{t("organizations.view.tabs.overview")}
</TabsTrigger>
<TabsTrigger value="members" className="flex-none rounded-none px-4 py-2">
Members
{t("organizations.view.tabs.members")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
{t("organizations.view.tabs.settings")}
</TabsTrigger>
</TabsList>
@ -183,11 +185,20 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Organization Details</p>
<p className="text-sm text-muted-foreground">{t("organizations.view.overview.detailsTitle")}</p>
<div className="mt-2 text-sm text-foreground">
<p>Created: {new Date(orgData.created_at).toLocaleDateString()}</p>
<p>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</p>
<p>Created By: {orgData.created_by}</p>
<p>
{t("organizations.view.overview.createdLabel")}
{new Date(orgData.created_at).toLocaleDateString()}
</p>
<p>
{t("organizations.view.overview.updatedLabel")}
{new Date(orgData.updated_at).toLocaleDateString()}
</p>
<p>
{t("organizations.view.overview.createdByLabel")}
{orgData.created_by}
</p>
</div>
</CardContent>
</Card>
@ -195,7 +206,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">
Budget Status
{t("organizations.view.overview.budgetTitle")}
<BudgetPeriodTooltip
spend={orgData.spend}
maxBudget={orgData.litellm_budget_table.max_budget}
@ -206,13 +217,16 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<div className="mt-2 text-sm text-foreground">
<p className="text-xl font-semibold">${formatNumberWithCommas(orgData.spend, 4)}</p>
<p>
of{" "}
{t("organizations.view.overview.budgetOf")}{" "}
{orgData.litellm_budget_table.max_budget === null
? "Unlimited"
? t("organizations.view.overview.unlimited")
: `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`}
</p>
{orgData.litellm_budget_table.budget_duration && (
<p className="text-muted-foreground">Reset: {orgData.litellm_budget_table.budget_duration}</p>
<p className="text-muted-foreground">
{t("organizations.view.overview.resetLabel")}
{orgData.litellm_budget_table.budget_duration}
</p>
)}
</div>
</CardContent>
@ -220,12 +234,21 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Rate Limits</p>
<p className="text-sm text-muted-foreground">{t("organizations.view.overview.rateLimitsTitle")}</p>
<div className="mt-2 text-sm text-foreground">
<p>TPM: {orgData.litellm_budget_table.tpm_limit ?? "Unlimited"}</p>
<p>RPM: {orgData.litellm_budget_table.rpm_limit ?? "Unlimited"}</p>
<p>
{t("organizations.view.overview.tpmLabel")}
{orgData.litellm_budget_table.tpm_limit ?? t("organizations.view.overview.unlimited")}
</p>
<p>
{t("organizations.view.overview.rpmLabel")}
{orgData.litellm_budget_table.rpm_limit ?? t("organizations.view.overview.unlimited")}
</p>
{orgData.litellm_budget_table.max_parallel_requests && (
<p>Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests}</p>
<p>
{t("organizations.view.overview.maxParallelLabel")}
{orgData.litellm_budget_table.max_parallel_requests}
</p>
)}
</div>
</CardContent>
@ -233,10 +256,10 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Models</p>
<p className="text-sm text-muted-foreground">{t("organizations.view.overview.modelsTitle")}</p>
<div className="mt-2 flex flex-wrap gap-2">
{orgData.models.length === 0 ? (
<BadgeLink>All proxy models</BadgeLink>
<BadgeLink>{t("organizations.view.overview.allProxyModels")}</BadgeLink>
) : (
orgData.models.map((model, index) => <BadgeLink key={index}>{model}</BadgeLink>)
)}
@ -246,7 +269,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Teams</p>
<p className="text-sm text-muted-foreground">{t("organizations.view.overview.teamsTitle")}</p>
<div className="mt-2 flex flex-wrap gap-2">
{orgData.teams?.map((team, index) => (
<BadgeLink key={index} href={teamDetailHref(team.team_id)}>
@ -282,9 +305,9 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
}}
onDelete={(member) => handleMemberDelete(member)}
onAddMember={() => setIsAddMemberModalVisible(true)}
roleColumnTitle="Organization Role"
roleColumnTitle={t("organizations.view.members.roleColumn")}
extraColumns={orgExtraColumns}
emptyText="No members found"
emptyText={t("organizations.view.members.empty")}
/>
</div>
</TabsContent>
@ -293,8 +316,10 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<Card className="max-h-[65vh] overflow-y-auto">
<CardContent>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Organization Settings</h2>
{canEditOrg && !isEditing && <Button onClick={() => setIsEditing(true)}>Edit Settings</Button>}
<h2 className="text-lg font-semibold text-foreground">{t("organizations.view.settings.title")}</h2>
{canEditOrg && !isEditing && (
<Button onClick={() => setIsEditing(true)}>{t("organizations.view.settings.editButton")}</Button>
)}
</div>
{isEditing ? (
@ -308,19 +333,19 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
) : (
<div className="space-y-4 text-sm">
<div>
<p className="font-medium text-foreground">Organization Name</p>
<p className="font-medium text-foreground">{t("organizations.view.settings.nameLabel")}</p>
<div>{orgData.organization_alias}</div>
</div>
<div>
<p className="font-medium text-foreground">Organization ID</p>
<p className="font-medium text-foreground">{t("organizations.view.settings.idLabel")}</p>
<div className="font-mono">{orgData.organization_id}</div>
</div>
<div>
<p className="font-medium text-foreground">Created At</p>
<p className="font-medium text-foreground">{t("organizations.view.settings.createdAtLabel")}</p>
<div>{new Date(orgData.created_at).toLocaleString()}</div>
</div>
<div>
<p className="font-medium text-foreground">Models</p>
<p className="font-medium text-foreground">{t("organizations.view.settings.modelsLabel")}</p>
<div className="mt-1 flex flex-wrap gap-2">
{orgData.models.map((model, index) => (
<BadgeLink key={index}>{model}</BadgeLink>
@ -328,19 +353,28 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
</div>
</div>
<div>
<p className="font-medium text-foreground">Rate Limits</p>
<div>TPM: {orgData.litellm_budget_table.tpm_limit ?? "Unlimited"}</div>
<div>RPM: {orgData.litellm_budget_table.rpm_limit ?? "Unlimited"}</div>
<p className="font-medium text-foreground">{t("organizations.view.settings.rateLimitsLabel")}</p>
<div>
{t("organizations.view.settings.tpmLabel")}
{orgData.litellm_budget_table.tpm_limit ?? t("organizations.view.settings.unlimited")}
</div>
<div>
{t("organizations.view.settings.rpmLabel")}
{orgData.litellm_budget_table.rpm_limit ?? t("organizations.view.settings.unlimited")}
</div>
</div>
<div>
<p className="font-medium text-foreground">Budget</p>
<p className="font-medium text-foreground">{t("organizations.view.settings.budgetLabel")}</p>
<div>
Max:{" "}
{t("organizations.view.settings.maxBudgetLabel")}{" "}
{orgData.litellm_budget_table.max_budget !== null
? `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`
: "No Limit"}
: t("organizations.view.settings.noLimit")}
</div>
<div>
{t("organizations.view.settings.resetLabel")}
{orgData.litellm_budget_table.budget_duration || t("organizations.view.settings.never")}
</div>
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
</div>
<ObjectPermissionsView
@ -361,22 +395,22 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
onCancel={() => setIsAddMemberModalVisible(false)}
onSubmit={handleMemberAdd}
accessToken={accessToken}
title="Add Organization Member"
title={t("organizations.view.addMember.title")}
roles={[
{
label: "org_admin",
value: "org_admin",
description: "Can add and remove members, and change their roles.",
description: t("organizations.view.addMember.roles.orgAdmin"),
},
{
label: "internal_user",
value: "internal_user",
description: "Can view/create keys for themselves within organization.",
description: t("organizations.view.addMember.roles.internalUser"),
},
{
label: "internal_user_viewer",
value: "internal_user_viewer",
description: "Can only view their keys within organization.",
description: t("organizations.view.addMember.roles.internalUserViewer"),
},
]}
defaultRole="internal_user"
@ -388,13 +422,13 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
initialData={selectedEditMember}
mode="edit"
config={{
title: "Edit Member",
title: t("organizations.view.editMember.title"),
showEmail: true,
showUserId: true,
roleOptions: [
{ label: "Org Admin", value: "org_admin" },
{ label: "Internal User", value: "internal_user" },
{ label: "Internal User Viewer", value: "internal_user_viewer" },
{ label: t("organizations.view.editMember.roles.orgAdmin"), value: "org_admin" },
{ label: t("organizations.view.editMember.roles.internalUser"), value: "internal_user" },
{ label: t("organizations.view.editMember.roles.internalUserViewer"), value: "internal_user_viewer" },
],
}}
/>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { availableTeamListCall, teamMemberAddCall } from "@/components/networking";
import { toast } from "@/lib/toast";
@ -12,6 +13,7 @@ interface AvailableTeamsProps {
}
const AvailableTeamsPanel: React.FC<AvailableTeamsProps> = ({ accessToken, userID }) => {
const { t } = useTranslation();
const [availableTeams, setAvailableTeams] = useState<AvailableTeam[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -54,11 +56,11 @@ const AvailableTeamsPanel: React.FC<AvailableTeamsProps> = ({ accessToken, userI
role: "user",
});
toast.success("Successfully joined team");
toast.success(t("teams.availableTeams.panel.toasts.joined"));
setAvailableTeams((teams) => teams.filter((team) => team.team_id !== teamId));
} catch (error) {
console.error("Error joining team:", error);
toast.fromError("Failed to join team");
toast.fromError(t("teams.availableTeams.panel.toasts.joinFailed"));
}
};

View file

@ -3,6 +3,7 @@
import { SortingState } from "@tanstack/react-table";
import { Users } from "lucide-react";
import React, { useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { DataTable } from "@/components/shared/DataTable";
@ -17,31 +18,37 @@ interface AvailableTeamsTableProps {
const DEFAULT_SORTING: SortingState = [{ id: "team_alias", desc: false }];
function EmptyState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Users className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No available teams to join</div>
<div className="text-sm font-medium text-foreground">{t("teams.availableTeams.table.emptyTitle")}</div>
<div className="text-sm text-muted-foreground">
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-primary underline-offset-4 hover:underline"
>
here
</a>
<Trans
i18nKey="teams.availableTeams.table.emptyDocs"
components={{
docs: (
<a
href="https://docs.litellm.ai/docs/proxy/self_serve#all-settings-for-self-serve--sso-flow"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
/>
),
}}
/>
</div>
</div>
);
}
const AvailableTeamsTable: React.FC<AvailableTeamsTableProps> = ({ teams, isLoading, onJoinTeam }) => {
const { t } = useTranslation();
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(() => getAvailableTeamsTableColumns({ onJoinTeam }), [onJoinTeam]);
const columns = useMemo(() => getAvailableTeamsTableColumns({ onJoinTeam, t }), [onJoinTeam, t]);
return (
<DataTable
@ -53,7 +60,7 @@ const AvailableTeamsTable: React.FC<AvailableTeamsTableProps> = ({ teams, isLoad
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading available teams…"
loadingMessage={t("teams.availableTeams.table.loading")}
noDataMessage={<EmptyState />}
size="compact"
/>

View file

@ -1,7 +1,9 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { MoreHorizontal, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { IdentityCell, ModelsCell } from "@/components/shared/table_cells";
@ -23,10 +25,11 @@ export interface AvailableTeam {
}
function AvailableTeamRowActions({ team, onJoinTeam }: { team: AvailableTeam; onJoinTeam: (teamId: string) => void }) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open team actions"
aria-label={t("teams.availableTeams.columns.openActions")}
data-testid={`available-team-actions-${team.team_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -35,7 +38,7 @@ function AvailableTeamRowActions({ team, onJoinTeam }: { team: AvailableTeam; on
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem data-testid="available-team-action-join" onClick={() => onJoinTeam(team.team_id)}>
<UserPlus />
Join team
{t("teams.availableTeams.columns.join")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -44,16 +47,18 @@ function AvailableTeamRowActions({ team, onJoinTeam }: { team: AvailableTeam; on
interface AvailableTeamsTableColumnsDeps {
onJoinTeam: (teamId: string) => void;
t: TFunction;
}
export const getAvailableTeamsTableColumns = ({
onJoinTeam,
t,
}: AvailableTeamsTableColumnsDeps): ColumnDef<AvailableTeam>[] => [
{
id: "team_alias",
accessorKey: "team_alias",
meta: { title: "Team Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Team Name" />,
meta: { title: t("teams.availableTeams.columns.teamName") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("teams.availableTeams.columns.teamName")} />,
size: 220,
enableSorting: true,
cell: ({ row }) => (
@ -63,15 +68,15 @@ export const getAvailableTeamsTableColumns = ({
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
meta: { title: t("teams.availableTeams.columns.description") },
header: t("teams.availableTeams.columns.description"),
size: 280,
enableSorting: false,
cell: ({ row }) => {
const description = row.original.description;
return (
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={description || undefined}>
{description || "No description available"}
{description || t("teams.availableTeams.columns.noDescription")}
</span>
);
},
@ -79,18 +84,20 @@ export const getAvailableTeamsTableColumns = ({
{
id: "members",
accessorFn: (team) => team.members_with_roles.length,
meta: { title: "Members" },
header: ({ column }) => <DataTableSortHeader column={column} title="Members" />,
meta: { title: t("teams.availableTeams.columns.members") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("teams.availableTeams.columns.members")} />,
size: 120,
enableSorting: true,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.members_with_roles.length} members</span>
<span className="text-sm text-muted-foreground">
{t("teams.availableTeams.columns.membersCount", { count: row.original.members_with_roles.length })}
</span>
),
},
{
id: "models",
meta: { title: "Models" },
header: "Models",
meta: { title: t("teams.availableTeams.columns.models") },
header: t("teams.availableTeams.columns.models"),
size: 260,
enableSorting: false,
cell: ({ row }) => <ModelsCell models={row.original.models} />,
@ -98,7 +105,7 @@ export const getAvailableTeamsTableColumns = ({
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
header: () => <span className="sr-only">{t("teams.availableTeams.columns.actions")}</span>,
size: 64,
enableSorting: false,
enableHiding: false,

View file

@ -1,6 +1,8 @@
import React, { useEffect, useMemo, useState } from "react";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import NumericalInput from "../shared/numerical_input";
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
@ -19,7 +21,7 @@ import {
buildMemberFormValues,
emptyMemberFormValues,
isModelMaxBudget,
TEMP_BUDGET_PAIR_MESSAGE,
TEMP_BUDGET_PAIR_MESSAGE_KEY,
tempBudgetPairError,
type MemberAdditionalField,
type MemberFieldsConfig,
@ -48,8 +50,6 @@ interface MemberModalProps<T extends BaseMember> {
config: ModalConfig;
}
const ROLE_REQUIRED_MESSAGE = "Please select a role!";
const isEmailish = (value: string): boolean =>
value === "" || z.email({ pattern: z.regexes.html5Email }).safeParse(value).success;
@ -59,17 +59,18 @@ const memberFieldSchema = z
.union([z.string(), z.number(), z.null(), z.array(z.string()), modelMaxBudgetSchema])
.optional();
const buildMemberSchema = (config: ModalConfig): z.ZodType<MemberFormValues, MemberFormValues> => {
const buildMemberSchema = (config: ModalConfig, t: TFunction): z.ZodType<MemberFormValues, MemberFormValues> => {
const roleRequired = t("teams.editMembership.errors.roleRequired");
const shape = {
user_email: z.string().refine(isEmailish, "Please enter a valid email!").nullish(),
user_email: z.string().refine(isEmailish, t("teams.editMembership.errors.invalidEmail")).nullish(),
user_id: z.string().nullish(),
role: z.string({ error: ROLE_REQUIRED_MESSAGE }).min(1, ROLE_REQUIRED_MESSAGE),
role: z.string({ error: roleRequired }).min(1, roleRequired),
...Object.fromEntries((config.additionalFields ?? []).map((field) => [field.name, memberFieldSchema])),
};
return z.object(shape).superRefine((values, ctx) => {
const path = tempBudgetPairError(values);
if (path !== null) ctx.addIssue({ code: "custom", path: [path], message: TEMP_BUDGET_PAIR_MESSAGE });
if (path !== null) ctx.addIssue({ code: "custom", path: [path], message: t(TEMP_BUDGET_PAIR_MESSAGE_KEY) });
});
};
@ -81,7 +82,8 @@ const MemberModal = <T extends BaseMember>({
mode,
config,
}: MemberModalProps<T>) => {
const schema = useMemo(() => buildMemberSchema(config), [config]);
const { t } = useTranslation();
const schema = useMemo(() => buildMemberSchema(config, t), [config, t]);
const form = useZodForm(schema, { defaultValues: emptyMemberFormValues(config) });
const [isSubmitting, setIsSubmitting] = useState(false);
@ -136,7 +138,7 @@ const MemberModal = <T extends BaseMember>({
step={field.step || 1}
min={field.min || 0}
style={{ width: "100%" }}
placeholder={field.placeholder || "Enter a numerical value"}
placeholder={field.placeholder || t("teams.editMembership.numericalPlaceholder")}
value={value ?? ""}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.value)}
/>
@ -166,7 +168,7 @@ const MemberModal = <T extends BaseMember>({
options={field.options ?? []}
value={Array.isArray(value) ? value : []}
onValueChange={onChange}
placeholder={field.placeholder || "Select options"}
placeholder={field.placeholder || t("teams.editMembership.multiSelectPlaceholder")}
/>
);
case "budget-duration":
@ -208,12 +210,15 @@ const MemberModal = <T extends BaseMember>({
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]">
<DialogHeader>
<DialogTitle>{config.title || (mode === "add" ? "Add Member" : "Edit Member")}</DialogTitle>
<DialogTitle>
{config.title ||
(mode === "add" ? t("teams.editMembership.addTitle") : t("teams.editMembership.editTitle"))}
</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
{config.showEmail && (
<FormField control={form.control} name="user_email" label="Email">
<FormField control={form.control} name="user_email" label={t("teams.editMembership.fields.email")}>
{({ ref, value, onChange, ...rest }) => (
<Input
{...rest}
@ -227,11 +232,11 @@ const MemberModal = <T extends BaseMember>({
)}
{config.showEmail && config.showUserId && (
<div className="text-center text-sm text-muted-foreground">OR</div>
<div className="text-center text-sm text-muted-foreground">{t("teams.editMembership.fields.or")}</div>
)}
{config.showUserId && (
<FormField control={form.control} name="user_id" label="User ID">
<FormField control={form.control} name="user_id" label={t("teams.editMembership.fields.userId")}>
{({ ref, value, onChange, ...rest }) => (
<Input
{...rest}
@ -249,9 +254,11 @@ const MemberModal = <T extends BaseMember>({
name="role"
label={
<span className="flex items-center gap-2">
<span>Role</span>
<span>{t("teams.editMembership.fields.role")}</span>
{mode === "edit" && initialData && (
<span className="text-sm text-muted-foreground">(Current: {getRoleLabel(initialData.role)})</span>
<span className="text-sm text-muted-foreground">
{t("teams.editMembership.fields.currentRole", { role: getRoleLabel(initialData.role) })}
</span>
)}
</span>
}
@ -281,17 +288,17 @@ const MemberModal = <T extends BaseMember>({
<div className="mt-6 text-right">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting} className="mr-2">
Cancel
{t("teams.editMembership.cancel")}
</Button>
<Button type="submit" variant="outline" disabled={isSubmitting}>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
{mode === "add"
? isSubmitting
? "Adding..."
: "Add Member"
? t("teams.editMembership.submit.adding")
: t("teams.editMembership.submit.add")
: isSubmitting
? "Saving..."
: "Save Changes"}
? t("teams.editMembership.submit.saving")
: t("teams.editMembership.submit.save")}
</Button>
</div>
</form>

View file

@ -2,6 +2,7 @@
import { Globe } from "lucide-react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Combobox,
@ -51,9 +52,12 @@ export const GuardrailsSelect: React.FC<GuardrailsSelectProps> = ({
globalGuardrails,
otherGuardrails,
globalGuardrailNames,
placeholder = "Select guardrails",
emptyText = "No guardrails found",
placeholder,
emptyText,
}) => {
const { t } = useTranslation();
const placeholderText = placeholder ?? t("teams.guardrailsSelect.placeholder");
const emptyStateText = emptyText ?? t("teams.guardrailsSelect.empty");
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
@ -62,8 +66,8 @@ export const GuardrailsSelect: React.FC<GuardrailsSelectProps> = ({
const grouped = globalGuardrails.length > 0 && otherGuardrails.length > 0;
const groups: GuardrailGroup[] = grouped
? [
{ label: "Global", icon: true, items: [...globalGuardrails] },
{ label: "Other", icon: false, items: [...otherGuardrails] },
{ label: t("teams.guardrailsSelect.groups.global"), icon: true, items: [...globalGuardrails] },
{ label: t("teams.guardrailsSelect.groups.other"), icon: false, items: [...otherGuardrails] },
]
: [{ label: "", icon: false, items: known }];
@ -89,17 +93,24 @@ export const GuardrailsSelect: React.FC<GuardrailsSelectProps> = ({
<>
{chips.map((option) => (
<ComboboxChip key={option.name} aria-label={option.name}>
{globalGuardrailNames.has(option.name) && <Globe className="size-3" aria-label="Global guardrail" />}
{globalGuardrailNames.has(option.name) && (
<Globe className="size-3" aria-label={t("teams.guardrailsSelect.globalChip")} />
)}
{option.name}
</ComboboxChip>
))}
<ComboboxChipsInput id={id} placeholder={placeholder} className="min-w-24" aria-label={placeholder} />
<ComboboxChipsInput
id={id}
placeholder={placeholderText}
className="min-w-24"
aria-label={placeholderText}
/>
</>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxEmpty>{emptyStateText}</ComboboxEmpty>
<ComboboxList>
{(group: GuardrailGroup) => (
<ComboboxGroup key={group.label} items={group.items}>

View file

@ -1,5 +1,6 @@
/* eslint-disable react/no-unescaped-entities */
import type { ParseKeys } from "i18next";
import React from "react";
import { useTranslation } from "react-i18next";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
@ -14,10 +15,10 @@ import { callbackInfo, callback_map, mapDisplayToInternalNames } from "../callba
import { Logo } from "@/components/molecules/logo/Logo";
import NumericalInput from "../shared/numerical_input";
const CALLBACK_TYPE_ITEMS = [
{ value: "success", label: "Success Only" },
{ value: "failure", label: "Failure Only" },
{ value: "success_and_failure", label: "Success & Failure" },
const CALLBACK_TYPE_ITEMS: readonly { value: string; labelKey: ParseKeys }[] = [
{ value: "success", labelKey: "teams.loggingSettings.callbackTypes.success" },
{ value: "failure", labelKey: "teams.loggingSettings.callbackTypes.failure" },
{ value: "success_and_failure", labelKey: "teams.loggingSettings.callbackTypes.successAndFailure" },
];
const CallbackVarInput: React.FC<{
@ -26,6 +27,7 @@ const CallbackVarInput: React.FC<{
value: string;
onValueChange: (value: string) => void;
}> = ({ sensitive, placeholder, value, onValueChange }) => {
const { t } = useTranslation();
const [revealed, setRevealed] = React.useState(false);
if (!sensitive) {
@ -44,7 +46,7 @@ const CallbackVarInput: React.FC<{
<InputGroupButton
size="icon-xs"
onClick={() => setRevealed(!revealed)}
aria-label={revealed ? "Hide password" : "Show password"}
aria-label={revealed ? t("teams.loggingSettings.password.hide") : t("teams.loggingSettings.password.show")}
>
{revealed ? <EyeOff /> : <Eye />}
</InputGroupButton>
@ -72,6 +74,8 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
disabledCallbacks = [],
onDisabledCallbacksChange,
}) => {
const { t } = useTranslation();
const callbackTypeItems = CALLBACK_TYPE_ITEMS.map((item) => ({ value: item.value, label: t(item.labelKey) }));
// Get callbacks that support team and key logging
const supportedCallbacks = Object.entries(callbackInfo)
.filter(([_, info]) => info.supports_key_team_logging)
@ -166,7 +170,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
onValueChange={(selected: string | null) => updateCallbackVar(configIndex, paramName, selected ?? "")}
>
<SelectTrigger aria-label={label} className="w-full">
<SelectValue placeholder={`Select ${label}`} />
<SelectValue placeholder={t("teams.loggingSettings.selectParam", { label })} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
@ -224,7 +228,9 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
<div className="w-3 h-3 bg-muted rounded-full flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-primary rounded-full"></div>
</div>
<span className="text-sm font-medium text-foreground">Integration Parameters</span>
<span className="text-sm font-medium text-foreground">
{t("teams.loggingSettings.integrationParameters")}
</span>
</div>
<div className="grid grid-cols-1 gap-4">
{Object.entries(dynamicParams)
@ -233,11 +239,15 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
<div key={paramName} className="space-y-2">
<label className="text-sm font-medium text-foreground capitalize flex items-center space-x-1">
<span>{paramName.replace(/_/g, " ")}</span>
{paramType === "password" && <Badge variant="secondary">Sensitive</Badge>}
{paramType === "number" && <Badge variant="secondary">Number</Badge>}
{paramType === "password" && (
<Badge variant="secondary">{t("teams.loggingSettings.badges.sensitive")}</Badge>
)}
{paramType === "number" && (
<Badge variant="secondary">{t("teams.loggingSettings.badges.number")}</Badge>
)}
</label>
{paramType === "number" && (
<span className="text-xs text-muted-foreground">Value must be between 0 and 1</span>
<span className="text-xs text-muted-foreground">{t("teams.loggingSettings.numberHint")}</span>
)}
{renderParamControl(config, configIndex, paramName, {
type: paramType,
@ -256,18 +266,24 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
<div className="space-y-4">
<div className="flex items-center space-x-2">
<BanIcon className="w-5 h-5 text-destructive" />
<span className="text-base font-semibold text-foreground">Disabled Callbacks</span>
<SimpleTooltip content="Select callbacks to disable for this key. Disabled callbacks will not receive any logging data.">
<span className="text-base font-semibold text-foreground">
{t("teams.loggingSettings.disabledCallbacks.title")}
</span>
<SimpleTooltip content={t("teams.loggingSettings.disabledCallbacks.tooltip")}>
<Info className="size-4 text-muted-foreground cursor-help" />
</SimpleTooltip>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Disabled Callbacks</label>
<label className="text-sm font-medium text-foreground">
{t("teams.loggingSettings.disabledCallbacks.label")}
</label>
<Select multiple value={disabledCallbacks} onValueChange={handleDisabledCallbacksChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select callbacks to disable">
{(selected: string[]) => (selected.length === 0 ? "Select callbacks to disable" : selected.join(", "))}
<SelectValue placeholder={t("teams.loggingSettings.disabledCallbacks.placeholder")}>
{(selected: string[]) =>
selected.length === 0 ? t("teams.loggingSettings.disabledCallbacks.placeholder") : selected.join(", ")
}
</SelectValue>
</SelectTrigger>
<SelectContent>
@ -290,9 +306,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
})}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
Select callbacks that should be disabled for this key. These callbacks will not receive any logging data.
</div>
<div className="text-xs text-muted-foreground">{t("teams.loggingSettings.disabledCallbacks.help")}</div>
</div>
</div>
@ -302,14 +316,16 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
<div className="flex justify-between items-center">
<div className="flex items-center space-x-2">
<CogIcon className="w-5 h-5 text-foreground" />
<span className="text-base font-semibold text-foreground">Logging Integrations</span>
<SimpleTooltip content="Configure callback logging integrations for this team.">
<span className="text-base font-semibold text-foreground">
{t("teams.loggingSettings.integrations.title")}
</span>
<SimpleTooltip content={t("teams.loggingSettings.integrations.tooltip")}>
<Info className="size-4 text-muted-foreground cursor-help" />
</SimpleTooltip>
</div>
<Button variant="secondary" onClick={addLoggingConfig} size="sm" type="button">
<Plus />
Add Integration
{t("teams.loggingSettings.integrations.add")}
</Button>
</div>
@ -333,7 +349,11 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
className="w-5 h-5 object-contain"
/>
)}
<span className="text-sm font-medium">{callbackDisplayName || "New Integration"} Configuration</span>
<span className="text-sm font-medium">
{t("teams.loggingSettings.card.title", {
name: callbackDisplayName || t("teams.loggingSettings.card.newIntegration"),
})}
</span>
</div>
<Button
variant="ghost"
@ -343,13 +363,15 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
type="button"
>
<Trash2 />
Remove
{t("teams.loggingSettings.card.remove")}
</Button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Integration Type</label>
<label className="text-sm font-medium text-foreground">
{t("teams.loggingSettings.card.integrationType")}
</label>
<Select
value={callbackDisplayName ?? null}
onValueChange={(value: string | null) =>
@ -357,7 +379,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select integration" />
<SelectValue placeholder={t("teams.loggingSettings.card.integrationTypePlaceholder")} />
</SelectTrigger>
<SelectContent>
{supportedCallbacks.map((callbackName) => {
@ -382,19 +404,21 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Event Type</label>
<label className="text-sm font-medium text-foreground">
{t("teams.loggingSettings.card.eventType")}
</label>
<Select
items={CALLBACK_TYPE_ITEMS}
items={callbackTypeItems}
value={config.callback_type}
onValueChange={(value: string | null) =>
value && updateLoggingConfig(index, "callback_type", value)
}
>
<SelectTrigger aria-label="Event Type" className="w-full">
<SelectTrigger aria-label={t("teams.loggingSettings.card.eventType")} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CALLBACK_TYPE_ITEMS.map((item) => (
{callbackTypeItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -414,10 +438,8 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
{value.length === 0 && (
<div className="text-center py-12 text-muted-foreground border-2 border-dashed border-border rounded-lg bg-muted/30">
<CogIcon className="w-12 h-12 text-muted-foreground mb-3 mx-auto" />
<div className="text-base font-medium mb-1">No logging integrations configured</div>
<div className="text-sm text-muted-foreground">
Click "Add Integration" to configure logging for this team
</div>
<div className="text-base font-medium mb-1">{t("teams.loggingSettings.empty.title")}</div>
<div className="text-sm text-muted-foreground">{t("teams.loggingSettings.empty.description")}</div>
</div>
)}
</div>

View file

@ -3,19 +3,21 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import type { TFunction } from "i18next";
import { CircleHelp } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useMyTeamMember } from "./useMyTeamMember";
interface MyUserTabProps {
teamId: string;
}
const labelWithTooltip = (label: string, tooltip: string) => (
const labelWithTooltip = (t: TFunction, label: string, tooltip: string) => (
<span className="flex items-center gap-1 text-muted-foreground">
{label}
<SimpleTooltip content={tooltip}>
<CircleHelp className="size-4" aria-label={`${label} information`} />
<CircleHelp className="size-4" aria-label={t("teams.myUserTab.infoAriaLabel", { label })} />
</SimpleTooltip>
</span>
);
@ -25,24 +27,25 @@ const formatNumber = (value: number | null | undefined, digits = 4): string => {
return formatNumberWithCommas(value, digits);
};
const formatRateLimit = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "Unlimited";
const formatRateLimit = (t: TFunction, value: number | null | undefined): string => {
if (value === null || value === undefined) return t("teams.myUserTab.unlimited");
return formatNumberWithCommas(value, 0);
};
function describePeriodLine(schedule: BudgetResetSchedule): string {
if (schedule.period) return `${schedule.period} (resets ${schedule.label})`;
if (schedule.resetAt) return `Resets ${schedule.label}`;
function describePeriodLine(t: TFunction, schedule: BudgetResetSchedule): string {
if (schedule.period) return t("teams.myUserTab.periodWithReset", { period: schedule.period, reset: schedule.label });
if (schedule.resetAt) return t("teams.myUserTab.resetsOn", { reset: schedule.label });
return schedule.label;
}
export default function MyUserTab({ teamId }: MyUserTabProps) {
const { t } = useTranslation();
const { data, isLoading, error } = useMyTeamMember(teamId);
if (isLoading) {
return (
<Card>
<CardContent className="text-muted-foreground">Loading your membership info…</CardContent>
<CardContent className="text-muted-foreground">{t("teams.myUserTab.loading")}</CardContent>
</Card>
);
}
@ -51,7 +54,7 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
return (
<Card>
<CardContent className="text-destructive">
{error instanceof Error ? error.message : "Failed to load your membership info for this team."}
{error instanceof Error ? error.message : t("teams.myUserTab.loadFailed")}
</CardContent>
</Card>
);
@ -60,9 +63,7 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
if (!data) {
return (
<Card>
<CardContent className="text-muted-foreground">
No membership info available for the current user in this team.
</CardContent>
<CardContent className="text-muted-foreground">{t("teams.myUserTab.empty")}</CardContent>
</Card>
);
}
@ -82,12 +83,12 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
<div>
<span className="text-muted-foreground">User</span>
<span className="text-muted-foreground">{t("teams.myUserTab.user")}</span>
<div className="mt-1 font-semibold">{data.user_email || data.user_id}</div>
<span className="font-mono text-xs text-muted-foreground">{data.user_id}</span>
</div>
<div>
<span className="text-muted-foreground">Team Role</span>
<span className="text-muted-foreground">{t("teams.myUserTab.teamRole")}</span>
<div className="mt-1">
<Badge variant={data.role === "admin" ? "default" : "secondary"}>{data.role || "user"}</Badge>
</div>
@ -99,41 +100,40 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardContent>
{labelWithTooltip("Current Period Spend (USD)", budgetReset.tooltip)}
{labelWithTooltip(t, t("teams.myUserTab.currentPeriodSpend.title"), budgetReset.tooltip)}
<div className="mt-2">
<h3 className="text-2xl font-semibold">${formatNumber(spend, 4)}</h3>
<span className="text-muted-foreground">
of {maxBudget === null ? "Unlimited" : `$${formatNumber(maxBudget, 4)}`}
{t("teams.myUserTab.currentPeriodSpend.ofBudget", {
budget: maxBudget === null ? t("teams.myUserTab.unlimited") : `$${formatNumber(maxBudget, 4)}`,
})}
</span>
</div>
<div className="mt-1 text-muted-foreground">{describePeriodLine(budgetReset)}</div>
<div className="mt-1 text-muted-foreground">{describePeriodLine(t, budgetReset)}</div>
</CardContent>
</Card>
<Card>
<CardContent>
{labelWithTooltip("Rate Limits", "Your per-member rate limits within this team.")}
{labelWithTooltip(t, t("teams.myUserTab.rateLimits.title"), t("teams.myUserTab.rateLimits.tooltip"))}
<div className="mt-2">
<span>TPM: {formatRateLimit(tpmLimit)}</span>
<span>{t("teams.myUserTab.rateLimits.tpm", { value: formatRateLimit(t, tpmLimit) })}</span>
<br />
<span>RPM: {formatRateLimit(rpmLimit)}</span>
<span>{t("teams.myUserTab.rateLimits.rpm", { value: formatRateLimit(t, rpmLimit) })}</span>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
{labelWithTooltip(
"Total Spend (USD)",
"Cumulative spend within this team across budget periods. This is a lower bound: lifetime tracking began 2026-04-21, so periods that reset before then are not included. It is never less than Current Period Spend.",
)}
{labelWithTooltip(t, t("teams.myUserTab.totalSpend.title"), t("teams.myUserTab.totalSpend.tooltip"))}
<h4 className="mt-2 text-xl font-semibold">${formatNumber(totalSpend, 4)}</h4>
</CardContent>
</Card>
<Card>
<CardContent>
{labelWithTooltip("Model Scope", "Models you can access within this team.")}
{labelWithTooltip(t, t("teams.myUserTab.modelScope.title"), t("teams.myUserTab.modelScope.tooltip"))}
<div className="mt-2">
{allowedModels && allowedModels.length > 0 ? (
<div className="flex flex-wrap gap-1">
@ -144,7 +144,7 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
))}
</div>
) : (
<span>All Team Models</span>
<span>{t("teams.myUserTab.modelScope.allTeamModels")}</span>
)}
</div>
</CardContent>

View file

@ -1,8 +1,8 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { pluralize } from "./memberBudgetReset";
import type { MemberBudgetResetState } from "./useMemberBudgetReset";
interface ResetMemberBudgetsDialogProps {
@ -20,11 +20,13 @@ export default function ResetMemberBudgetsDialog({
onKeep,
onDismiss,
}: ResetMemberBudgetsDialogProps) {
const { t } = useTranslation();
const open = state.phase !== "idle";
const busy = state.phase === "resetting";
const failed = state.phase === "resetFailed";
const memberCount = state.phase === "idle" ? 0 : state.pending.userIds.length;
const newBudget = state.phase === "idle" ? 0 : state.pending.newBudget;
const budget = formatNumberWithCommas(newBudget, 2);
return (
<Dialog
@ -35,32 +37,29 @@ export default function ResetMemberBudgetsDialog({
>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset member budgets?</DialogTitle>
<DialogTitle>{t("teams.resetMemberBudgets.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{memberCount} {pluralize(memberCount, "member has", "members have")} a custom budget, so the new team default
of ${formatNumberWithCommas(newBudget, 2)} will not apply to {pluralize(memberCount, "that member", "them")}.
Reset {pluralize(memberCount, "it", "them")} to the default, or keep the custom{" "}
{pluralize(memberCount, "budget", "budgets")}?
{t("teams.resetMemberBudgets.description", { count: memberCount, budget })}
</p>
<DialogFooter>
{failed ? (
<>
<Button variant="outline" onClick={onDismiss}>
Cancel
{t("teams.resetMemberBudgets.cancel")}
</Button>
<Button onClick={onRetry}>Retry reset</Button>
<Button onClick={onRetry}>{t("teams.resetMemberBudgets.retry")}</Button>
</>
) : (
<>
<Button variant="ghost" onClick={onDismiss} disabled={busy}>
Cancel
{t("teams.resetMemberBudgets.cancel")}
</Button>
<Button variant="outline" onClick={onKeep} disabled={busy}>
Keep custom {pluralize(memberCount, "budget", "budgets")}
{t("teams.resetMemberBudgets.keepCustom", { count: memberCount })}
</Button>
<Button onClick={onReset} disabled={busy}>
{pluralize(memberCount, "Reset", "Reset all")} to ${formatNumberWithCommas(newBudget, 2)}
{t("teams.resetMemberBudgets.resetTo", { count: memberCount, budget })}
</Button>
</>
)}

View file

@ -1,8 +1,10 @@
"use client";
import type { ParseKeys, TFunction } from "i18next";
import { Save } from "lucide-react";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import type { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
@ -77,13 +79,12 @@ const INPUT_STEP: Readonly<Record<Exclude<TeamAdminSettingsField, "team_member_b
team_member_rpm_limit: 1,
};
const MAX_BUDGET_KEEP_OR_LOWER_HINT = "You can keep or lower this budget. Ask a proxy admin to raise it.";
const MAX_BUDGET_RAISE_HINT =
"You can raise this budget. Raises are capped by your organization's budget when the team belongs to one.";
const MAX_BUDGET_KEEP_OR_LOWER_HINT: ParseKeys = "teams.adminSettingsForm.maxBudgetKeepOrLowerHint";
const MAX_BUDGET_RAISE_HINT: ParseKeys = "teams.adminSettingsForm.maxBudgetRaiseHint";
const fieldHint = (name: TeamAdminSettingsField, mayRaiseMaxBudget: boolean): string | undefined => {
const fieldHint = (t: TFunction, name: TeamAdminSettingsField, mayRaiseMaxBudget: boolean): string | undefined => {
if (name !== "max_budget") return undefined;
return mayRaiseMaxBudget ? MAX_BUDGET_RAISE_HINT : MAX_BUDGET_KEEP_OR_LOWER_HINT;
return t(mayRaiseMaxBudget ? MAX_BUDGET_RAISE_HINT : MAX_BUDGET_KEEP_OR_LOWER_HINT);
};
const durationSelectValue = (value: string | number | null | undefined): string | undefined => {
@ -123,6 +124,7 @@ function TeamAdminSettingsFormFields({
onCancel,
onSave,
}: TeamAdminSettingsFormProps) {
const { t } = useTranslation();
const editsMetadata = metadata !== undefined && editableFields.has(TEAM_ADMIN_METADATA_FIELD);
const [schema] = useState(() => teamAdminSettingsSchemaFor(editsMetadata ? metadata.context : undefined));
const form = useZodForm(schema, { defaultValues: { ...initialValues, metadata: [...(metadata?.pairs ?? [])] } });
@ -137,23 +139,21 @@ function TeamAdminSettingsFormFields({
return (
<form onSubmit={(event) => void submit(event)}>
<FieldGroup>
<p className="text-sm text-muted-foreground">
A proxy admin chose which settings team admins can change. Ask a proxy admin to change anything else.
</p>
<p className="text-sm text-muted-foreground">{t("teams.adminSettingsForm.intro")}</p>
{TEAM_ADMIN_SETTINGS_FIELDS.filter((name) => editableFields.has(name)).map((name) => (
<FormField
key={name}
control={form.control}
name={name}
label={teamAdminFieldLabel(name)}
description={fieldHint(name, mayRaiseMaxBudget)}
description={fieldHint(t, name, mayRaiseMaxBudget)}
>
{({ ref, id, value, onChange, ...field }) =>
isTeamAdminDurationSettingsField(name) ? (
<BudgetDurationDropdown
id={id}
showNeverResets
placeholder="Inherit team reset period"
placeholder={t("teams.adminSettingsForm.inheritDurationPlaceholder")}
value={durationSelectValue(value)}
onChange={(next) => onChange(next === NEVER_RESETS_BUDGET_DURATION ? null : next ?? undefined)}
/>
@ -180,22 +180,18 @@ function TeamAdminSettingsFormFields({
schemaFields={metadata.schemaFields}
schemaLoading={metadata.schemaLoading}
/>
<FieldDescription>
{
'A value that is valid JSON, like 3, true or {"region": "us"}, is saved as that type, anything else as text. Keys the proxy reads as settings stay with proxy admins and are kept as they are.'
}
</FieldDescription>
<FieldDescription>{t("teams.adminSettingsForm.metadataDescription")}</FieldDescription>
</Field>
)}
</FieldGroup>
<div className="mt-6 flex items-center justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
Cancel
{t("teams.adminSettingsForm.cancel")}
</Button>
<Button type="submit" disabled={isSaving || !hasChanges}>
{isSaving ? <UiLoadingSpinner className="size-4" /> : <Save className="size-4" />}
Save Changes
{t("teams.adminSettingsForm.save")}
</Button>
</div>
</form>

File diff suppressed because it is too large Load diff

View file

@ -15,17 +15,19 @@ import { toast } from "@/lib/toast";
import { describeBudgetResetSchedule } from "@/utils/budgetUtils";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import type { ParseKeys, TFunction } from "i18next";
import { CircleHelp } from "lucide-react";
import { useMemo, useState, type ComponentProps } from "react";
import { Trans, useTranslation } from "react-i18next";
import { TeamData, TeamMemberBudgetSource, TeamMembership } from "./TeamInfo";
const BUDGET_SOURCE_LABELS: Record<Exclude<TeamMemberBudgetSource, "none">, string> = {
team_default: "Team default",
custom: "Custom",
const BUDGET_SOURCE_LABEL_KEYS: Record<Exclude<TeamMemberBudgetSource, "none">, ParseKeys> = {
team_default: "teams.memberTab.budgetSource.teamDefault",
custom: "teams.memberTab.budgetSource.custom",
};
const formatBudget = (value: number | null): string =>
value === null ? "Unlimited" : `$${formatNumberWithCommas(value, 2)}`;
const formatBudget = (t: TFunction, value: number | null): string =>
value === null ? t("teams.memberTab.unlimited") : `$${formatNumberWithCommas(value, 2)}`;
const hasModelBudgets = (budget: StoredModelMaxBudget | null | undefined): budget is StoredModelMaxBudget =>
budget != null && Object.keys(budget).length > 0;
@ -69,6 +71,7 @@ export default function TeamMemberTab({
onMemberSpendReset,
onMemberBudgetReset,
}: TeamMemberTabProps) {
const { t } = useTranslation();
const [memberToResetSpend, setMemberToResetSpend] = useState<Member | null>(null);
const [memberToResetBudget, setMemberToResetBudget] = useState<Member | null>(null);
const { mutate: resetMemberSpend, isPending: isResettingSpend } = useResetTeamMemberSpend();
@ -124,16 +127,16 @@ 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("teams.memberTab.rateLimits.none");
const membership = findMembership(userId);
const rpmLimit = membership?.litellm_budget_table?.rpm_limit;
const tpmLimit = membership?.litellm_budget_table?.tpm_limit;
const rpmText = rpmLimit != null ? `${formatNumber(rpmLimit)} RPM` : null;
const tpmText = tpmLimit != null ? `${formatNumber(tpmLimit)} TPM` : null;
const rpmText = rpmLimit != null ? t("teams.memberTab.rateLimits.rpm", { value: formatNumber(rpmLimit) }) : null;
const tpmText = tpmLimit != null ? t("teams.memberTab.rateLimits.tpm", { value: formatNumber(tpmLimit) }) : null;
const limits = [rpmText, tpmText].filter(Boolean);
return limits.length > 0 ? limits.join(" / ") : "No Limits";
return limits.length > 0 ? limits.join(" / ") : t("teams.memberTab.rateLimits.none");
};
const { data: uiSettingsData } = useUISettings();
@ -171,9 +174,9 @@ export default function TeamMemberTab({
{
title: (
<span className="flex items-center gap-1">
Model Scope
<SimpleTooltip content="Models this member can access. Empty means they inherit all team models.">
<CircleHelp className="size-4" aria-label="Model scope information" />
{t("teams.memberTab.columns.modelScope.title")}
<SimpleTooltip content={t("teams.memberTab.columns.modelScope.tooltip")}>
<CircleHelp className="size-4" aria-label={t("teams.memberTab.columns.modelScope.ariaLabel")} />
</SimpleTooltip>
</span>
),
@ -181,7 +184,7 @@ export default function TeamMemberTab({
render: (record: Member) => {
const models = getUserAllowedModels(record.user_id);
if (!models) {
return <span className="text-muted-foreground">(all team models)</span>;
return <span className="text-muted-foreground">{t("teams.memberTab.columns.modelScope.allTeamModels")}</span>;
}
const displayed = models.slice(0, 2);
const remaining = models.length - displayed.length;
@ -194,7 +197,9 @@ export default function TeamMemberTab({
))}
{remaining > 0 && (
<SimpleTooltip content={models.slice(2).join(", ")}>
<span className="text-muted-foreground">+{remaining} more</span>
<span className="text-muted-foreground">
{t("teams.memberTab.columns.modelScope.moreModels", { count: remaining })}
</span>
</SimpleTooltip>
)}
</div>
@ -204,9 +209,9 @@ export default function TeamMemberTab({
{
title: (
<span className="flex items-center gap-1">
Current Period Spend (USD)
<SimpleTooltip content="Spend in this member's current budget period, the value checked against their budget. The Budget Period column shows the dates that period covers, or Never resets. Spend above a rollover cap at reset carries into the next period.">
<CircleHelp className="size-4" aria-label="Current period spend information" />
{t("teams.memberTab.columns.currentPeriodSpend.title")}
<SimpleTooltip content={t("teams.memberTab.columns.currentPeriodSpend.tooltip")}>
<CircleHelp className="size-4" aria-label={t("teams.memberTab.columns.currentPeriodSpend.ariaLabel")} />
</SimpleTooltip>
</span>
),
@ -218,9 +223,9 @@ export default function TeamMemberTab({
{
title: (
<span className="flex items-center gap-1">
Total Spend (USD)
<SimpleTooltip content="Cumulative spend by this member within this team, across budget periods. This is a lower bound: lifetime tracking began 2026-04-21, so periods that reset before then are not included. It is never less than Current Period Spend.">
<CircleHelp className="size-4" aria-label="Total spend information" />
{t("teams.memberTab.columns.totalSpend.title")}
<SimpleTooltip content={t("teams.memberTab.columns.totalSpend.tooltip")}>
<CircleHelp className="size-4" aria-label={t("teams.memberTab.columns.totalSpend.ariaLabel")} />
</SimpleTooltip>
</span>
),
@ -232,9 +237,9 @@ export default function TeamMemberTab({
{
title: (
<span className="flex items-center gap-1">
Team Member Budget (USD)
<SimpleTooltip content="Team default follows the team's member budget, so changing it in team settings updates this member too. Custom is set on this member only and ignores later team changes.">
<CircleHelp className="size-4" aria-label="Team member budget information" />
{t("teams.memberTab.columns.memberBudget.title")}
<SimpleTooltip content={t("teams.memberTab.columns.memberBudget.tooltip")}>
<CircleHelp className="size-4" aria-label={t("teams.memberTab.columns.memberBudget.ariaLabel")} />
</SimpleTooltip>
</span>
),
@ -245,10 +250,15 @@ export default function TeamMemberTab({
const source = getUserBudgetSource(record.user_id);
return (
<span className="flex items-center justify-end gap-2">
<MoneyCell value={getUserBudget(record.user_id)} decimals={2} emptyText="Unlimited" showZero />
<MoneyCell
value={getUserBudget(record.user_id)}
decimals={2}
emptyText={t("teams.memberTab.unlimited")}
showZero
/>
{source !== "none" && (
<Badge variant={source === "custom" ? "outline" : "secondary"} data-testid="member-budget-source">
{BUDGET_SOURCE_LABELS[source]}
{t(BUDGET_SOURCE_LABEL_KEYS[source])}
</Badge>
)}
{source === "custom" && canEditTeam && (
@ -259,7 +269,7 @@ export default function TeamMemberTab({
data-testid="reset-member-budget"
onClick={() => setMemberToResetBudget(record)}
>
Use team default
{t("teams.memberTab.columns.memberBudget.useTeamDefault")}
</Button>
)}
</span>
@ -267,7 +277,7 @@ export default function TeamMemberTab({
},
},
{
title: "Budget Period",
title: t("teams.memberTab.columns.budgetPeriod.title"),
key: "budget_reset",
sortValue: (record: Member) => getUserBudgetReset(record.user_id),
render: (record: Member) => renderBudgetPeriod(record.user_id),
@ -275,9 +285,9 @@ export default function TeamMemberTab({
{
title: (
<span className="flex items-center gap-1">
Team Member Rate Limits
<SimpleTooltip content="Rate limits for this member's usage within this team.">
<CircleHelp className="size-4" aria-label="Team member rate limits information" />
{t("teams.memberTab.columns.rateLimits.title")}
<SimpleTooltip content={t("teams.memberTab.columns.rateLimits.tooltip")}>
<CircleHelp className="size-4" aria-label={t("teams.memberTab.columns.rateLimits.ariaLabel")} />
</SimpleTooltip>
</span>
),
@ -292,7 +302,7 @@ export default function TeamMemberTab({
{ teamId: teamData.team_id, userId: memberToResetSpend.user_id },
{
onSuccess: () => {
toast.success("Team member spend reset to $0");
toast.success(t("teams.memberTab.toasts.spendReset"));
setMemberToResetSpend(null);
onMemberSpendReset();
},
@ -307,7 +317,7 @@ export default function TeamMemberTab({
{ teamId: teamData.team_id, userId: memberToResetBudget.user_id },
{
onSuccess: () => {
toast.success("Team member budget reset to the team default");
toast.success(t("teams.memberTab.toasts.budgetReset"));
setMemberToResetBudget(null);
onMemberBudgetReset();
},
@ -317,8 +327,8 @@ export default function TeamMemberTab({
};
const resetSpendDisabledReason = (member: Member): string | null => {
if (!isProxyAdmin && member.user_id === userId) return "Ask a proxy admin to reset your own spend";
if (getUserCurrentPeriodSpend(member.user_id) <= 0) return "No current period spend to reset";
if (!isProxyAdmin && member.user_id === userId) return t("teams.memberTab.resetSpendDisabled.ownSpend");
if (getUserCurrentPeriodSpend(member.user_id) <= 0) return t("teams.memberTab.resetSpendDisabled.noSpend");
return null;
};
@ -343,8 +353,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("teams.memberTab.roleColumn.title")}
roleTooltip={t("teams.memberTab.roleColumn.tooltip")}
extraColumns={extraColumns}
showDeleteForMember={() =>
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
@ -355,27 +365,35 @@ export default function TeamMemberTab({
<Dialog open={memberToResetSpend !== null} onOpenChange={(open) => !open && setMemberToResetSpend(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset Team Member Spend</DialogTitle>
<DialogTitle>{t("teams.memberTab.resetSpendDialog.title")}</DialogTitle>
</DialogHeader>
<p>
Reset current period spend for{" "}
<strong>{memberToResetSpend?.user_email || memberToResetSpend?.user_id}</strong> in this team to{" "}
<strong>$0</strong>?
<Trans
i18nKey="teams.memberTab.resetSpendDialog.prompt"
values={{ member: memberToResetSpend?.user_email || memberToResetSpend?.user_id }}
components={{ strong: <strong /> }}
/>
</p>
<p className="text-sm text-muted-foreground">
Current period spend
{resetSpendPeriod ? ` (${resetSpendPeriod})` : ""}:{" "}
<strong>
${formatNumberWithCommas(getUserCurrentPeriodSpend(memberToResetSpend?.user_id ?? null), 4)}
</strong>
. This is the value checked against the member&apos;s budget. Total spend and logs are preserved.
<Trans
i18nKey={
resetSpendPeriod
? "teams.memberTab.resetSpendDialog.summaryWithPeriod"
: "teams.memberTab.resetSpendDialog.summary"
}
values={{
period: resetSpendPeriod,
amount: formatNumberWithCommas(getUserCurrentPeriodSpend(memberToResetSpend?.user_id ?? null), 4),
}}
components={{ strong: <strong /> }}
/>
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setMemberToResetSpend(null)}>
Cancel
{t("teams.memberTab.resetSpendDialog.cancel")}
</Button>
<Button variant="destructive" onClick={handleResetSpend} disabled={isResettingSpend}>
Reset
{t("teams.memberTab.resetSpendDialog.confirm")}
</Button>
</DialogFooter>
</DialogContent>
@ -383,23 +401,31 @@ export default function TeamMemberTab({
<Dialog open={memberToResetBudget !== null} onOpenChange={(open) => !open && setMemberToResetBudget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset Team Member Budget</DialogTitle>
<DialogTitle>{t("teams.memberTab.resetBudgetDialog.title")}</DialogTitle>
</DialogHeader>
<p>
Remove the custom budget for{" "}
<strong>{memberToResetBudget?.user_email || memberToResetBudget?.user_id}</strong> and put them back on the
team default of <strong>{formatBudget(teamDefaultBudget)}</strong>?
<Trans
i18nKey="teams.memberTab.resetBudgetDialog.prompt"
values={{
member: memberToResetBudget?.user_email || memberToResetBudget?.user_id,
budget: formatBudget(t, teamDefaultBudget),
}}
components={{ strong: <strong /> }}
/>
</p>
<p className="text-sm text-muted-foreground">
Custom budget: <strong>{formatBudget(getUserBudget(memberToResetBudget?.user_id ?? null))}</strong>. Their
spend is kept. Future changes to the team&apos;s member budget will apply to them again.
<Trans
i18nKey="teams.memberTab.resetBudgetDialog.summary"
values={{ budget: formatBudget(t, getUserBudget(memberToResetBudget?.user_id ?? null)) }}
components={{ strong: <strong /> }}
/>
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setMemberToResetBudget(null)}>
Cancel
{t("teams.memberTab.resetBudgetDialog.cancel")}
</Button>
<Button onClick={handleResetBudget} disabled={isResettingBudget}>
Use team default
{t("teams.memberTab.resetBudgetDialog.confirm")}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -30,6 +30,7 @@ import { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingStat
import { ChevronDown, ChevronRight } from "lucide-react";
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 { deriveKeyModelScope } from "../key_scope";
import { KEY_RATE_LIMIT_SORT_FIELDS } from "../key_team_helpers/key_sorting";
@ -50,6 +51,7 @@ interface TeamVirtualKeysTableProps {
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) {
const { t } = useTranslation();
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [tablePagination, setTablePagination] = useState<PaginationState>({
@ -140,8 +142,14 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "token",
accessorKey: "token",
meta: { title: "Key ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Key ID" variant="header-cycle" />,
meta: { title: t("teams.virtualKeysTable.columns.keyId") },
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("teams.virtualKeysTable.columns.keyId")}
variant="header-cycle"
/>
),
size: 120,
enableSorting: true,
cell: (info) => (
@ -151,8 +159,14 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "key_alias",
accessorKey: "key_alias",
meta: { title: "Key Alias" },
header: ({ column }) => <DataTableSortHeader column={column} title="Key Alias" variant="header-cycle" />,
meta: { title: t("teams.virtualKeysTable.columns.keyAlias") },
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("teams.virtualKeysTable.columns.keyAlias")}
variant="header-cycle"
/>
),
size: 150,
enableSorting: true,
cell: (info) => {
@ -167,7 +181,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "key_name",
accessorKey: "key_name",
header: "Secret Key",
header: t("teams.virtualKeysTable.columns.secretKey"),
size: 120,
enableSorting: false,
cell: (info) => <span className="font-mono text-xs">{info.getValue() as string}</span>,
@ -175,7 +189,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "organization_id",
accessorFn: (key) => (key.organization_id ?? key.org_id) || organization?.organization_id || null,
header: "Organization ID",
header: t("teams.virtualKeysTable.columns.organizationId"),
size: 140,
enableSorting: false,
cell: (info) => {
@ -191,7 +205,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "user_email",
accessorKey: "user",
header: "User Email",
header: t("teams.virtualKeysTable.columns.userEmail"),
size: 160,
enableSorting: false,
cell: (info) => {
@ -212,7 +226,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "user_id",
accessorKey: "user_id",
header: "User ID",
header: t("teams.virtualKeysTable.columns.userId"),
size: 70,
enableSorting: false,
cell: (info) => {
@ -234,8 +248,14 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" variant="header-cycle" />,
meta: { title: t("teams.virtualKeysTable.columns.createdAt") },
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("teams.virtualKeysTable.columns.createdAt")}
variant="header-cycle"
/>
),
size: 120,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
@ -243,7 +263,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "created_by",
accessorKey: "created_by",
header: "Created By",
header: t("teams.virtualKeysTable.columns.createdBy"),
size: 130,
enableSorting: false,
cell: (info) => {
@ -263,37 +283,71 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" variant="header-cycle" />,
meta: { title: t("teams.virtualKeysTable.columns.updatedAt") },
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("teams.virtualKeysTable.columns.updatedAt")}
variant="header-cycle"
/>
),
size: 120,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
cell: (info) => (
<DateCell
value={info.getValue() as string | null}
precision="date"
fallback={t("teams.virtualKeysTable.dates.never")}
/>
),
},
{
id: "last_active",
accessorKey: "last_active",
meta: { title: "Last Active" },
header: ({ column }) => <DataTableSortHeader column={column} title="Last Active" />,
meta: { title: t("teams.virtualKeysTable.columns.lastActive") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("teams.virtualKeysTable.columns.lastActive")} />
),
size: 130,
enableSorting: true,
sortDescFirst: false,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
cell: (info) => (
<DateCell
value={info.getValue() as string | null}
precision="date"
fallback={t("teams.virtualKeysTable.dates.unknown")}
/>
),
},
{
id: "expires",
accessorKey: "expires",
meta: { title: "Expires" },
header: ({ column }) => <DataTableSortHeader column={column} title="Expires" />,
meta: { title: t("teams.virtualKeysTable.columns.expires") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("teams.virtualKeysTable.columns.expires")} />
),
size: 120,
enableSorting: true,
sortDescFirst: false,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
cell: (info) => (
<DateCell
value={info.getValue() as string | null}
precision="date"
fallback={t("teams.virtualKeysTable.dates.never")}
/>
),
},
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend (USD)", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" variant="header-cycle" />,
meta: { title: t("teams.virtualKeysTable.columns.spend"), numeric: true },
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("teams.virtualKeysTable.columns.spend")}
variant="header-cycle"
/>
),
size: 100,
enableSorting: true,
cell: (info) => <MoneyCell value={info.getValue() as number | null} decimals={4} />,
@ -301,8 +355,10 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "total_spend",
accessorKey: "total_spend",
meta: { title: "Lifetime Spend", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Lifetime Spend" />,
meta: { title: t("teams.virtualKeysTable.columns.lifetimeSpend"), numeric: true },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("teams.virtualKeysTable.columns.lifetimeSpend")} />
),
size: 130,
enableSorting: true,
sortDescFirst: true,
@ -311,8 +367,14 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "max_budget",
accessorKey: "max_budget",
meta: { title: "Budget (USD)", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Budget (USD)" variant="header-cycle" />,
meta: { title: t("teams.virtualKeysTable.columns.budget"), numeric: true },
header: ({ column }) => (
<DataTableSortHeader
column={column}
title={t("teams.virtualKeysTable.columns.budget")}
variant="header-cycle"
/>
),
size: 160,
enableSorting: true,
cell: (info) => {
@ -325,41 +387,50 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
return memberGate ? (
<span className="block w-full whitespace-nowrap text-right tabular-nums">
${formatNumberWithCommas(memberGate.maxBudget, 0)}{" "}
<span className="text-muted-foreground">(team member)</span>
<span className="text-muted-foreground">{t("teams.virtualKeysTable.budget.teamMember")}</span>
</span>
) : (
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
<MoneyCell
value={info.getValue() as number | null}
decimals={0}
emptyText={t("teams.virtualKeysTable.budget.unlimited")}
showZero
/>
);
},
},
{
id: "budget_reset_at",
accessorKey: "budget_reset_at",
meta: { title: "Budget Reset" },
header: ({ column }) => <DataTableSortHeader column={column} title="Budget Reset" />,
meta: { title: t("teams.virtualKeysTable.columns.budgetReset") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("teams.virtualKeysTable.columns.budgetReset")} />
),
size: 130,
enableSorting: true,
sortDescFirst: false,
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
cell: (info) => (
<DateCell value={info.getValue() as string | null} fallback={t("teams.virtualKeysTable.dates.never")} />
),
},
{
id: "models",
accessorKey: "models",
header: "Models",
header: t("teams.virtualKeysTable.columns.models"),
size: 200,
enableSorting: false,
cell: (info) => {
const models = info.getValue() as string[];
const scope = deriveKeyModelScope(info.row.original.allowed_routes, info.row.original.key_type);
const emptyModelsBadge = !scope.hasModelAccess ? (
<SimpleTooltip content={`Scoped to ${scope.label} routes; this key cannot call any models`}>
<SimpleTooltip content={t("teams.virtualKeysTable.models.noModelAccessTooltip", { scope: scope.label })}>
<Badge variant="secondary" className="mb-1">
No model access
{t("teams.virtualKeysTable.models.noModelAccess")}
</Badge>
</SimpleTooltip>
) : (
<Badge variant="destructive" className="mb-1">
All Proxy Models
{t("teams.virtualKeysTable.models.allProxyModels")}
</Badge>
);
return (
@ -374,7 +445,11 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{models.length > 3 && (
<button
type="button"
aria-label={expandedAccordions[info.row.id] ? "Collapse models" : "Expand models"}
aria-label={
expandedAccordions[info.row.id]
? t("teams.virtualKeysTable.models.collapse")
: t("teams.virtualKeysTable.models.expand")
}
className="rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() =>
setExpandedAccordions((prev) => ({
@ -394,7 +469,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{models.slice(0, 3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index} variant="destructive">
All Proxy Models
{t("teams.virtualKeysTable.models.allProxyModels")}
</Badge>
) : (
<Badge key={index}>
@ -406,7 +481,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
)}
{models.length > 3 && !expandedAccordions[info.row.id] && (
<Badge variant="secondary">
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
{t("teams.virtualKeysTable.models.more", { count: models.length - 3 })}
</Badge>
)}
{expandedAccordions[info.row.id] && (
@ -414,7 +489,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{models.slice(3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index + 3} variant="destructive">
All Proxy Models
{t("teams.virtualKeysTable.models.allProxyModels")}
</Badge>
) : (
<Badge key={index + 3}>
@ -438,7 +513,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
},
{
id: "rate_limits",
meta: { title: "Rate Limits" },
meta: { title: t("teams.virtualKeysTable.columns.rateLimits") },
header: ({ table }) => <DataTableMultiSortHeader table={table} fields={KEY_RATE_LIMIT_SORT_FIELDS} />,
size: 140,
enableSorting: false,
@ -446,14 +521,20 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
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>
{t("teams.virtualKeysTable.rateLimits.tpm")}
{key.tpm_limit !== null ? key.tpm_limit : t("teams.virtualKeysTable.rateLimits.unlimited")}
</div>
<div>
{t("teams.virtualKeysTable.rateLimits.rpm")}
{key.rpm_limit !== null ? key.rpm_limit : t("teams.virtualKeysTable.rateLimits.unlimited")}
</div>
</div>
);
},
},
],
[expandedAccordions, teamMemberBudgets, teamId, organization?.organization_id],
[expandedAccordions, teamMemberBudgets, teamId, organization?.organization_id, t],
);
const handleSortingChange = useCallback((updaterOrValue: React.SetStateAction<SortingState>) => {
@ -489,7 +570,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
enableColumnResizing
columnResizeMode="onChange"
isLoading={isLoading || isFetching}
loadingMessage="Loading keys..."
loadingMessage={t("teams.virtualKeysTable.loading")}
size="compact"
toolbar={(table) => (
<>
@ -497,33 +578,38 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
table={table}
searchValue={searchInput}
onSearchChange={handleSearchChange}
searchPlaceholder="Search by key alias or ID…"
searchPlaceholder={t("teams.virtualKeysTable.searchPlaceholder")}
onRefresh={() => refetch?.()}
isRefreshing={isFetching}
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={{ user_id: "User ID", key_hash: "Key ID" }}
filterLabels={{
user_id: t("teams.virtualKeysTable.filters.userId"),
key_hash: t("teams.virtualKeysTable.filters.keyId"),
}}
/>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description={`Narrow down keys for ${teamAlias ?? "this team"}`}
title={t("teams.virtualKeysTable.filters.title")}
description={t("teams.virtualKeysTable.filters.description", {
team: teamAlias ?? t("teams.virtualKeysTable.filters.thisTeam"),
})}
>
{({ get, set }) => (
<>
<DataTableFilterField label="User ID">
<DataTableFilterField label={t("teams.virtualKeysTable.filters.userId")}>
<Input
value={(get("user_id") as string) ?? ""}
onChange={(event) => set("user_id", event.target.value)}
placeholder="Filter by user ID…"
placeholder={t("teams.virtualKeysTable.filters.userIdPlaceholder")}
/>
</DataTableFilterField>
<DataTableFilterField label="Key ID">
<DataTableFilterField label={t("teams.virtualKeysTable.filters.keyId")}>
<Input
value={(get("key_hash") as string) ?? ""}
onChange={(event) => set("key_hash", event.target.value)}
placeholder="Enter Key ID…"
placeholder={t("teams.virtualKeysTable.filters.keyIdPlaceholder")}
/>
</DataTableFilterField>
</>

View file

@ -1,3 +1,4 @@
import type { ParseKeys } from "i18next";
import {
entriesToModelMaxBudget,
modelMaxBudgetToEntries,
@ -44,7 +45,7 @@ const NULLABLE_NUMERIC_FIELDS: ReadonlySet<string> = new Set([
"temp_budget_increase",
]);
export const TEMP_BUDGET_PAIR_MESSAGE = "Set both a temporary budget increase and its expiry, or neither";
export const TEMP_BUDGET_PAIR_MESSAGE_KEY: ParseKeys = "teams.memberFormValues.tempBudgetPairRequired";
const isUnset = (value: MemberFieldValue): boolean => value === null || value === undefined || value === "";

View file

@ -5,6 +5,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { RotateCw, Save } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "@/lib/toast";
import { getPermissionInfo } from "./permission_definitions";
@ -15,6 +16,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);
@ -32,7 +34,7 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
setSelectedPermissions(teamPermissions);
setHasChanges(false);
} catch (error) {
toast.fromError("Failed to load permissions");
toast.fromError(t("teams.memberPermissions.toasts.loadFailed"));
console.error("Error fetching permissions:", error);
} finally {
setLoading(false);
@ -56,10 +58,10 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
if (!accessToken) return;
setSaving(true);
await teamPermissionsUpdateCall(accessToken, teamId, selectedPermissions);
toast.success("Permissions updated successfully");
toast.success(t("teams.memberPermissions.toasts.updated"));
setHasChanges(false);
} catch (error) {
toast.fromError("Failed to update permissions");
toast.fromError(t("teams.memberPermissions.toasts.updateFailed"));
console.error("Error updating permissions:", error);
} finally {
setSaving(false);
@ -71,7 +73,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("teams.memberPermissions.loading")}</div>;
}
const hasPermissions = permissions.length > 0;
@ -79,35 +81,33 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
return (
<Card className="block bg-card 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">
<h3 className="text-lg font-medium text-foreground mb-2 sm:mb-0">Member Permissions</h3>
<h3 className="text-lg font-medium text-foreground mb-2 sm:mb-0">{t("teams.memberPermissions.title")}</h3>
{canEditTeam && hasChanges && (
<div className="flex gap-3">
<Button variant="outline" onClick={handleReset}>
<RotateCw className="size-3.5" />
Reset
{t("teams.memberPermissions.reset")}
</Button>
<Button onClick={handleSave} disabled={saving}>
<Save className="size-3.5" />
Save Changes
{t("teams.memberPermissions.save")}
</Button>
</div>
)}
</div>
<p className="mb-6 text-sm text-muted-foreground">
Control what team members can do when they are not team admins.
</p>
<p className="mb-6 text-sm text-muted-foreground">{t("teams.memberPermissions.description")}</p>
{hasPermissions ? (
<div className="overflow-x-auto">
<Table className="min-w-full">
<TableHeader>
<TableRow>
<TableHead>Method</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Description</TableHead>
<TableHead>{t("teams.memberPermissions.columns.method")}</TableHead>
<TableHead>{t("teams.memberPermissions.columns.endpoint")}</TableHead>
<TableHead>{t("teams.memberPermissions.columns.description")}</TableHead>
<TableHead className="sticky right-0 bg-card shadow-[-4px_0_4px_-4px_rgba(0,0,0,0.1)] text-center">
Allow Access
{t("teams.memberPermissions.columns.allowAccess")}
</TableHead>
</TableRow>
</TableHeader>
@ -145,7 +145,7 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
</div>
) : (
<div className="py-12">
<p className="text-center text-sm text-muted-foreground">No permissions available</p>
<p className="text-center text-sm text-muted-foreground">{t("teams.memberPermissions.empty")}</p>
</div>
)}
</Card>

View file

@ -1,4 +1,6 @@
import type { ParseKeys, TFunction } from "i18next";
import { z } from "zod";
import i18n from "@/i18n/i18n";
import { numberOrNull } from "@/lib/forms/numberOrNull";
import {
@ -85,23 +87,26 @@ export const isTeamAdminDurationSettingsField = (
field: TeamAdminSettingsField,
): field is TeamAdminDurationSettingsField => field === "team_member_budget_duration";
const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([
["tpm_limit", "Tokens per minute Limit (TPM)"],
["rpm_limit", "Requests per minute Limit (RPM)"],
["max_budget", "Max Budget (USD)"],
[TEAM_ADMIN_METADATA_FIELD, "Team Metadata"],
["soft_budget", "Soft Budget (USD)"],
["team_member_budget", "Member Default Budget (USD)"],
["team_member_budget_duration", "Member Default Budget Duration"],
["team_member_tpm_limit", "Member Default TPM Limit"],
["team_member_rpm_limit", "Member Default RPM Limit"],
[TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION, "Raise the team's max budget"],
["projects", "Create and update projects"],
["member_key_budgets", "Update budgets on team members' keys"],
[TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION, "Create keys for team members"],
const TEAM_ADMIN_FIELD_LABEL_KEYS: ReadonlyMap<string, ParseKeys> = new Map<string, ParseKeys>([
["tpm_limit", "teams.adminEditAccess.fieldLabels.tpmLimit"],
["rpm_limit", "teams.adminEditAccess.fieldLabels.rpmLimit"],
["max_budget", "teams.adminEditAccess.fieldLabels.maxBudget"],
[TEAM_ADMIN_METADATA_FIELD, "teams.adminEditAccess.fieldLabels.metadata"],
["soft_budget", "teams.adminEditAccess.fieldLabels.softBudget"],
["team_member_budget", "teams.adminEditAccess.fieldLabels.teamMemberBudget"],
["team_member_budget_duration", "teams.adminEditAccess.fieldLabels.teamMemberBudgetDuration"],
["team_member_tpm_limit", "teams.adminEditAccess.fieldLabels.teamMemberTpmLimit"],
["team_member_rpm_limit", "teams.adminEditAccess.fieldLabels.teamMemberRpmLimit"],
[TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION, "teams.adminEditAccess.fieldLabels.raiseMaxBudget"],
["projects", "teams.adminEditAccess.fieldLabels.projects"],
["member_key_budgets", "teams.adminEditAccess.fieldLabels.memberKeyBudgets"],
[TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION, "teams.adminEditAccess.fieldLabels.createMemberKeys"],
]);
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
export const teamAdminFieldLabel = (field: string, t: TFunction = i18n.t.bind(i18n)): string => {
const key = TEAM_ADMIN_FIELD_LABEL_KEYS.get(field);
return key === undefined ? field : t(key);
};
export type TeamAdminSettingsValues = { readonly [F in TeamAdminNumericSettingsField]?: string | number | null } & {
readonly [F in TeamAdminDurationSettingsField]?: string | null;
@ -218,11 +223,14 @@ export interface TeamAdminMetadataPairIssue {
export const teamAdminMetadataPairIssue = (
pair: Partial<MetadataPair> | undefined,
context: TeamAdminMetadataContext,
t: TFunction = i18n.t.bind(i18n),
): TeamAdminMetadataPairIssue | null => {
if (pair?.key === undefined) return null;
if (context.proxyControlledKeys.has(pair.key)) return { path: "key", message: "Only proxy admins can set this key" };
if (context.proxyControlledKeys.has(pair.key)) {
return { path: "key", message: t("teams.adminEditAccess.metadataIssues.proxyControlledKey") };
}
if (!context.declaredKeys.has(pair.key) || !(pair.key in context.storedMetadata)) return null;
const value = pairValue(pair);
const clearsStoredValue = isBlank(value) && canonicalJson(value) !== canonicalJson(context.storedMetadata[pair.key]);
return clearsStoredValue ? { path: "value", message: "This field cannot be left empty" } : null;
return clearsStoredValue ? { path: "value", message: t("teams.adminEditAccess.metadataIssues.cannotBeEmpty") } : null;
};

View file

@ -1,3 +1,7 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key";
export const ALL_PROXY_MODELS = "all-proxy-models";
@ -42,20 +46,21 @@ export function normalizeTeamModelSelection(models: string[] | undefined): strin
return models && models.length > 0 ? models : [NO_DEFAULT_MODELS];
}
export const describeGroups = (names: string[]): string =>
names.length > 1 ? `access groups ${names.join(", ")}` : `access group ${names[0]}`;
export const describeGroups = (names: string[], t: TFunction = i18n.t.bind(i18n)): string =>
t("teams.modelAccess.groups", { count: names.length, names: names.join(", ") });
export function computeTeamModelBadges(
models: string[],
accessGroupModels: string[],
accessGroupDetails: TeamAccessGroupModelGrant[] | undefined,
t: TFunction = i18n.t.bind(i18n),
): TeamModelBadge[] {
const grants = accessGroupDetails ?? [];
const groupNamesFor = (model: string): string[] =>
grants.filter((g) => g.models.includes(model)).map((g) => g.access_group_name);
const viaGroups = (model: string): string => {
const names = groupNamesFor(model);
return names.length > 0 ? describeGroups(names) : "an access group";
return names.length > 0 ? describeGroups(names, t) : t("teams.modelAccess.anAccessGroup");
};
const allProxy = models.length === 0 || models.includes(ALL_PROXY_MODELS);
@ -65,16 +70,16 @@ export function computeTeamModelBadges(
);
const allProxyBadge: TeamModelBadge = {
label: "All proxy models",
label: t("teams.modelAccess.allProxy.label"),
kind: "all-proxy",
tooltip: models.includes(ALL_PROXY_MODELS)
? "Granted by the All Proxy Models entry in the team's model list"
: "The team's model list is empty, so it can access every model on the proxy",
? t("teams.modelAccess.allProxy.sentinelTooltip")
: t("teams.modelAccess.allProxy.emptyListTooltip"),
};
const noDefaultBadge: TeamModelBadge = {
label: "No default models",
label: t("teams.modelAccess.noDefault.label"),
kind: "no-default",
tooltip: "No models are granted directly. Access comes only from access groups",
tooltip: t("teams.modelAccess.noDefault.tooltip"),
};
const headBadge = (): TeamModelBadge[] => {
if (allProxy) return [allProxyBadge];
@ -90,15 +95,15 @@ export function computeTeamModelBadges(
kind: "direct",
tooltip:
groupNamesFor(m).length > 0
? `Granted directly in the team's model list, and also via ${viaGroups(m)}`
: "Granted directly in the team's model list",
? t("teams.modelAccess.direct.alsoViaGroupsTooltip", { groups: viaGroups(m) })
: t("teams.modelAccess.direct.tooltip"),
}),
),
...groupModels.map(
(m): TeamModelBadge => ({
label: m,
kind: "access-group",
tooltip: `Granted via ${viaGroups(m)}`,
tooltip: t("teams.modelAccess.viaGroupsTooltip", { groups: viaGroups(m) }),
}),
),
];

View file

@ -1,10 +1,10 @@
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "@/lib/http/schema";
import { toast } from "@/lib/toast";
import {
chunk,
MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES,
pluralize,
type MemberBudgetResetPending,
type TeamUpdatePayload,
} from "./memberBudgetReset";
@ -28,6 +28,7 @@ type ResetRun =
| { ok: false; attempted: number; error: unknown };
export const useMemberBudgetReset = (gateway: MemberBudgetResetGateway) => {
const { t } = useTranslation();
const [state, setState] = useState<MemberBudgetResetState>({ phase: "idle" });
const activeRun = useRef<object | null>(null);
const isCurrent = (run: object) => activeRun.current === run;
@ -60,10 +61,10 @@ export const useMemberBudgetReset = (gateway: MemberBudgetResetGateway) => {
const total = pending.userIds.length;
if (outcome.attempted > 0) {
toast.error(
`Reset ${outcome.attempted} of ${total} member ${pluralize(total, "budget", "budgets")}; the rest could not be reset`,
t("teams.resetMemberBudgets.toasts.partiallyReset", { count: total, attempted: outcome.attempted }),
);
} else {
toast.fromError("Team updated, but member budgets could not be reset");
toast.fromError(t("teams.resetMemberBudgets.toasts.resetFailed"));
}
setState({ phase: "resetFailed", pending, attempted: outcome.attempted });
await refreshTeamData();
@ -71,13 +72,9 @@ export const useMemberBudgetReset = (gateway: MemberBudgetResetGateway) => {
}
const failed = outcome.results.filter((r) => !r.success);
if (failed.length > 0) {
toast.error(
`Team updated, but ${failed.length} member ${pluralize(failed.length, "budget", "budgets")} could not be reset`,
);
toast.error(t("teams.resetMemberBudgets.toasts.someFailed", { count: failed.length }));
} else {
toast.success(
`Reset ${outcome.attempted} member ${pluralize(outcome.attempted, "budget", "budgets")} to the team default`,
);
toast.success(t("teams.resetMemberBudgets.toasts.reset", { count: outcome.attempted }));
}
setState({ phase: "idle" });
await refreshTeamData();
@ -118,7 +115,7 @@ export const useMemberBudgetReset = (gateway: MemberBudgetResetGateway) => {
setState({ phase: "idle" });
try {
await gateway.saveTeam(pending.updateData);
if (isCurrent(run)) toast.success("Team settings updated successfully");
if (isCurrent(run)) toast.success(t("teams.resetMemberBudgets.toasts.teamUpdated"));
} catch (error) {
console.error("Error updating team:", error);
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,8 @@
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
dayjs.extend(utc);
@ -24,8 +27,6 @@ export interface BudgetResetSchedule {
tooltip: string;
}
const ROLLOVER_NOTE = "Spend above a rollover cap at reset carries into the next period.";
const DURATION_WORD_ALIASES: Record<string, string> = {
hourly: "1h",
daily: "24h",
@ -73,6 +74,7 @@ export function formatBudgetPeriod(start: Dayjs, end: Dayjs): string {
export function describeBudgetResetSchedule(
budgetDuration: string | null | undefined,
budgetResetAt: string | null | undefined,
t: TFunction = i18n.t.bind(i18n),
): BudgetResetSchedule {
const resetDate = budgetResetAt ? dayjs(budgetResetAt) : null;
if (resetDate?.isValid()) {
@ -82,28 +84,32 @@ export function describeBudgetResetSchedule(
? formatBudgetPeriod(getBudgetPeriodStart(budgetDuration, resetDate.utc()).local(), resetDate)
: null;
const coverage = period
? `Current Period Spend covers ${period} and resets every ${budgetDuration}.`
: "Current Period Spend resets on a schedule.";
? t("budgetSchedule.coverageWithPeriod", { period, duration: budgetDuration })
: t("budgetSchedule.coverageWithoutPeriod");
const tooltipValues = { coverage, label, timeZone, rolloverNote: t("budgetSchedule.rolloverNote") };
return {
label,
resetAt: resetDate.toISOString(),
period,
tooltip: `${coverage} Next reset ${label} (${timeZone}). ${ROLLOVER_NOTE}`,
tooltip: t("budgetSchedule.scheduledTooltip", tooltipValues),
};
}
if (!budgetDuration) {
return {
label: "Never resets",
label: t("budgetSchedule.neverResets.label"),
resetAt: null,
period: null,
tooltip: "This budget has no reset window, so this is lifetime spend for as long as the membership exists.",
tooltip: t("budgetSchedule.neverResets.tooltip"),
};
}
return {
label: "Unknown",
label: t("budgetSchedule.unscheduled.label"),
resetAt: null,
period: null,
tooltip: `Resets every ${budgetDuration}, but the next reset time has not been scheduled yet. ${ROLLOVER_NOTE}`,
tooltip: t("budgetSchedule.unscheduled.tooltip", {
duration: budgetDuration,
rolloverNote: t("budgetSchedule.rolloverNote"),
}),
};
}