mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
26c6978808
commit
b861fd3fd4
8 changed files with 2138 additions and 1170 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 "user" 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 "user" 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 'user' 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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 'All Proxy Models' 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 'All Proxy Models' 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue