feat(ui): migrate users forms + modals to shadcn

Migrates user_edit_view, CreateUserButton, BulkEditUsers,
bulk_create_users_button, and DefaultUserSettings off antd Form/Select/
Modal/Input/Typography and off Tremor Card/Text/Title. All five now use
shadcn Dialog + react-hook-form + shadcn Select/Input/Textarea/Badge/
Table, with a local multi-select shim (shadcn Select + badge chips).

The categorical status palette used by the CSV bulk import (red/green/
blue/yellow for invalid/success/pending/warning) and the bulk-edit
amber warning are preserved as raw Tailwind classes and the owning
files are added to the no-raw-tailwind-colors override list and
DEVIATIONS.md per blueprint rule 3.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-24 10:15:09 +00:00
parent 26c6978808
commit b861fd3fd4
No known key found for this signature in database
8 changed files with 2138 additions and 1170 deletions

View file

@ -146,7 +146,11 @@
"src/components/price_data_reload.tsx",
"src/components/mcp_tools/mcp_connect.tsx",
"src/components/mcp_tools/mcp_tools.tsx",
"src/components/mcp_tools/ToolTestPanel.tsx"
"src/components/mcp_tools/ToolTestPanel.tsx",
"src/components/bulk_create_users_button.tsx",
"src/components/BulkEditUsers.tsx",
"src/components/DefaultUserSettings.tsx",
"src/components/view_users/user_info_view.tsx"
],
"rules": {
"litellm-ui/no-banned-ui-imports": "off",

View file

@ -60,6 +60,26 @@ Format per entry:
policy tokens (`--policy-untrusted`, `--policy-trusted`,
`--policy-blocked`) backed by CSS variables; out of scope for phase 1.
## Users section — categorical colors (red/green/blue/yellow) in bulk-create + edit status UI
- **Type:** `blueprint deviation`
- **Blueprint rule deviated from:** §1 "semantic Tailwind tokens only".
- **What the agent did instead:** `bulk_create_users_button.tsx`,
`BulkEditUsers.tsx`, `DefaultUserSettings.tsx`, and
`view_users/user_info_view.tsx` keep raw palette classes
(`bg-red-50`/`text-red-500`, `bg-green-100`/`text-green-500`,
`bg-blue-50`/`text-blue-500`, `bg-yellow-50`/`text-yellow-700`,
`bg-blue-100` model chips, `text-amber-600` warning callout, etc.) and
are added to the `litellm-ui/no-raw-tailwind-colors` override list in
`.eslintrc.json`.
- **Why:** The CSV import flow uses red = invalid / failed, green =
success, blue = info / pending, yellow = structural warning — a
categorical, status-driven palette with four distinct hues. Only
`destructive` maps cleanly; `primary`/`muted` don't cover "success" or
"info/pending" as separate categories. A phase-2 task could introduce
semantic status tokens (`--status-success`, `--status-info`,
`--status-warning`, `--status-failure`); out of scope for phase 1.
## Section 1 (Access Groups) — custom MultiSelect shim in lieu of antd Select mode=multiple
- **Type:** `blueprint deviation`

View file

@ -1,21 +1,42 @@
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
Modal,
Typography,
Divider,
Table,
Select,
InputNumber,
Card,
Space,
Checkbox,
} from "antd";
import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "./networking";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import MessageManager from "@/components/molecules/message_manager";
import {
userBulkUpdateUserCall,
teamBulkMemberAddCall,
Member,
} from "./networking";
import { UserEditView } from "./user_edit_view";
import NotificationsManager from "./molecules/notifications_manager";
import MessageManager from "@/components/molecules/message_manager";
const { Text, Title } = Typography;
import NumericalInput from "./shared/numerical_input";
interface BulkEditUserModalProps {
open: boolean;
@ -30,6 +51,79 @@ interface BulkEditUserModalProps {
allowAllUsers?: boolean; // Optional flag to enable "all users" mode
}
/**
* shadcn Select + Badge chip multi-select for teams.
*/
function TeamMultiSelect({
value,
onChange,
options,
placeholder,
}: {
value: string[];
onChange: (next: string[]) => void;
options: { label: string; value: string }[];
placeholder: string;
}) {
const selected = useMemo(() => value ?? [], [value]);
const remaining = useMemo(
() => options.filter((o) => !selected.includes(o.value)),
[options, selected],
);
return (
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) onChange([...selected, v]);
}}
>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No more teams available
</div>
) : (
remaining.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map((v) => {
const opt = options.find((o) => o.value === v);
return (
<Badge
key={v}
variant="secondary"
className="flex items-center gap-1"
>
{opt?.label ?? v}
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== v))}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${opt?.label ?? v}`}
>
<X size={12} />
</button>
</Badge>
);
})}
</div>
)}
</div>
);
}
const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
open,
onCancel,
@ -49,7 +143,6 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
const [updateAllUsers, setUpdateAllUsers] = useState(false);
const handleCancel = () => {
// Reset team management state
setSelectedTeams([]);
setTeamBudget(null);
setAddToTeams(false);
@ -75,6 +168,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
keys: [],
teams: teams || [],
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[teams, open],
);
@ -89,7 +183,6 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
try {
const userIds = selectedUsers.map((user) => user.user_id);
// Build the update payload - only include fields that have been changed from default/empty values
const updatePayload: any = {};
if (formValues.user_role && formValues.user_role !== "") {
@ -108,46 +201,54 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
updatePayload.budget_duration = formValues.budget_duration;
}
if (formValues.metadata && Object.keys(formValues.metadata).length > 0) {
if (
formValues.metadata &&
Object.keys(formValues.metadata).length > 0
) {
updatePayload.metadata = formValues.metadata;
}
// Check if any operations were requested
const hasUserUpdates = Object.keys(updatePayload).length > 0;
const hasTeamAdditions = addToTeams && selectedTeams.length > 0;
if (!hasUserUpdates && !hasTeamAdditions) {
NotificationsManager.fromBackend("Please modify at least one field or select teams to add users to");
NotificationsManager.fromBackend(
"Please modify at least one field or select teams to add users to",
);
return;
}
let successMessages: string[] = [];
// Handle user property updates
if (hasUserUpdates) {
if (updateAllUsers) {
const result = await userBulkUpdateUserCall(accessToken, updatePayload, undefined, true);
successMessages.push(`Updated all users (${result.total_requested} total)`);
const result = await userBulkUpdateUserCall(
accessToken,
updatePayload,
undefined,
true,
);
successMessages.push(
`Updated all users (${result.total_requested} total)`,
);
} else {
await userBulkUpdateUserCall(accessToken, updatePayload, userIds);
successMessages.push(`Updated ${userIds.length} user(s)`);
}
}
// Handle team additions
if (hasTeamAdditions) {
const teamResults: any[] = [];
for (const teamId of selectedTeams) {
try {
// Create member objects for bulk add
let members: Member[] | null = null;
if (updateAllUsers) {
members = null;
} else {
members = selectedUsers.map((user) => ({
user_id: user.user_id,
role: "user" as const, // Default role for bulk add
role: "user" as const,
user_email: user.user_email || null,
}));
}
@ -178,17 +279,23 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
}
}
// Generate team success message
const successfulTeams = teamResults.filter((r) => r.success);
const failedTeams = teamResults.filter((r) => !r.success);
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)`);
const totalAdditions = successfulTeams.reduce(
(sum, r) => sum + r.successfulAdditions,
0,
);
successMessages.push(
`Added users to ${successfulTeams.length} team(s) (${totalAdditions} total additions)`,
);
}
if (failedTeams.length > 0) {
MessageManager.warning(`Failed to add users to ${failedTeams.length} team(s)`);
MessageManager.warning(
`Failed to add users to ${failedTeams.length} team(s)`,
);
}
}
@ -196,7 +303,6 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
NotificationsManager.success(successMessages.join(". "));
}
// Reset team management state
setSelectedTeams([]);
setTeamBudget(null);
setAddToTeams(false);
@ -212,164 +318,176 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
}
};
const teamOptions = (teams || []).map((team: any) => ({
label: team.team_alias || team.team_id,
value: team.team_id,
}));
return (
<Modal
<Dialog
open={open}
onCancel={handleCancel}
footer={null}
title={updateAllUsers ? "Bulk Edit All Users" : `Bulk Edit ${selectedUsers.length} User(s)`}
width={800}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
{allowAllUsers && (
<div className="mb-4">
<Checkbox checked={updateAllUsers} onChange={(e) => setUpdateAllUsers(e.target.checked)}>
<Text strong>Update ALL users in the system</Text>
</Checkbox>
{updateAllUsers && (
<div style={{ marginTop: 8 }}>
<Text type="warning" style={{ fontSize: "12px" }}>
⚠️ This will apply changes to ALL users in the system, not just the selected ones.
</Text>
<DialogContent className="max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{updateAllUsers
? "Bulk Edit All Users"
: `Bulk Edit ${selectedUsers.length} User(s)`}
</DialogTitle>
</DialogHeader>
{allowAllUsers && (
<div className="mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={updateAllUsers}
onCheckedChange={(c) => setUpdateAllUsers(c === true)}
/>
<span className="font-semibold">
Update ALL users in the system
</span>
</label>
{updateAllUsers && (
<div className="mt-2">
<span className="text-xs text-amber-600 dark:text-amber-400">
⚠️ This will apply changes to ALL users in the system, not
just the selected ones.
</span>
</div>
)}
</div>
)}
{!updateAllUsers && (
<div className="mb-4">
<h5 className="text-sm font-semibold mb-2">
Selected Users ({selectedUsers.length}):
</h5>
<div className="border border-border rounded-md max-h-[200px] overflow-y-auto">
<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="w-[20%]">Budget</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedUsers.map((user) => (
<TableRow key={user.user_id}>
<TableCell className="text-xs font-semibold">
{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">
{possibleUIRoles?.[user.user_role]?.ui_label ||
user.user_role}
</TableCell>
<TableCell className="text-xs">
{user.max_budget !== null
? `$${user.max_budget}`
: "Unlimited"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
)}
<Separator />
{!updateAllUsers && (
<div className="mb-4">
<Title level={5}>Selected Users ({selectedUsers.length}):</Title>
<Table
size="small"
bordered
dataSource={selectedUsers}
pagination={false}
scroll={{ y: 200 }}
rowKey="user_id"
columns={[
{
title: "User ID",
dataIndex: "user_id",
key: "user_id",
width: "30%",
render: (text: string) => (
<Text strong style={{ fontSize: "12px" }}>
{text.length > 20 ? `${text.slice(0, 20)}...` : text}
</Text>
),
},
{
title: "Email",
dataIndex: "user_email",
key: "user_email",
width: "25%",
render: (text: string) => (
<Text type="secondary" style={{ fontSize: "12px" }}>
{text || "No email"}
</Text>
),
},
{
title: "Current Role",
dataIndex: "user_role",
key: "user_role",
width: "25%",
render: (role: string) => (
<Text style={{ fontSize: "12px" }}>{possibleUIRoles?.[role]?.ui_label || role}</Text>
),
},
{
title: "Budget",
dataIndex: "max_budget",
key: "max_budget",
width: "20%",
render: (budget: number | null) => (
<Text style={{ fontSize: "12px" }}>{budget !== null ? `$${budget}` : "Unlimited"}</Text>
),
},
]}
/>
<p className="text-sm">
<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.
</p>
</div>
)}
<Divider />
{/* Team Management Section */}
<Card className="p-4 bg-muted/50 mb-4">
<h4 className="text-sm font-semibold mb-3">Team Management</h4>
<div className="flex flex-col gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={addToTeams}
onCheckedChange={(c) => setAddToTeams(c === true)}
/>
<span>Add selected users to teams</span>
</label>
<div className="mb-4">
<Text>
<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.
</Text>
</div>
{addToTeams && (
<>
<div>
<Label className="font-semibold">Select Teams:</Label>
<div className="mt-2">
<TeamMultiSelect
value={selectedTeams}
onChange={setSelectedTeams}
options={teamOptions}
placeholder="Select teams to add users to"
/>
</div>
</div>
{/* Team Management Section */}
<Card title="Team Management" size="small" className="mb-4" style={{ backgroundColor: "#fafafa" }}>
<Space direction="vertical" style={{ width: "100%" }}>
<Checkbox checked={addToTeams} onChange={(e) => setAddToTeams(e.target.checked)}>
Add selected users to teams
</Checkbox>
<div>
<Label className="font-semibold">Team Budget (Optional):</Label>
<div className="mt-2">
<NumericalInput
placeholder="Max budget per user in team"
value={teamBudget}
onChange={(v: number | null) => setTeamBudget(v)}
min={0}
step={0.01}
precision={2}
/>
</div>
<span className="text-xs text-muted-foreground">
Leave empty for unlimited budget within team limits
</span>
</div>
{addToTeams && (
<>
<div>
<Text strong>Select Teams:</Text>
<Select
mode="multiple"
placeholder="Select teams to add users to"
value={selectedTeams}
onChange={setSelectedTeams}
style={{ width: "100%", marginTop: 8 }}
options={
teams?.map((team) => ({
label: team.team_alias || team.team_id,
value: team.team_id,
})) || []
}
/>
</div>
<span className="text-xs text-muted-foreground">
Users will be added with &quot;user&quot; role by default.
All users will be added to each selected team.
</span>
</>
)}
</div>
</Card>
<div>
<Text strong>Team Budget (Optional):</Text>
<InputNumber
placeholder="Max budget per user in team"
value={teamBudget}
onChange={(value) => setTeamBudget(value)}
style={{ width: "100%", marginTop: 8 }}
min={0}
step={0.01}
precision={2}
/>
<Text type="secondary" style={{ fontSize: "12px" }}>
Leave empty for unlimited budget within team limits
</Text>
</div>
<UserEditView
userData={mockUserData}
onCancel={handleCancel}
onSubmit={handleSubmit}
teams={teams}
accessToken={accessToken}
userID="bulk_edit"
userRole={userRole}
userModels={userModels}
possibleUIRoles={possibleUIRoles}
isBulkEdit={true}
/>
<Text type="secondary" style={{ fontSize: "12px" }}>
Users will be added with &quot;user&quot; role by default. All users will be added to each selected
team.
</Text>
</>
)}
</Space>
</Card>
<UserEditView
userData={mockUserData}
onCancel={handleCancel}
onSubmit={handleSubmit}
teams={teams}
accessToken={accessToken}
userID="bulk_edit"
userRole={userRole}
userModels={userModels}
possibleUIRoles={possibleUIRoles}
isBulkEdit={true}
/>
{loading && (
<div style={{ textAlign: "center", marginTop: "10px" }}>
<Text>Updating {updateAllUsers ? "all users" : selectedUsers.length} user(s)...</Text>
</div>
)}
</Modal>
{loading && (
<div className="text-center mt-3">
<span className="text-sm">
Updating{" "}
{updateAllUsers ? "all users" : selectedUsers.length} user(s)...
</span>
</div>
)}
</DialogContent>
</Dialog>
);
};

View file

@ -103,8 +103,8 @@ describe("CreateUserButton", () => {
<CreateUserButton {...defaultProps} possibleUIRoles={possibleUIRoles} isEmbedded />,
);
await userEvent.click(screen.getByRole("combobox", { name: /user role/i }));
expect(screen.getByText("Admin")).toBeInTheDocument();
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Admin/i })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /User/i })).toBeInTheDocument();
});
it("should close modal when cancel is clicked in standalone mode", async () => {
@ -139,7 +139,7 @@ describe("CreateUserButton", () => {
await user.type(screen.getByLabelText(/user email/i), "test@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("option", { name: /User/i }));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
@ -161,7 +161,7 @@ describe("CreateUserButton", () => {
await user.type(screen.getByLabelText(/user email/i), "embedded@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("option", { name: /User/i }));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
@ -179,7 +179,7 @@ describe("CreateUserButton", () => {
await user.type(screen.getByLabelText(/user email/i), "duplicate@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("option", { name: /User/i }));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
@ -202,7 +202,7 @@ describe("CreateUserButton", () => {
await user.type(screen.getByLabelText(/user email/i), "info@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("option", { name: /User/i }));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
@ -233,7 +233,7 @@ describe("CreateUserButton", () => {
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "standalone@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("option", { name: /User/i }));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
@ -262,7 +262,7 @@ describe("CreateUserButton", () => {
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "sso@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("option", { name: /User/i }));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
@ -302,12 +302,12 @@ describe("CreateUserButton", () => {
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "org@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("option", { name: /User/i }));
// Select org from the dropdown
const orgSelect = within(dialog).getByRole("combobox", { name: /organization/i });
await user.click(orgSelect);
await user.click(screen.getByText("My Org (org-1)"));
await user.click(screen.getByRole("option", { name: /My Org \(org-1\)/i }));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
@ -345,7 +345,7 @@ describe("CreateUserButton", () => {
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "nomemberadd@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("option", { name: /User/i }));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {

View file

@ -1,10 +1,43 @@
import { Info as InfoCircleOutlined, UserPlus as UserAddOutlined } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { Accordion, AccordionBody, AccordionHeader, SelectItem, TextInput } from "@tremor/react";
import { Alert, Button, Form, Input, Modal, Select, Select as Select2, Space, Tooltip, Typography } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Controller, useForm } from "react-hook-form";
import { Info, UserPlus, X } from "lucide-react";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import BulkCreateUsers from "./bulk_create_users_button";
import TeamDropdown from "./common_components/team_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
@ -17,8 +50,7 @@ import {
userCreateCall,
} from "./networking";
import OnboardingModal, { InvitationLink } from "./onboarding_link";
const { Option } = Select;
const { Text, Link, Title } = Typography;
// Helper function to generate UUID compatible across all environments
const generateUUID = (): string => {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
@ -49,6 +81,102 @@ interface UISettings {
SSO_ENABLED: boolean;
}
interface CreateUserFormValues {
user_email?: string;
user_role: string;
team_id?: string;
organization_ids?: string[];
models?: string[];
metadata?: string;
}
const defaultFormValues: CreateUserFormValues = {
user_email: "",
user_role: "internal_user_viewer",
team_id: undefined,
organization_ids: [],
models: [],
metadata: "",
};
/**
* shadcn Select + badge chip multi-select. Mirrors the pattern from
* AccessGroupBaseForm. Used for org IDs and personal model selection.
*/
function MultiSelect({
value,
onChange,
options,
placeholder,
emptyText,
ariaLabel,
}: {
value: string[];
onChange: (next: string[]) => void;
options: { label: string; value: string }[];
placeholder: string;
emptyText: string;
ariaLabel?: string;
}) {
const selected = useMemo(() => value ?? [], [value]);
const remaining = useMemo(
() => options.filter((o) => !selected.includes(o.value)),
[options, selected],
);
return (
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) onChange([...selected, v]);
}}
>
<SelectTrigger aria-label={ariaLabel ?? placeholder}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
{emptyText}
</div>
) : (
remaining.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map((v) => {
const opt = options.find((o) => o.value === v);
return (
<Badge
key={v}
variant="secondary"
className="flex items-center gap-1"
>
{opt?.label ?? v}
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== v))}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${opt?.label ?? v}`}
>
<X size={12} />
</button>
</Badge>
);
})}
</div>
)}
</div>
);
}
export const CreateUserButton: React.FC<CreateuserProps> = ({
userID,
accessToken,
@ -59,16 +187,26 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
}) => {
const queryClient = useQueryClient();
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
const [form] = Form.useForm();
const [isModalVisible, setIsModalVisible] = useState(false);
const [apiuser, setApiuser] = useState<boolean>(false);
const [userModels, setUserModels] = useState<string[]>([]);
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] =
useState(false);
const [invitationLinkData, setInvitationLinkData] =
useState<InvitationLink | null>(null);
const [baseUrl, setBaseUrl] = useState<string | null>(null);
const { data: organizations = [] } = useOrganizations();
const {
control,
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CreateUserFormValues>({ defaultValues: defaultFormValues });
// Derive teams from the user's organizations, falling back to the teams prop
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const availableTeams = useMemo(() => {
const orgTeams = organizations.flatMap((org) => org.teams || []);
if (orgTeams.length > 0) return orgTeams;
@ -79,7 +217,11 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
const fetchData = async () => {
try {
const userRole = "any";
const modelDataResponse = await modelAvailableCall(accessToken, userID, userRole);
const modelDataResponse = await modelAvailableCall(
accessToken,
userID,
userRole,
);
const availableModels = [];
for (let i = 0; i < modelDataResponse.data.length; i++) {
const model = modelDataResponse.data[i];
@ -95,46 +237,47 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
setBaseUrl(getProxyBaseUrl());
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleOk = () => {
setIsModalVisible(false);
form.resetFields();
};
const handleCancel = () => {
setIsModalVisible(false);
setApiuser(false);
form.resetFields();
reset(defaultFormValues);
};
const handleCreate = async (formValues: {
user_id: string;
models?: string[];
user_role: string;
organization_ids?: string[];
organizations?: string[];
}) => {
const handleCreate = async (formValues: CreateUserFormValues) => {
try {
NotificationsManager.info("Making API Call");
if (!isEmbedded) {
setIsModalVisible(true);
}
if ((!formValues.models || formValues.models.length === 0) && formValues.user_role !== "proxy_admin") {
formValues.models = ["no-default-models"];
const payload: Record<string, any> = {
user_email: formValues.user_email,
user_role: formValues.user_role,
team_id: formValues.team_id,
models: formValues.models,
metadata: formValues.metadata,
};
if (
(!payload.models || payload.models.length === 0) &&
payload.user_role !== "proxy_admin"
) {
payload.models = ["no-default-models"];
}
if (formValues.organization_ids) {
formValues.organizations = formValues.organization_ids;
delete formValues.organization_ids;
if (formValues.organization_ids && formValues.organization_ids.length > 0) {
payload.organizations = formValues.organization_ids;
}
const response = await userCreateCall(accessToken, null, formValues);
const response = await userCreateCall(accessToken, null, payload);
await queryClient.invalidateQueries({ queryKey: ["userList"] });
setApiuser(true);
const user_id = response.data?.user_id || response.user_id;
if (onUserCreated && isEmbedded) {
onUserCreated(user_id);
form.resetFields();
reset(defaultFormValues);
return;
}
@ -148,13 +291,13 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
// create an InvitationLink Object for this user for the SSO flow
// for SSO the invite link is the proxy base url since the User just needs to login
const invitationLink: InvitationLink = {
id: generateUUID(), // Generate a unique ID
id: generateUUID(),
user_id: user_id,
is_accepted: false,
accepted_at: null,
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Set expiry to 7 days from now
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
created_at: new Date(),
created_by: userID, // Assuming userID is the current user creating the invitation
created_by: userID,
updated_at: new Date(),
updated_by: userID,
has_user_setup_sso: true,
@ -164,210 +307,322 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
}
NotificationsManager.success("API user Created");
form.resetFields();
reset(defaultFormValues);
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 || "Error creating the user";
NotificationsManager.fromBackend(errorMessage);
console.error("Error creating the user:", error);
}
};
const orgOptions = organizations.map((org) => ({
label: `${org.organization_alias} (${org.organization_id})`,
value: org.organization_id,
}));
const modelOptions = useMemo(() => {
const base = [
{ label: "All Proxy Models", value: "all-proxy-models" },
{ label: "No Default Models", value: "no-default-models" },
];
const extras = userModels.map((model) => ({
label: getModelDisplayName(model),
value: model,
}));
return [...base, ...extras];
}, [userModels]);
// Modify the return statement to handle embedded mode
if (isEmbedded) {
return (
<Form
form={form}
onFinish={handleCreate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
initialValues={{ user_role: "internal_user_viewer" }}
>
<Alert
message="Email invitations"
description={
<>
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is
configured.{" "}
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
Learn how to set up email notifications
</Link>
</>
}
type="info"
showIcon
className="mb-4"
/>
<Form.Item label="User Email" name="user_email">
<TextInput placeholder="" />
</Form.Item>
<Form.Item label="User Role" name="user_role">
<Select2>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<div className="flex">
{ui_label}{" "}
<Text className="ml-2" style={{ color: "gray", fontSize: "12px" }}>
{description}
</Text>
</div>
</SelectItem>
))}
</Select2>
</Form.Item>
<Form.Item label="Team" name="team_id">
<TeamDropdown />
</Form.Item>
<form onSubmit={handleSubmit(handleCreate)} className="space-y-4">
<Alert>
<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"
className="text-primary underline"
>
Learn how to set up email notifications
</a>
</AlertDescription>
</Alert>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button htmlType="submit">Create User</Button>
<div className="space-y-2">
<Label htmlFor="embedded_user_email">User Email</Label>
<Input id="embedded_user_email" {...register("user_email")} />
</div>
</Form>
<div className="space-y-2">
<Label htmlFor="embedded_user_role">User Role</Label>
<Controller
control={control}
name="user_role"
render={({ field }) => (
<Select
value={field.value ?? ""}
onValueChange={(v) => field.onChange(v)}
>
<SelectTrigger id="embedded_user_role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(
([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<div className="flex">
{ui_label}
<span className="ml-2 text-muted-foreground text-xs">
{description}
</span>
</div>
</SelectItem>
),
)}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Team</Label>
<Controller
control={control}
name="team_id"
render={({ field }) => (
<TeamDropdown
value={field.value ?? ""}
onChange={(v) => field.onChange(v)}
/>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="embedded_metadata">Metadata</Label>
<Textarea
id="embedded_metadata"
rows={4}
placeholder="Enter metadata as JSON"
{...register("metadata")}
/>
</div>
<div className="flex justify-end">
<Button type="submit">Create User</Button>
</div>
</form>
);
}
// Original return for standalone mode
return (
<div className="flex gap-2">
<Button type="primary" className="mb-0" onClick={() => setIsModalVisible(true)}>
+ Invite User
</Button>
<BulkCreateUsers accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
<Modal
title="Invite User"
<Button onClick={() => setIsModalVisible(true)}>+ Invite User</Button>
<BulkCreateUsers
accessToken={accessToken}
teams={teams}
possibleUIRoles={possibleUIRoles}
/>
<Dialog
open={isModalVisible}
width={800}
footer={null}
onOk={handleOk}
onCancel={handleCancel}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
<Space direction="vertical" size="middle">
<Text className="mb-1">Create a User who can own keys</Text>
<Alert
message="Email invitations"
description={
<>
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is
configured.{" "}
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
Learn how to set up email notifications
</Link>
</>
}
type="info"
showIcon
className="mb-4"
/>
</Space>
<Form
form={form}
onFinish={handleCreate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
initialValues={{ user_role: "internal_user_viewer" }}
>
<Form.Item label="User Email" name="user_email">
<Input />
</Form.Item>
<Form.Item
label={
<span>
Global Proxy Role{" "}
<Tooltip title="This role is independent of any team/org specific roles. Configure Team / Organization Admins in the Settings">
<InfoCircleOutlined />
</Tooltip>
</span>
}
name="user_role"
>
<Select2>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<Text>{ui_label}</Text>
<Text type="secondary">
{" - "}
{description}
</Text>
</SelectItem>
))}
</Select2>
</Form.Item>
<DialogContent className="max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>Create a User who can own keys</DialogDescription>
</DialogHeader>
<Form.Item
label="Team"
className="gap-2"
name="team_id"
help="If selected, user will be added as a 'user' role to the team."
>
<TeamDropdown />
</Form.Item>
<Form.Item
label="Organization"
name="organization_ids"
help="The user will be added to the selected organization(s)."
>
<Select mode="multiple" placeholder="Select Organization" style={{ width: "100%" }}>
{organizations.map((org) => (
<Option key={org.organization_id} value={org.organization_id}>
{org.organization_alias} ({org.organization_id})
</Option>
))}
</Select>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
</Form.Item>
<Accordion>
<AccordionHeader>
<Text strong>Personal Key Creation</Text>
</AccordionHeader>
<AccordionBody>
<Form.Item
className="gap-2"
label={
<span>
Models{" "}
<Tooltip title="Models user has access to, outside of team scope.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="models"
help="Models user has access to, outside of team scope."
<Alert>
<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"
className="text-primary underline"
>
<Select2 mode="multiple" placeholder="Select models" style={{ width: "100%" }}>
<Select2.Option key="all-proxy-models" value="all-proxy-models">
All Proxy Models
</Select2.Option>
<Select2.Option key="no-default-models" value="no-default-models">
No Default Models
</Select2.Option>
{userModels.map((model) => (
<Select2.Option key={model} value={model}>
{getModelDisplayName(model)}
</Select2.Option>
))}
</Select2>
</Form.Item>
</AccordionBody>
</Accordion>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="primary" icon={<UserAddOutlined />} htmlType="submit">
Invite User
</Button>
</div>
</Form>
</Modal>
Learn how to set up email notifications
</a>
</AlertDescription>
</Alert>
<form
onSubmit={handleSubmit(handleCreate)}
className="space-y-4 mt-4"
>
<div className="space-y-2">
<Label htmlFor="user_email">User Email</Label>
<Input id="user_email" {...register("user_email")} />
</div>
<div className="space-y-2">
<Label htmlFor="user_role" className="flex items-center">
Global Proxy Role
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
This role is independent of any team/org specific roles.
Configure Team / Organization Admins in the Settings
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Controller
control={control}
name="user_role"
render={({ field }) => (
<Select
value={field.value ?? ""}
onValueChange={(v) => field.onChange(v)}
>
<SelectTrigger id="user_role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(
([role, { ui_label, description }]) => (
<SelectItem
key={role}
value={role}
title={ui_label}
>
<span className="font-medium">{ui_label}</span>
<span className="ml-2 text-muted-foreground text-xs">
{" - "}
{description}
</span>
</SelectItem>
),
)}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Team</Label>
<Controller
control={control}
name="team_id"
render={({ field }) => (
<TeamDropdown
value={field.value ?? ""}
onChange={(v) => field.onChange(v)}
/>
)}
/>
<p className="text-xs text-muted-foreground">
If selected, user will be added as a &apos;user&apos; role to
the team.
</p>
</div>
<div className="space-y-2">
<Label>Organization</Label>
<Controller
control={control}
name="organization_ids"
render={({ field }) => (
<MultiSelect
value={field.value ?? []}
onChange={field.onChange}
options={orgOptions}
placeholder="Select Organization"
emptyText="No organizations available"
ariaLabel="Organization"
/>
)}
/>
<p className="text-xs text-muted-foreground">
The user will be added to the selected organization(s).
</p>
</div>
<div className="space-y-2">
<Label htmlFor="metadata">Metadata</Label>
<Textarea
id="metadata"
rows={4}
placeholder="Enter metadata as JSON"
{...register("metadata")}
/>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="personal-key-creation">
<AccordionTrigger>
<span className="font-medium">Personal Key Creation</span>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<Label className="flex items-center">
Models
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Models user has access to, outside of team scope.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Controller
control={control}
name="models"
render={({ field }) => (
<MultiSelect
value={field.value ?? []}
onChange={field.onChange}
options={modelOptions}
placeholder="Select models"
emptyText="No models available"
/>
)}
/>
<p className="text-xs text-muted-foreground">
Models user has access to, outside of team scope.
</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{errors.user_role && (
<p className="text-sm text-destructive">
{errors.user_role.message as string}
</p>
)}
<DialogFooter>
<Button type="submit">
<UserPlus className="h-4 w-4 mr-1" />
Invite User
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{apiuser && (
<OnboardingModal
isInvitationLinkModalVisible={isInvitationLinkModalVisible}

View file

@ -1,13 +1,33 @@
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import React, { useState, useEffect } from "react";
import { Card, Title, Text, Divider, TextInput } from "@tremor/react";
import { Button, Typography, Spin, Switch, Select, InputNumber } from "antd";
import { Plus as PlusOutlined, Trash2 as DeleteOutlined } from "lucide-react";
import { getInternalUserSettings, updateInternalUserSettings, modelAvailableCall } from "./networking";
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
import React, { useEffect, useMemo, useState } from "react";
import { Plus as PlusOutlined, Trash2 as DeleteOutlined, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getInternalUserSettings,
updateInternalUserSettings,
modelAvailableCall,
} from "./networking";
import BudgetDurationDropdown, {
getBudgetDurationLabel,
} from "./common_components/budget_duration_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import NotificationManager from "./molecules/notifications_manager";
import NumericalInput from "./shared/numerical_input";
interface DefaultUserSettingsProps {
accessToken: string | null;
@ -22,6 +42,79 @@ interface TeamEntry {
user_role: "user" | "admin";
}
/**
* shadcn multi-select (Select + badge chips) used for enum arrays.
*/
function MultiSelect({
value,
onChange,
options,
placeholder,
}: {
value: string[];
onChange: (next: string[]) => void;
options: { label: string; value: string }[];
placeholder: string;
}) {
const selected = useMemo(() => value ?? [], [value]);
const remaining = useMemo(
() => options.filter((o) => !selected.includes(o.value)),
[options, selected],
);
return (
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) onChange([...selected, v]);
}}
>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No more options
</div>
) : (
remaining.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map((v) => {
const opt = options.find((o) => o.value === v);
return (
<Badge
key={v}
variant="secondary"
className="flex items-center gap-1"
>
{opt?.label ?? v}
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== v))}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${opt?.label ?? v}`}
>
<X size={12} />
</button>
</Badge>
);
})}
</div>
)}
</div>
);
}
const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
accessToken,
possibleUIRoles,
@ -34,8 +127,6 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
const [editedValues, setEditedValues] = useState<any>({});
const [saving, setSaving] = useState<boolean>(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const { Paragraph } = Typography;
const { Option } = Select;
useEffect(() => {
const fetchSSOSettings = async () => {
@ -49,12 +140,17 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
setSettings(data);
setEditedValues(data.values || {});
// Fetch available models
if (accessToken) {
try {
const modelResponse = await modelAvailableCall(accessToken, userID, userRole);
const modelResponse = await modelAvailableCall(
accessToken,
userID,
userRole,
);
if (modelResponse && modelResponse.data) {
const modelNames = modelResponse.data.map((model: { id: string }) => model.id);
const modelNames = modelResponse.data.map(
(model: { id: string }) => model.id,
);
setAvailableModels(modelNames);
}
} catch (error) {
@ -70,6 +166,7 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
};
fetchSSOSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accessToken]);
const handleSaveSettings = async () => {
@ -77,7 +174,6 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
setSaving(true);
try {
// Convert empty strings to null
const processedValues = Object.entries(editedValues).reduce(
(acc, [key, value]) => {
acc[key] = value === "" ? null : value;
@ -86,7 +182,10 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
{} as Record<string, any>,
);
const updatedSettings = await updateInternalUserSettings(accessToken, processedValues);
const updatedSettings = await updateInternalUserSettings(
accessToken,
processedValues,
);
setSettings({ ...settings, values: updatedSettings.settings });
setIsEditing(false);
} catch (error) {
@ -104,7 +203,6 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
}));
};
// Helper function to normalize teams array to consistent format
const normalizeTeams = (teams: any[]): TeamEntry[] => {
if (!teams || !Array.isArray(teams)) return [];
@ -128,7 +226,6 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
});
};
// Teams editor component
const renderTeamsEditor = (teams: any[]) => {
const normalizedTeams = normalizeTeams(teams);
@ -157,35 +254,43 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
return (
<div className="space-y-3">
{normalizedTeams.map((team, index) => (
<div key={index} className="border rounded-lg p-4 bg-gray-50">
<div
key={index}
className="border border-border rounded-lg p-4 bg-muted/50"
>
<div className="flex items-center justify-between mb-3">
<Text className="font-medium">Team {index + 1}</Text>
<span className="font-medium">Team {index + 1}</span>
<Button
size="small"
danger
icon={<DeleteOutlined />}
size="sm"
variant="destructive"
onClick={() => removeTeam(index)}
>
<DeleteOutlined className="h-4 w-4 mr-1" />
Remove
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Text className="text-sm font-medium mb-1">Team ID</Text>
<TextInput
<span className="text-sm font-medium mb-1 block">Team ID</span>
<Input
value={team.team_id}
onChange={(e) => updateTeam(index, "team_id", e.target.value)}
onChange={(e) =>
updateTeam(index, "team_id", e.target.value)
}
placeholder="Enter team ID"
/>
</div>
<div>
<Text className="text-sm font-medium mb-1">Max Budget in Team</Text>
<InputNumber
style={{ width: "100%" }}
<span className="text-sm font-medium mb-1 block">
Max Budget in Team
</span>
<NumericalInput
value={team.max_budget_in_team}
onChange={(value) => updateTeam(index, "max_budget_in_team", value)}
onChange={(value: number | null) =>
updateTeam(index, "max_budget_in_team", value)
}
placeholder="Optional"
min={0}
step={0.01}
@ -194,21 +299,30 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
</div>
<div>
<Text className="text-sm font-medium mb-1">User Role</Text>
<span className="text-sm font-medium mb-1 block">
User Role
</span>
<Select
style={{ width: "100%" }}
value={team.user_role}
onChange={(value) => updateTeam(index, "user_role", value)}
onValueChange={(value) =>
updateTeam(index, "user_role", value)
}
>
<Option value="user">User</Option>
<Option value="admin">Admin</Option>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
))}
<Button icon={<PlusOutlined />} onClick={addTeam} className="w-full">
<Button variant="outline" onClick={addTeam} className="w-full">
<PlusOutlined className="h-4 w-4 mr-1" />
Add Team
</Button>
</div>
@ -219,25 +333,32 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
const type = property.type;
if (key === "teams") {
return <div className="mt-2">{renderTeamsEditor(editedValues[key] || [])}</div>;
return (
<div className="mt-2">{renderTeamsEditor(editedValues[key] || [])}</div>
);
} else if (key === "user_role" && possibleUIRoles) {
return (
<Select
style={{ width: "100%" }}
value={editedValues[key] || ""}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
onValueChange={(v) => handleTextInputChange(key, v)}
>
{Object.entries(possibleUIRoles)
.filter(([role]) => role.includes("internal_user"))
.map(([role, { ui_label, description }]) => (
<Option key={role} value={role}>
<div className="flex items-center">
<span>{ui_label}</span>
<span className="ml-2 text-xs text-gray-500">{description}</span>
</div>
</Option>
))}
<SelectTrigger className="mt-2">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{Object.entries(possibleUIRoles)
.filter(([role]) => role.includes("internal_user"))
.map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role}>
<div className="flex items-center">
<span>{ui_label}</span>
<span className="ml-2 text-xs text-muted-foreground">
{description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (key === "budget_duration") {
@ -251,66 +372,70 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
} else if (type === "boolean") {
return (
<div className="mt-2">
<Switch checked={!!editedValues[key]} onChange={(checked) => handleTextInputChange(key, checked)} />
<Switch
checked={!!editedValues[key]}
onCheckedChange={(checked) => handleTextInputChange(key, checked)}
/>
</div>
);
} else if (type === "array" && property.items?.enum) {
const options = property.items.enum.map((option: string) => ({
label: option,
value: option,
}));
return (
<Select
mode="multiple"
style={{ width: "100%" }}
value={editedValues[key] || []}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{property.items.enum.map((option: string) => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
<div className="mt-2">
<MultiSelect
value={editedValues[key] || []}
onChange={(v) => handleTextInputChange(key, v)}
options={options}
placeholder="Select options"
/>
</div>
);
} else if (key === "models") {
const options = [
{ label: "No Default Models", value: "no-default-models" },
{ label: "All Proxy Models", value: "all-proxy-models" },
...availableModels.map((model) => ({
label: getModelDisplayName(model),
value: model,
})),
];
return (
<Select
mode="multiple"
style={{ width: "100%" }}
value={editedValues[key] || []}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
<Option key="no-default-models" value="no-default-models">
No Default Models
</Option>
<Option key="all-proxy-models" value="all-proxy-models">
All Proxy Models
</Option>
{availableModels.map((model: string) => (
<Option key={model} value={model}>
{getModelDisplayName(model)}
</Option>
))}
</Select>
<div className="mt-2">
<MultiSelect
value={editedValues[key] || []}
onChange={(v) => handleTextInputChange(key, v)}
options={options}
placeholder="Select models"
/>
</div>
);
} else if (type === "string" && property.enum) {
return (
<Select
style={{ width: "100%" }}
value={editedValues[key] || ""}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
onValueChange={(v) => handleTextInputChange(key, v)}
>
{property.enum.map((option: string) => (
<Option key={option} value={option}>
{option}
</Option>
))}
<SelectTrigger className="mt-2">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{property.enum.map((option: string) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else {
return (
<TextInput
value={editedValues[key] !== undefined ? String(editedValues[key]) : ""}
<Input
value={
editedValues[key] !== undefined ? String(editedValues[key]) : ""
}
onChange={(e) => handleTextInputChange(key, e.target.value)}
placeholder={property.description || ""}
className="mt-2"
@ -320,33 +445,48 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
};
const renderValue = (key: string, value: any): JSX.Element => {
if (value === null || value === undefined) return <span className="text-gray-400">Not set</span>;
if (value === null || value === undefined)
return <span className="text-muted-foreground">Not set</span>;
if (key === "teams" && Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">No teams assigned</span>;
if (value.length === 0)
return (
<span className="text-muted-foreground">No teams assigned</span>
);
const normalizedTeams = normalizeTeams(value);
return (
<div className="space-y-2 mt-1">
{normalizedTeams.map((team, index) => (
<div key={index} className="border rounded-lg p-3 bg-white">
<div
key={index}
className="border border-border rounded-lg p-3 bg-background"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm">
<div>
<span className="font-medium text-gray-600">Team ID:</span>
<p className="text-gray-900">{team.team_id || "Not specified"}</p>
<span className="font-medium text-muted-foreground">
Team ID:
</span>
<p className="text-foreground">
{team.team_id || "Not specified"}
</p>
</div>
<div>
<span className="font-medium text-gray-600">Max Budget:</span>
<p className="text-gray-900">
<span className="font-medium text-muted-foreground">
Max Budget:
</span>
<p className="text-foreground">
{team.max_budget_in_team !== undefined
? `$${formatNumberWithCommas(team.max_budget_in_team, 4)}`
: "No limit"}
</p>
</div>
<div>
<span className="font-medium text-gray-600">Role:</span>
<p className="text-gray-900 capitalize">{team.user_role}</p>
<span className="font-medium text-muted-foreground">
Role:
</span>
<p className="text-foreground capitalize">{team.user_role}</p>
</div>
</div>
</div>
@ -360,7 +500,9 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
return (
<div>
<span className="font-medium">{ui_label}</span>
{description && <p className="text-xs text-gray-500 mt-1">{description}</p>}
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</div>
);
}
@ -374,12 +516,16 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
}
if (key === "models" && Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">None</span>;
if (value.length === 0)
return <span className="text-muted-foreground">None</span>;
return (
<div className="flex flex-wrap gap-2 mt-1">
{value.map((model, index) => (
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
<span
key={index}
className="px-2 py-1 bg-blue-100 dark:bg-blue-950/50 text-blue-800 dark:text-blue-300 rounded text-xs"
>
{getModelDisplayName(model)}
</span>
))}
@ -389,12 +535,16 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
if (typeof value === "object") {
if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">None</span>;
if (value.length === 0)
return <span className="text-muted-foreground">None</span>;
return (
<div className="flex flex-wrap gap-2 mt-1">
{value.map((item, index) => (
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
<span
key={index}
className="px-2 py-1 bg-blue-100 dark:bg-blue-950/50 text-blue-800 dark:text-blue-300 rounded text-xs"
>
{typeof item === "object" ? JSON.stringify(item) : String(item)}
</span>
))}
@ -402,7 +552,11 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
);
}
return <pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">{JSON.stringify(value, null, 2)}</pre>;
return (
<pre className="bg-muted p-2 rounded text-xs overflow-auto mt-1">
{JSON.stringify(value, null, 2)}
</pre>
);
}
return <span>{String(value)}</span>;
@ -410,58 +564,73 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<Spin size="large" />
<div className="flex flex-col gap-3 p-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
</div>
);
}
if (!settings) {
return (
<Card>
<Text>No settings available or you do not have permission to view them.</Text>
<Card className="p-6">
<span>
No settings available or you do not have permission to view them.
</span>
</Card>
);
}
// Dynamically render settings based on the schema
const renderSettings = () => {
const { values, field_schema } = settings;
if (!field_schema || !field_schema.properties) {
return <Text>No schema information available</Text>;
return <span>No schema information available</span>;
}
return Object.entries(field_schema.properties).map(([key, property]: [string, any]) => {
const value = values[key];
const displayName = key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
return Object.entries(field_schema.properties).map(
([key, property]: [string, any]) => {
const value = values[key];
const displayName = key
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase());
return (
<div key={key} className="mb-6 pb-6 border-b border-gray-200 last:border-0">
<Text className="font-medium text-lg">{displayName}</Text>
<Paragraph className="text-sm text-gray-500 mt-1">
{property.description || "No description available"}
</Paragraph>
return (
<div
key={key}
className="mb-6 pb-6 border-b border-border last:border-0"
>
<span className="font-medium text-lg block">{displayName}</span>
<p className="text-sm text-muted-foreground mt-1">
{property.description || "No description available"}
</p>
{isEditing ? (
<div className="mt-2">{renderEditableField(key, property, value)}</div>
) : (
<div className="mt-1 p-2 bg-gray-50 rounded">{renderValue(key, value)}</div>
)}
</div>
);
});
{isEditing ? (
<div className="mt-2">
{renderEditableField(key, property, value)}
</div>
) : (
<div className="mt-1 p-2 bg-muted/50 rounded">
{renderValue(key, value)}
</div>
)}
</div>
);
},
);
};
return (
<Card>
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<Title>Default User Settings</Title>
<h2 className="text-2xl font-semibold m-0">Default User Settings</h2>
{!loading &&
settings &&
(isEditing ? (
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setIsEditing(false);
setEditedValues(settings.values || {});
@ -470,19 +639,19 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
>
Cancel
</Button>
<Button type="primary" onClick={handleSaveSettings} loading={saving}>
Save Changes
<Button onClick={handleSaveSettings} disabled={saving}>
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
) : (
<Button type="primary" onClick={() => setIsEditing(true)}>Edit Settings</Button>
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
))}
</div>
{settings?.field_schema?.description && (
<Paragraph className="mb-4">{settings.field_schema.description}</Paragraph>
<p className="mb-4">{settings.field_schema.description}</p>
)}
<Divider />
<Separator />
<div className="mt-4 space-y-4">{renderSettings()}</div>
</Card>

View file

@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
@ -8,9 +9,17 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Form, Select } from "antd";
import { Info } from "lucide-react";
import React, { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Info, X } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { all_admin_roles } from "../utils/roles";
import BudgetDurationDropdown from "./common_components/budget_duration_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
@ -32,6 +41,96 @@ interface UserEditViewProps {
isBulkEdit?: boolean;
}
interface UserEditFormValues {
user_id?: string;
user_email?: string;
user_alias?: string;
user_role?: string;
models: string[];
max_budget: number | string | null;
budget_duration?: string | null;
metadata?: string;
}
/**
* Multi-select with shadcn Select + badge chip list. Mirrors the pattern
* used in `AccessGroupBaseForm.tsx`.
*/
function ModelMultiSelect({
value,
onChange,
options,
disabled,
placeholder,
}: {
value: string[];
onChange: (next: string[]) => void;
options: { label: string; value: string }[];
disabled?: boolean;
placeholder: string;
}) {
const selected = useMemo(() => value ?? [], [value]);
const remaining = useMemo(
() => options.filter((o) => !selected.includes(o.value)),
[options, selected],
);
return (
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) onChange([...selected, v]);
}}
disabled={disabled}
>
<SelectTrigger aria-label={placeholder}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No more options
</div>
) : (
remaining.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map((v) => {
const opt = options.find((o) => o.value === v);
return (
<Badge
key={v}
variant="secondary"
className="flex items-center gap-1"
>
{opt?.label ?? v}
{!disabled && (
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== v))}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${opt?.label ?? v}`}
>
<X size={12} />
</button>
)}
</Badge>
);
})}
</div>
)}
</div>
);
}
export function UserEditView({
userData,
onCancel,
@ -47,15 +146,39 @@ export function UserEditView({
possibleUIRoles,
isBulkEdit = false,
}: UserEditViewProps) {
const [form] = Form.useForm();
const [unlimitedBudget, setUnlimitedBudget] = useState(false);
const {
control,
register,
handleSubmit,
setValue,
reset,
formState: { errors },
} = useForm<UserEditFormValues>({
defaultValues: {
user_id: userData.user_id,
user_email: userData.user_info?.user_email,
user_alias: userData.user_info?.user_alias,
user_role: userData.user_info?.user_role,
models: userData.user_info?.models || [],
max_budget:
userData.user_info?.max_budget === null ||
userData.user_info?.max_budget === undefined
? ""
: userData.user_info?.max_budget,
budget_duration: userData.user_info?.budget_duration,
metadata: userData.user_info?.metadata
? JSON.stringify(userData.user_info.metadata, null, 2)
: undefined,
},
});
React.useEffect(() => {
useEffect(() => {
const maxBudget = userData.user_info?.max_budget;
const isUnlimited = maxBudget === null || maxBudget === undefined;
setUnlimitedBudget(isUnlimited);
form.setFieldsValue({
reset({
user_id: userData.user_id,
user_email: userData.user_info?.user_email,
user_alias: userData.user_info?.user_alias,
@ -67,20 +190,22 @@ export function UserEditView({
? JSON.stringify(userData.user_info.metadata, null, 2)
: undefined,
});
}, [userData, form]);
}, [userData, reset]);
const handleUnlimitedBudgetChange = (checked: boolean) => {
setUnlimitedBudget(checked);
if (checked) {
form.setFieldsValue({ max_budget: "" });
setValue("max_budget", "");
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSubmit = (values: any) => {
if (values.metadata && typeof values.metadata === "string") {
const submitHandler = (values: UserEditFormValues) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const out: Record<string, any> = { ...values };
if (out.metadata && typeof out.metadata === "string") {
try {
values.metadata = JSON.parse(values.metadata);
out.metadata = JSON.parse(out.metadata);
} catch (error) {
console.error("Error parsing metadata JSON:", error);
return;
@ -89,156 +214,196 @@ export function UserEditView({
if (
unlimitedBudget ||
values.max_budget === "" ||
values.max_budget === undefined
out.max_budget === "" ||
out.max_budget === undefined
) {
values.max_budget = null;
out.max_budget = null;
}
onSubmit(values);
onSubmit(out);
};
const modelOptions = useMemo(() => {
const base = [
{ label: "All Proxy Models", value: "all-proxy-models" },
{ label: "No Default Models", value: "no-default-models" },
];
const extras = userModels.map((model) => ({
label: getModelDisplayName(model),
value: model,
}));
return [...base, ...extras];
}, [userModels]);
const modelsDisabled = !all_admin_roles.includes(userRole || "");
const possibleRoleEntries = possibleUIRoles
? Object.entries(possibleUIRoles)
: [];
return (
<Form form={form} onFinish={handleSubmit} layout="vertical">
<form onSubmit={handleSubmit(submitHandler)} className="space-y-4">
{!isBulkEdit && (
<Form.Item label="User ID" name="user_id">
<Input disabled />
</Form.Item>
<div className="space-y-2">
<Label htmlFor="user_id">User ID</Label>
<Input id="user_id" disabled {...register("user_id")} />
</div>
)}
{!isBulkEdit && (
<Form.Item label="Email" name="user_email">
<Input />
</Form.Item>
<div className="space-y-2">
<Label htmlFor="user_email">Email</Label>
<Input id="user_email" {...register("user_email")} />
</div>
)}
<Form.Item label="User Alias" name="user_alias">
<Input />
</Form.Item>
<div className="space-y-2">
<Label htmlFor="user_alias">User Alias</Label>
<Input id="user_alias" {...register("user_alias")} />
</div>
<Form.Item
label={
<span>
Global Proxy Role{" "}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
This is the role that the user will globally on the proxy.
This role is independent of any team/org specific roles.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
}
name="user_role"
>
<Select>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(
([role, { ui_label, description }]) => (
<Select.Option key={role} value={role} title={ui_label}>
<div className="flex">
{ui_label}{" "}
<p className="ml-2 text-muted-foreground text-xs">
{description}
</p>
</div>
</Select.Option>
),
)}
</Select>
</Form.Item>
<div className="space-y-2">
<Label htmlFor="user_role" className="flex items-center">
Global Proxy Role
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
This is the role that the user will globally on the proxy. This
role is independent of any team/org specific roles.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Controller
control={control}
name="user_role"
render={({ field }) => (
<Select
value={field.value ?? ""}
onValueChange={(v) => field.onChange(v)}
>
<SelectTrigger id="user_role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{possibleRoleEntries.map(
([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<div className="flex">
{ui_label}
<p className="ml-2 text-muted-foreground text-xs">
{description}
</p>
</div>
</SelectItem>
),
)}
</SelectContent>
</Select>
)}
/>
</div>
<Form.Item
label={
<span>
Personal Models{" "}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Select which models this user can access outside of
team-scope. Choose &apos;All Proxy Models&apos; to grant
access to all models available on the proxy.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
}
name="models"
>
<Select
mode="multiple"
placeholder="Select models"
style={{ width: "100%" }}
disabled={!all_admin_roles.includes(userRole || "")}
>
<Select.Option key="all-proxy-models" value="all-proxy-models">
All Proxy Models
</Select.Option>
<Select.Option key="no-default-models" value="no-default-models">
No Default Models
</Select.Option>
{userModels.map((model) => (
<Select.Option key={model} value={model}>
{getModelDisplayName(model)}
</Select.Option>
))}
</Select>
</Form.Item>
<div className="space-y-2">
<Label className="flex items-center">
Personal Models
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Select which models this user can access outside of team-scope.
Choose &apos;All Proxy Models&apos; to grant access to all
models available on the proxy.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Controller
control={control}
name="models"
render={({ field }) => (
<ModelMultiSelect
value={field.value ?? []}
onChange={field.onChange}
options={modelOptions}
disabled={modelsDisabled}
placeholder="Select models"
/>
)}
/>
</div>
<Form.Item
label={
<div className="flex items-center gap-3">
<span>Max Budget (USD)</span>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={unlimitedBudget}
onCheckedChange={(c) => handleUnlimitedBudgetChange(c === true)}
/>
<span className="text-sm">Unlimited Budget</span>
</label>
</div>
}
name="max_budget"
rules={[
{
validator: (_, value) => {
<div className="space-y-2">
<div className="flex items-center gap-3">
<Label htmlFor="max_budget">Max Budget (USD)</Label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={unlimitedBudget}
onCheckedChange={(c) => handleUnlimitedBudgetChange(c === true)}
/>
<span className="text-sm">Unlimited Budget</span>
</label>
</div>
<Controller
control={control}
name="max_budget"
rules={{
validate: (value) => {
if (
!unlimitedBudget &&
(value === "" || value === null || value === undefined)
) {
return Promise.reject(
new Error(
"Please enter a budget or select Unlimited Budget",
),
);
return "Please enter a budget or select Unlimited Budget";
}
return Promise.resolve();
return true;
},
},
]}
>
<NumericalInput
step={0.01}
precision={2}
style={{ width: "100%" }}
disabled={unlimitedBudget}
}}
render={({ field }) => (
<NumericalInput
id="max_budget"
step={0.01}
precision={2}
style={{ width: "100%" }}
disabled={unlimitedBudget}
value={field.value as number | string | null | undefined}
onChange={(v: number | null) => field.onChange(v === null ? "" : v)}
/>
)}
/>
</Form.Item>
{errors.max_budget && (
<p className="text-sm text-destructive">
{errors.max_budget.message as string}
</p>
)}
</div>
<Form.Item label="Reset Budget" name="budget_duration">
<BudgetDurationDropdown />
</Form.Item>
<div className="space-y-2">
<Label htmlFor="budget_duration">Reset Budget</Label>
<Controller
control={control}
name="budget_duration"
render={({ field }) => (
<BudgetDurationDropdown
value={field.value ?? null}
onChange={(v) => field.onChange(v)}
/>
)}
/>
</div>
<Form.Item label="Metadata" name="metadata">
<Textarea rows={4} placeholder="Enter metadata as JSON" />
</Form.Item>
<div className="space-y-2">
<Label htmlFor="metadata">Metadata</Label>
<Textarea
id="metadata"
rows={4}
placeholder="Enter metadata as JSON"
{...register("metadata")}
/>
</div>
<div className="flex justify-end space-x-2">
<Button variant="secondary" type="button" onClick={onCancel}>
@ -246,6 +411,6 @@ export function UserEditView({
</Button>
<Button type="submit">Save Changes</Button>
</div>
</Form>
</form>
);
}