feat(ui): i18n for users table and info view

This commit is contained in:
吹雪 2026-06-11 06:21:18 +09:00
parent fc31bc2d78
commit fa7ee22cba
6 changed files with 329 additions and 123 deletions

View file

@ -299,6 +299,8 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
handleDelete,
handleResetPassword,
() => {}, // placeholder function, will be overridden in UserDataTable
undefined,
t,
);
return (

View file

@ -5,6 +5,7 @@ import { UserInfo } from "./types";
import { PencilAltIcon, TrashIcon, InformationCircleIcon, RefreshIcon } from "@heroicons/react/outline";
import { CopyOutlined } from "@ant-design/icons";
import { formatNumberWithCommas, copyToClipboard } from "@/utils/dataUtils";
import type { TFunction } from "i18next";
interface SelectionOptions {
selectedUsers: UserInfo[];
@ -21,12 +22,15 @@ export const columns = (
handleDelete: (user: UserInfo) => void,
handleResetPassword: (userId: string) => void,
handleUserClick: (userId: string, openInEditMode?: boolean) => void,
selectionOptions?: SelectionOptions,
selectionOptions: SelectionOptions | undefined,
t: TFunction,
): ColumnDef<UserInfo>[] => {
const tr = (key: string, opts?: Record<string, unknown>) => t(`viewUsers.columns.${key}`, opts ?? {});
// Backend sortable columns: user_id, user_email, created_at, spend, user_alias, user_role
const baseColumns: ColumnDef<UserInfo>[] = [
{
header: "User ID",
header: tr("userId"),
accessorKey: "user_id",
enableSorting: true,
cell: ({ row }) => (
@ -35,11 +39,11 @@ export const columns = (
<span className="text-xs">{row.original.user_id ? `${row.original.user_id.slice(0, 7)}...` : "-"}</span>
</Tooltip>
{row.original.user_id && (
<Tooltip title="Copy User ID">
<Tooltip title={tr("copyUserId")}>
<CopyOutlined
onClick={(e) => {
e.stopPropagation();
copyToClipboard(row.original.user_id, "User ID copied to clipboard");
copyToClipboard(row.original.user_id, tr("userIdCopied"));
}}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
@ -49,48 +53,53 @@ export const columns = (
),
},
{
header: "Email",
header: tr("email"),
accessorKey: "user_email",
enableSorting: true,
cell: ({ row }) => <span className="text-xs">{row.original.user_email || "-"}</span>,
},
{
id: "status",
header: "Status",
header: tr("status"),
enableSorting: false,
cell: ({ row }) => {
const isScimInactive =
(row.original.metadata as Record<string, unknown> | null | undefined)?.scim_active === false;
if (isScimInactive) {
return (
<Tooltip title="Deactivated via SCIM (external identity provider). The user's virtual keys are blocked.">
<Tooltip
title={tr(
"scimInactiveTooltip",
"Deactivated via SCIM (external identity provider). The user's virtual keys are blocked.",
)}
>
<Tag color="red" data-testid={`user-status-${row.original.user_id}`}>
Inactive
{tr("inactive")}
</Tag>
</Tooltip>
);
}
return (
<Tag color="green" data-testid={`user-status-${row.original.user_id}`}>
Active
{tr("active")}
</Tag>
);
},
},
{
header: "Global Proxy Role",
header: tr("globalProxyRole"),
accessorKey: "user_role",
enableSorting: true,
cell: ({ row }) => <span className="text-xs">{possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}</span>,
},
{
header: "User Alias",
header: tr("userAlias"),
accessorKey: "user_alias",
enableSorting: false,
cell: ({ row }) => <span className="text-xs">{row.original.user_alias || "-"}</span>,
},
{
header: "Spend (USD)",
header: tr("spendUsd"),
accessorKey: "spend",
enableSorting: true,
cell: ({ row }) => (
@ -98,18 +107,23 @@ export const columns = (
),
},
{
header: "Budget (USD)",
header: tr("budgetUsd"),
accessorKey: "max_budget",
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs">{row.original.max_budget !== null ? row.original.max_budget : "Unlimited"}</span>
<span className="text-xs">{row.original.max_budget !== null ? row.original.max_budget : tr("unlimited")}</span>
),
},
{
header: () => (
<div className="flex items-center gap-2">
<span>SSO ID</span>
<Tooltip title="SSO ID is the ID of the user in the SSO provider. If the user is not using SSO, this will be null.">
<span>{tr("ssoId")}</span>
<Tooltip
title={tr(
"ssoIdTooltip",
"SSO ID is the ID of the user in the SSO provider. If the user is not using SSO, this will be null.",
)}
>
<InformationCircleIcon className="w-4 h-4" />
</Tooltip>
</div>
@ -121,25 +135,27 @@ export const columns = (
),
},
{
header: "Virtual Keys",
header: tr("virtualKeys"),
accessorKey: "key_count",
enableSorting: false,
cell: ({ row }) => (
<Grid numItems={2}>
{row.original.key_count > 0 ? (
<Badge size="xs" color="indigo">
{row.original.key_count} {row.original.key_count === 1 ? "Key" : "Keys"}
{t
? t("viewUsers.columns.key", { count: row.original.key_count })
: `${row.original.key_count} ${row.original.key_count === 1 ? "Key" : "Keys"}`}
</Badge>
) : (
<Badge size="xs" color="gray">
No Keys
{tr("noKeys")}
</Badge>
)}
</Grid>
),
},
{
header: "Created At",
header: t ? t("common.createdAt") : "Created At",
accessorKey: "created_at",
enableSorting: true,
cell: ({ row }) => (
@ -149,7 +165,7 @@ export const columns = (
),
},
{
header: "Updated At",
header: t ? t("common.updatedAt") : "Updated At",
accessorKey: "updated_at",
enableSorting: false,
cell: ({ row }) => (
@ -160,11 +176,11 @@ export const columns = (
},
{
id: "actions",
header: "Actions",
header: t ? t("common.actions") : "Actions",
enableSorting: false,
cell: ({ row }) => (
<div className="flex gap-2">
<Tooltip title="Edit user details">
<Tooltip title={tr("editUserDetails")}>
<Icon
icon={PencilAltIcon}
size="sm"
@ -172,7 +188,7 @@ export const columns = (
className="cursor-pointer hover:text-blue-600"
/>
</Tooltip>
<Tooltip title="Delete user">
<Tooltip title={tr("deleteUser")}>
<Icon
icon={TrashIcon}
size="sm"
@ -180,7 +196,7 @@ export const columns = (
className="cursor-pointer hover:text-red-600"
/>
</Tooltip>
<Tooltip title="Reset Password">
<Tooltip title={tr("resetPassword")}>
<Icon
icon={RefreshIcon}
size="sm"

View file

@ -10,6 +10,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, CircleUserRound } from "lucide-react";
import { useTranslation } from "react-i18next";
interface FilterState {
email: string;
@ -76,6 +77,7 @@ export function UserDataTable({
currentPage,
handlePageChange,
}: UserDataTableProps) {
const { t } = useTranslation();
const [sorting, setSorting] = React.useState<SortingState>([
{
id: currentSort?.sortBy || "created_at",
@ -143,6 +145,7 @@ export function UserDataTable({
isIndeterminate,
}
: undefined,
t,
);
}
return originalColumns;
@ -157,6 +160,7 @@ export function UserDataTable({
selectedUsers,
isAllSelected,
isIndeterminate,
t,
]);
const table = useReactTable({
@ -220,7 +224,7 @@ export function UserDataTable({
<div className="flex flex-wrap items-center gap-3">
{/* Email Search */}
<FilterInput
placeholder="Search by email..."
placeholder={t("viewUsers.table.searchByEmail")}
value={filters.email}
onChange={(value) => updateFilters({ email: value })}
icon={Search}
@ -246,14 +250,14 @@ export function UserDataTable({
<div className="flex flex-wrap items-center gap-3 mt-3">
{/* User ID Search */}
<FilterInput
placeholder="Filter by User ID"
placeholder={t("viewUsers.table.filterByUserId")}
value={filters.user_id}
onChange={(value) => updateFilters({ user_id: value })}
icon={User}
/>
<FilterInput
placeholder="Filter by SSO ID"
placeholder={t("viewUsers.table.filterBySsoId")}
value={filters.sso_user_id}
onChange={(value) => updateFilters({ sso_user_id: value })}
icon={CircleUserRound}
@ -264,7 +268,7 @@ export function UserDataTable({
<Select
value={filters.user_role}
onValueChange={(value) => updateFilters({ user_role: value })}
placeholder="Select Role"
placeholder={t("viewUsers.table.selectRole")}
>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(([key, value]) => (
@ -280,7 +284,7 @@ export function UserDataTable({
<Select
value={filters.team}
onValueChange={(value) => updateFilters({ team: value })}
placeholder="Select Team"
placeholder={t("viewUsers.table.selectTeam")}
>
{teams?.map((team) => (
<SelectItem key={team.team_id} value={team.team_id}>
@ -298,15 +302,17 @@ export function UserDataTable({
<Skeleton.Input active style={{ width: 192, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
Showing{" "}
{userListResponse && userListResponse.users && userListResponse.users.length > 0
? (userListResponse.page - 1) * userListResponse.page_size + 1
: 0}{" "}
-{" "}
{userListResponse && userListResponse.users
? Math.min(userListResponse.page * userListResponse.page_size, userListResponse.total)
: 0}{" "}
of {userListResponse ? userListResponse.total : 0} results
{t("viewUsers.table.showing", {
from:
userListResponse && userListResponse.users && userListResponse.users.length > 0
? (userListResponse.page - 1) * userListResponse.page_size + 1
: 0,
to:
userListResponse && userListResponse.users
? Math.min(userListResponse.page * userListResponse.page_size, userListResponse.total)
: 0,
total: userListResponse ? userListResponse.total : 0,
})}
</span>
)}
@ -326,7 +332,7 @@ export function UserDataTable({
currentPage === 1 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
}`}
>
Previous
{t("common.previous")}
</button>
<button
onClick={() => handlePageChange(currentPage + 1)}
@ -337,7 +343,7 @@ export function UserDataTable({
: "hover:bg-gray-50"
}`}
>
Next
{t("common.next")}
</button>
</>
)}
@ -393,7 +399,7 @@ export function UserDataTable({
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>🚅 Loading users...</p>
<p>🚅 {t("viewUsers.table.loadingUsers")}</p>
</div>
</TableCell>
</TableRow>
@ -427,7 +433,7 @@ export function UserDataTable({
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No users found</p>
<p>{t("viewUsers.table.noUsersFound")}</p>
</div>
</TableCell>
</TableRow>

View file

@ -41,6 +41,7 @@ import { CopyIcon, CheckIcon } from "lucide-react";
import NotificationsManager from "../molecules/notifications_manager";
import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import { useTranslation } from "react-i18next";
interface UserInfoViewProps {
userId: string;
@ -69,6 +70,7 @@ export default function UserInfoView({
initialTab = 0,
startInEditMode = false,
}: UserInfoViewProps) {
const { t } = useTranslation();
const [userData, setUserData] = useState<UserInfoV2Response | null>(null);
const [teamDetails, setTeamDetails] = useState<TeamDisplayInfo[]>([]);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -132,7 +134,7 @@ export default function UserInfoView({
setUserModels(availableModels);
} catch (error) {
console.error("Error fetching user data:", error);
NotificationsManager.fromBackend("Failed to fetch user data");
NotificationsManager.fromBackend(t("viewUsers.notifications.failedFetchUserData"));
} finally {
setIsLoading(false);
}
@ -177,7 +179,7 @@ export default function UserInfoView({
user_id: userId,
};
await teamMemberAddCall(accessToken, selectedTeamId, member);
NotificationsManager.success("User added to team successfully");
NotificationsManager.success(t("viewUsers.notifications.userAddedToTeam"));
setIsAddTeamModalOpen(false);
// Re-fetch user data to refresh teams
const data = await userGetInfoV2(accessToken, userId);
@ -197,7 +199,7 @@ export default function UserInfoView({
}
} catch (error: any) {
console.error("Error adding user to team:", error);
NotificationsManager.fromBackend(error?.message || "Failed to add user to team");
NotificationsManager.fromBackend(error?.message || t("viewUsers.notifications.failedAddToTeam"));
} finally {
setIsAddingTeam(false);
}
@ -217,7 +219,7 @@ export default function UserInfoView({
user_id: userId,
};
await teamMemberDeleteCall(accessToken, teamToRemove.team_id, member);
NotificationsManager.success("User removed from team successfully");
NotificationsManager.success(t("viewUsers.notifications.userRemovedFromTeam"));
setIsRemoveTeamModalOpen(false);
setTeamToRemove(null);
// Re-fetch user data to refresh teams
@ -238,7 +240,7 @@ export default function UserInfoView({
}
} catch (error: any) {
console.error("Error removing user from team:", error);
NotificationsManager.fromBackend(error?.message || "Failed to remove user from team");
NotificationsManager.fromBackend(error?.message || t("viewUsers.notifications.failedRemoveFromTeam"));
} finally {
setIsRemovingTeam(false);
}
@ -253,16 +255,16 @@ export default function UserInfoView({
const handleResetPassword = async () => {
if (!accessToken) {
NotificationsManager.fromBackend("Access token not found");
NotificationsManager.fromBackend(t("viewUsers.notifications.accessTokenNotFound"));
return;
}
try {
NotificationsManager.success("Generating password reset link...");
NotificationsManager.success(t("viewUsers.notifications.generatingPasswordResetLink"));
const data = await invitationCreateCall(accessToken, userId);
setInvitationLinkData(data);
setIsInvitationLinkModalVisible(true);
} catch (error) {
NotificationsManager.fromBackend("Failed to generate password reset link");
NotificationsManager.fromBackend(t("viewUsers.notifications.failedGeneratePasswordReset"));
}
};
@ -271,14 +273,14 @@ export default function UserInfoView({
if (!accessToken) return;
setIsDeletingUser(true);
await userDeleteCall(accessToken, [userId]);
NotificationsManager.success("User deleted successfully");
NotificationsManager.success(t("viewUsers.notifications.userDeletedSuccess"));
if (onDelete) {
onDelete();
}
onClose();
} catch (error) {
console.error("Error deleting user:", error);
NotificationsManager.fromBackend("Failed to delete user");
NotificationsManager.fromBackend(t("viewUsers.notifications.failedDeleteUser"));
} finally {
setIsDeleteModalOpen(false);
setIsDeletingUser(false);
@ -306,11 +308,11 @@ export default function UserInfoView({
metadata: formValues.metadata ?? userData.metadata,
});
NotificationsManager.success("User updated successfully");
NotificationsManager.success(t("viewUsers.notifications.userUpdatedSingle"));
setIsEditing(false);
} catch (error) {
console.error("Error updating user:", error);
NotificationsManager.fromBackend("Failed to update user");
NotificationsManager.fromBackend(t("viewUsers.notifications.failedUpdateUser"));
}
};
@ -318,9 +320,9 @@ export default function UserInfoView({
return (
<div className="p-4">
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
Back to Users
{t("viewUsers.userInfoView.backToUsers")}
</Button>
<Text>Loading user data...</Text>
<Text>{t("viewUsers.userInfoView.loadingUserData")}</Text>
</div>
);
}
@ -329,9 +331,9 @@ export default function UserInfoView({
return (
<div className="p-4">
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
Back to Users
{t("viewUsers.userInfoView.backToUsers")}
</Button>
<Text>User not found</Text>
<Text>{t("viewUsers.userInfoView.userNotFound")}</Text>
</div>
);
}
@ -365,9 +367,9 @@ export default function UserInfoView({
<div className="flex justify-between items-center mb-6">
<div>
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
Back to Users
{t("viewUsers.userInfoView.backToUsers")}
</Button>
<Title>{userData.user_email || "User"}</Title>
<Title>{userData.user_email || t("viewUsers.userInfoView.userFallbackTitle")}</Title>
<div className="flex items-center cursor-pointer">
<Text className="text-gray-500 font-mono">{userData.user_id}</Text>
<AntdButton
@ -386,7 +388,7 @@ export default function UserInfoView({
{userRole && rolesWithWriteAccess.includes(userRole) && (
<div className="flex items-center space-x-2">
<Button icon={RefreshIcon} variant="secondary" onClick={handleResetPassword} className="flex items-center">
Reset Password
{t("viewUsers.userInfoView.resetPassword")}
</Button>
<Button
icon={TrashIcon}
@ -394,7 +396,7 @@ export default function UserInfoView({
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center text-red-500 border-red-500 hover:text-red-600 hover:border-red-600"
>
Delete User
{t("viewUsers.userInfoView.deleteUser")}
</Button>
</div>
)}
@ -402,18 +404,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("viewUsers.deleteModal.title")}
message={t("viewUsers.deleteModal.message")}
resourceInformationTitle={t("viewUsers.deleteModal.resourceInfoTitle")}
resourceInformation={[
{ label: "Email", value: userData.user_email },
{ label: "User ID", value: userData.user_id, code: true },
{ label: t("viewUsers.deleteModal.labelEmail"), value: userData.user_email },
{ label: t("viewUsers.deleteModal.labelUserId"), value: userData.user_id, code: true },
{
label: "Global Proxy Role",
label: t("viewUsers.deleteModal.labelGlobalProxyRole"),
value: (userData.user_role && possibleUIRoles?.[userData.user_role]?.ui_label) || userData.user_role || "-",
},
{
label: "Total Spend (USD)",
label: t("viewUsers.deleteModal.labelTotalSpend"),
value: userData.spend !== null && userData.spend !== undefined ? userData.spend.toFixed(2) : undefined,
},
]}
@ -424,8 +426,8 @@ export default function UserInfoView({
<TabGroup defaultIndex={activeTab} onIndexChange={setActiveTab}>
<TabList className="mb-4">
<Tab>Overview</Tab>
<Tab>Details</Tab>
<Tab>{t("viewUsers.userInfoView.overviewTab")}</Tab>
<Tab>{t("viewUsers.userInfoView.detailsTab")}</Tab>
</TabList>
<TabPanels>
@ -433,22 +435,24 @@ export default function UserInfoView({
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<Card>
<Text>Spend</Text>
<Text>{t("viewUsers.userInfoView.spend")}</Text>
<div className="mt-2">
<Title>${formatNumberWithCommas(userData.spend || 0, 4)}</Title>
<Text>
of{" "}
{userData.max_budget !== null ? `$${formatNumberWithCommas(userData.max_budget, 4)}` : "Unlimited"}
{t("viewUsers.userInfoView.of")}{" "}
{userData.max_budget !== null
? `$${formatNumberWithCommas(userData.max_budget, 4)}`
: t("viewUsers.userInfoView.unlimitedBudget")}
</Text>
</div>
</Card>
<Card>
<div className="flex justify-between items-center mb-2">
<Text>Teams</Text>
<Text>{t("viewUsers.userInfoView.teams")}</Text>
{isProxyAdmin && (
<Button icon={PlusIcon} variant="light" size="xs" onClick={handleOpenAddTeamModal}>
Add Team
{t("viewUsers.userInfoView.addTeam")}
</Button>
)}
</div>
@ -458,8 +462,10 @@ export default function UserInfoView({
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Team Name</TableHeaderCell>
{isProxyAdmin && <TableHeaderCell className="text-right">Actions</TableHeaderCell>}
<TableHeaderCell>{t("viewUsers.userInfoView.teamName")}</TableHeaderCell>
{isProxyAdmin && (
<TableHeaderCell className="text-right">{t("common.actions")}</TableHeaderCell>
)}
</TableRow>
</TableHead>
<TableBody>
@ -483,28 +489,28 @@ export default function UserInfoView({
</Table>
</div>
) : (
<Text>No teams</Text>
<Text>{t("viewUsers.userInfoView.noTeams")}</Text>
)}
{!isTeamsExpanded && teamDetails.length > 20 && (
<Button variant="light" size="xs" className="mt-2" onClick={() => setIsTeamsExpanded(true)}>
+{teamDetails.length - 20} more
{t("viewUsers.userInfoView.moreTeams", { count: teamDetails.length - 20 })}
</Button>
)}
{isTeamsExpanded && teamDetails.length > 20 && (
<Button variant="light" size="xs" className="mt-2" onClick={() => setIsTeamsExpanded(false)}>
Show Less
{t("viewUsers.userInfoView.showLess")}
</Button>
)}
</div>
</Card>
<Card>
<Text>Personal Models</Text>
<Text>{t("viewUsers.userInfoView.personalModels")}</Text>
<div className="mt-2">
{userData.models?.length && userData.models?.length > 0 ? (
userData.models?.map((model, index) => <Text key={index}>{model}</Text>)
) : (
<Text>All proxy models</Text>
<Text>{t("viewUsers.userInfoView.allProxyModels")}</Text>
)}
</div>
</Card>
@ -515,9 +521,9 @@ export default function UserInfoView({
<TabPanel>
<Card>
<div className="flex justify-between items-center mb-4">
<Title>User Settings</Title>
<Title>{t("viewUsers.userInfoView.userSettings")}</Title>
{!isEditing && userRole && rolesWithWriteAccess.includes(userRole) && (
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
<Button onClick={() => setIsEditing(true)}>{t("viewUsers.userInfoView.editSettings")}</Button>
)}
</div>
@ -536,7 +542,7 @@ export default function UserInfoView({
) : (
<div className="space-y-4">
<div>
<Text className="font-medium">User ID</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.userIdLabel")}</Text>
<div className="flex items-center cursor-pointer">
<Text className="font-mono">{userData.user_id}</Text>
<AntdButton
@ -554,32 +560,36 @@ export default function UserInfoView({
</div>
<div>
<Text className="font-medium">Email</Text>
<Text>{userData.user_email || "Not Set"}</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.emailLabel")}</Text>
<Text>{userData.user_email || t("viewUsers.userInfoView.notSet")}</Text>
</div>
<div>
<Text className="font-medium">User Alias</Text>
<Text>{userData.user_alias || "Not Set"}</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.userAliasLabel")}</Text>
<Text>{userData.user_alias || t("viewUsers.userInfoView.notSet")}</Text>
</div>
<div>
<Text className="font-medium">Global Proxy Role</Text>
<Text>{userData.user_role || "Not Set"}</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.globalProxyRoleLabel")}</Text>
<Text>{userData.user_role || t("viewUsers.userInfoView.notSet")}</Text>
</div>
<div>
<Text className="font-medium">Created</Text>
<Text>{userData.created_at ? new Date(userData.created_at).toLocaleString() : "Unknown"}</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.createdLabel")}</Text>
<Text>
{userData.created_at ? new Date(userData.created_at).toLocaleString() : t("common.unknown")}
</Text>
</div>
<div>
<Text className="font-medium">Last Updated</Text>
<Text>{userData.updated_at ? new Date(userData.updated_at).toLocaleString() : "Unknown"}</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.lastUpdatedLabel")}</Text>
<Text>
{userData.updated_at ? new Date(userData.updated_at).toLocaleString() : t("common.unknown")}
</Text>
</div>
<div>
<Text className="font-medium">Personal Models</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.personalModels")}</Text>
<div className="flex flex-wrap gap-2 mt-1">
{userData.models?.length && userData.models?.length > 0 ? (
userData.models?.map((model, index) => (
@ -588,27 +598,27 @@ export default function UserInfoView({
</span>
))
) : (
<Text>All proxy models</Text>
<Text>{t("viewUsers.userInfoView.allProxyModels")}</Text>
)}
</div>
</div>
<div>
<Text className="font-medium">Max Budget</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.maxBudgetLabel")}</Text>
<Text>
{userData.max_budget !== null && userData.max_budget !== undefined
? `$${formatNumberWithCommas(userData.max_budget, 4)}`
: "Unlimited"}
: t("viewUsers.userInfoView.unlimitedBudget")}
</Text>
</div>
<div>
<Text className="font-medium">Budget Reset</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.budgetResetLabel")}</Text>
<Text>{getBudgetDurationLabel(userData.budget_duration ?? null)}</Text>
</div>
<div>
<Text className="font-medium">Metadata</Text>
<Text className="font-medium">{t("viewUsers.userInfoView.metadataLabel")}</Text>
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">
{JSON.stringify(userData.metadata || {}, null, 2)}
</pre>
@ -630,14 +640,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("viewUsers.userInfoView.removeFromTeamTitle")}
alertMessage={t("viewUsers.userInfoView.removeFromTeamAlert")}
message={t("viewUsers.userInfoView.removeFromTeamMessage")}
resourceInformationTitle={t("viewUsers.userInfoView.teamMembershipTitle")}
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("viewUsers.userInfoView.teamLabel"), value: teamToRemove?.team_alias || teamToRemove?.team_id },
{ label: t("viewUsers.deleteModal.labelUserId"), value: userData?.user_id, code: true },
{ label: t("viewUsers.deleteModal.labelEmail"), value: userData?.user_email },
]}
onCancel={handleRemoveTeamCancel}
onOk={handleRemoveTeamConfirm}
@ -646,7 +656,7 @@ export default function UserInfoView({
{/* Add to Team Modal */}
<Modal
title="Add User to Team"
title={t("viewUsers.userInfoView.addUserToTeamTitle")}
open={isAddTeamModalOpen}
onCancel={() => setIsAddTeamModalOpen(false)}
footer={null}
@ -654,12 +664,12 @@ export default function UserInfoView({
maskClosable={!isAddingTeam}
>
<Form layout="vertical" onFinish={handleAddTeamSubmit}>
<Form.Item label="Team" required>
<Form.Item label={t("viewUsers.userInfoView.teamFormLabel")} required>
<AntdSelect
showSearch
value={selectedTeamId || undefined}
onChange={setSelectedTeamId}
placeholder="Select a team"
placeholder={t("viewUsers.userInfoView.selectTeamPlaceholder")}
filterOption={(input, option) => {
const team = availableTeamsForAdd.find((t) => t.team_id === option?.value);
if (!team) return false;
@ -675,20 +685,18 @@ export default function UserInfoView({
</AntdSelect>
</Form.Item>
<Form.Item label="Member Role">
<Form.Item label={t("viewUsers.userInfoView.memberRoleLabel")}>
<AntdSelect value={selectedRole} onChange={setSelectedRole}>
<AntdSelect.Option value="user">
<Tooltip title="Can view team info, but not manage it">
<Tooltip title={t("viewUsers.userInfoView.roleUserDesc")}>
<span className="font-medium">user</span>
<span className="ml-2 text-gray-500 text-sm">- Can view team info, but not manage it</span>
<span className="ml-2 text-gray-500 text-sm">{t("viewUsers.userInfoView.roleUserDesc")}</span>
</Tooltip>
</AntdSelect.Option>
<AntdSelect.Option value="admin">
<Tooltip title="Can create team keys, add members, and manage settings">
<Tooltip title={t("viewUsers.userInfoView.roleAdminDesc")}>
<span className="font-medium">admin</span>
<span className="ml-2 text-gray-500 text-sm">
- Can create team keys, add members, and manage settings
</span>
<span className="ml-2 text-gray-500 text-sm">{t("viewUsers.userInfoView.roleAdminDesc")}</span>
</Tooltip>
</AntdSelect.Option>
</AntdSelect>
@ -696,7 +704,7 @@ export default function UserInfoView({
<div className="text-right mt-4">
<AntdButton type="primary" htmlType="submit" loading={isAddingTeam} disabled={!selectedTeamId}>
{isAddingTeam ? "Adding..." : "Add to Team"}
{isAddingTeam ? t("viewUsers.userInfoView.adding") : t("viewUsers.userInfoView.addToTeam")}
</AntdButton>
</div>
</Form>

View file

@ -2665,7 +2665,14 @@
"userDeletedSuccess": "User deleted successfully",
"failedDeleteUser": "Failed to delete user",
"selectUsersToEdit": "Please select users to edit",
"userUpdatedSuccess": "User {{userId}} updated successfully"
"userUpdatedSuccess": "User {{userId}} updated successfully",
"failedFetchUserData": "Failed to fetch user data",
"userAddedToTeam": "User added to team successfully",
"failedAddToTeam": "Failed to add user to team",
"userRemovedFromTeam": "User removed from team successfully",
"failedRemoveFromTeam": "Failed to remove user from team",
"userUpdatedSingle": "User updated successfully",
"failedUpdateUser": "Failed to update user"
},
"deleteModal": {
"title": "Delete User?",
@ -2675,6 +2682,86 @@
"labelUserId": "User ID",
"labelGlobalProxyRole": "Global Proxy Role",
"labelTotalSpend": "Total Spend (USD)"
},
"columns": {
"userId": "User ID",
"copyUserId": "Copy User ID",
"userIdCopied": "User ID copied to clipboard",
"email": "Email",
"status": "Status",
"scimInactiveTooltip": "Deactivated via SCIM (external identity provider). The user's virtual keys are blocked.",
"inactive": "Inactive",
"active": "Active",
"globalProxyRole": "Global Proxy Role",
"userAlias": "User Alias",
"spendUsd": "Spend (USD)",
"budgetUsd": "Budget (USD)",
"unlimited": "Unlimited",
"ssoId": "SSO ID",
"ssoIdTooltip": "SSO ID is the ID of the user in the SSO provider. If the user is not using SSO, this will be null.",
"virtualKeys": "Virtual Keys",
"key_one": "{{count}} Key",
"key_other": "{{count}} Keys",
"noKeys": "No Keys",
"editUserDetails": "Edit user details",
"deleteUser": "Delete user",
"resetPassword": "Reset Password"
},
"table": {
"searchByEmail": "Search by email...",
"filterByUserId": "Filter by User ID",
"filterBySsoId": "Filter by SSO ID",
"selectRole": "Select Role",
"selectTeam": "Select Team",
"showing": "Showing {{from}} - {{to}} of {{total}} results",
"loadingUsers": "Loading users...",
"noUsersFound": "No users found"
},
"userInfoView": {
"backToUsers": "Back to Users",
"loadingUserData": "Loading user data...",
"userNotFound": "User not found",
"resetPassword": "Reset Password",
"deleteUser": "Delete User",
"overviewTab": "Overview",
"detailsTab": "Details",
"spend": "Spend",
"of": "of",
"unlimitedBudget": "Unlimited",
"teams": "Teams",
"addTeam": "Add Team",
"teamName": "Team Name",
"noTeams": "No teams",
"moreTeams": "+{{count}} more",
"showLess": "Show Less",
"personalModels": "Personal Models",
"allProxyModels": "All proxy models",
"userSettings": "User Settings",
"editSettings": "Edit Settings",
"userIdLabel": "User ID",
"emailLabel": "Email",
"userAliasLabel": "User Alias",
"globalProxyRoleLabel": "Global Proxy Role",
"createdLabel": "Created",
"lastUpdatedLabel": "Last Updated",
"maxBudgetLabel": "Max Budget",
"budgetResetLabel": "Budget Reset",
"metadataLabel": "Metadata",
"notSet": "Not Set",
"removeFromTeamTitle": "Remove from Team",
"removeFromTeamAlert": "Removing this user from the team will also delete any keys the user created for this team.",
"removeFromTeamMessage": "Are you sure you want to remove this user from the team? This action cannot be undone.",
"teamMembershipTitle": "Team Membership",
"teamLabel": "Team",
"addUserToTeamTitle": "Add User to Team",
"teamFormLabel": "Team",
"selectTeamPlaceholder": "Select a team",
"memberRoleLabel": "Member Role",
"roleUserDesc": "- Can view team info, but not manage it",
"roleAdminDesc": "- Can create team keys, add members, and manage settings",
"adding": "Adding...",
"addToTeam": "Add to Team",
"userFallbackTitle": "User"
}
},
"viewLogs": {

View file

@ -2665,7 +2665,14 @@
"userDeletedSuccess": "用户已成功删除",
"failedDeleteUser": "删除用户失败",
"selectUsersToEdit": "请选择要编辑的用户",
"userUpdatedSuccess": "用户 {{userId}} 更新成功"
"userUpdatedSuccess": "用户 {{userId}} 更新成功",
"failedFetchUserData": "获取用户数据失败",
"userAddedToTeam": "用户已成功加入团队",
"failedAddToTeam": "将用户加入团队失败",
"userRemovedFromTeam": "用户已成功从团队中移除",
"failedRemoveFromTeam": "从团队中移除用户失败",
"userUpdatedSingle": "用户更新成功",
"failedUpdateUser": "更新用户失败"
},
"deleteModal": {
"title": "删除用户?",
@ -2675,6 +2682,86 @@
"labelUserId": "用户 ID",
"labelGlobalProxyRole": "全局代理角色",
"labelTotalSpend": "总花费(USD)"
},
"columns": {
"userId": "用户 ID",
"copyUserId": "复制用户 ID",
"userIdCopied": "用户 ID 已复制到剪贴板",
"email": "邮箱",
"status": "状态",
"scimInactiveTooltip": "已通过 SCIM(外部身份提供商)停用。该用户的虚拟密钥已被封锁。",
"inactive": "未激活",
"active": "已激活",
"globalProxyRole": "全局代理角色",
"userAlias": "用户别名",
"spendUsd": "花费(USD)",
"budgetUsd": "预算(USD)",
"unlimited": "无限制",
"ssoId": "SSO ID",
"ssoIdTooltip": "SSO ID 是用户在 SSO 提供商中的 ID。如果用户未使用 SSO,此字段将为空。",
"virtualKeys": "虚拟密钥",
"key_one": "{{count}} 个密钥",
"key_other": "{{count}} 个密钥",
"noKeys": "无密钥",
"editUserDetails": "编辑用户详情",
"deleteUser": "删除用户",
"resetPassword": "重置密码"
},
"table": {
"searchByEmail": "按邮箱搜索...",
"filterByUserId": "按用户 ID 筛选",
"filterBySsoId": "按 SSO ID 筛选",
"selectRole": "选择角色",
"selectTeam": "选择团队",
"showing": "显示第 {{from}} - {{to}} 条,共 {{total}} 条",
"loadingUsers": "加载用户中...",
"noUsersFound": "未找到用户"
},
"userInfoView": {
"backToUsers": "返回用户列表",
"loadingUserData": "加载用户数据中...",
"userNotFound": "未找到用户",
"resetPassword": "重置密码",
"deleteUser": "删除用户",
"overviewTab": "概览",
"detailsTab": "详情",
"spend": "花费",
"of": "共",
"unlimitedBudget": "无限制",
"teams": "团队",
"addTeam": "添加团队",
"teamName": "团队名称",
"noTeams": "暂无团队",
"moreTeams": "+{{count}} 个更多",
"showLess": "收起",
"personalModels": "个人模型",
"allProxyModels": "所有代理模型",
"userSettings": "用户设置",
"editSettings": "编辑设置",
"userIdLabel": "用户 ID",
"emailLabel": "邮箱",
"userAliasLabel": "用户别名",
"globalProxyRoleLabel": "全局代理角色",
"createdLabel": "创建时间",
"lastUpdatedLabel": "最后更新",
"maxBudgetLabel": "最大预算",
"budgetResetLabel": "预算重置",
"metadataLabel": "元数据",
"notSet": "未设置",
"removeFromTeamTitle": "从团队中移除",
"removeFromTeamAlert": "将此用户从团队中移除后,该用户为此团队创建的所有密钥也将被删除。",
"removeFromTeamMessage": "确定要将此用户从团队中移除吗?此操作无法撤销。",
"teamMembershipTitle": "团队成员",
"teamLabel": "团队",
"addUserToTeamTitle": "将用户加入团队",
"teamFormLabel": "团队",
"selectTeamPlaceholder": "选择团队",
"memberRoleLabel": "成员角色",
"roleUserDesc": "- 可查看团队信息,但无法管理",
"roleAdminDesc": "- 可创建团队密钥、添加成员并管理设置",
"adding": "添加中...",
"addToTeam": "加入团队",
"userFallbackTitle": "用户"
}
},
"viewLogs": {