mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-03 07:23:33 +00:00
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:
parent
bfef2e1e56
commit
2feef1f636
47 changed files with 3946 additions and 1600 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "user" 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' 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'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's role (one of: "proxy_admin", "proxy_admin_viewer",
|
||||
"internal_user", "internal_user_viewer")
|
||||
</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., "team-1,team-2")
|
||||
</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., "100")
|
||||
{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., "30d", "1mo")
|
||||
{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., "gpt-3.5-turbo,gpt-4")
|
||||
{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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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'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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 === "";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue