diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 38d4c435e9a..2e68ed74b54 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1449,14 +1449,6 @@ "count": 1 } }, - "src/components/bulk_create_users_button.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/callback_info_helpers.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/BulkInviteButton.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/BulkInviteButton.tsx new file mode 100644 index 00000000000..f9da7cca6d1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/BulkInviteButton.tsx @@ -0,0 +1,31 @@ +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; + +import { BulkInviteDialog } from "./BulkInviteDialog"; + +interface BulkInviteButtonProps { + accessToken: string; + teams: readonly { team_id: string }[] | null; + possibleUIRoles: null | Record>; + onUsersCreated?: () => void; +} + +export function BulkInviteButton({ accessToken, teams, onUsersCreated }: BulkInviteButtonProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + setIsOpen(false)} + accessToken={accessToken} + teams={teams} + onUsersCreated={onUsersCreated} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/BulkInviteDialog.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/BulkInviteDialog.integration.test.tsx new file mode 100644 index 00000000000..917f2f99bba --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/BulkInviteDialog.integration.test.tsx @@ -0,0 +1,229 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "../../../../../../tests/test-utils"; +import { BulkInviteButton } from "./BulkInviteButton"; + +const { userCreateCall, invitationCreateCall } = vi.hoisted(() => ({ + userCreateCall: vi.fn(), + invitationCreateCall: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ + userCreateCall, + invitationCreateCall, + getProxyBaseUrl: () => "", + getUiPageUrl: (url: string) => url, + getProxyUISettings: vi.fn().mockResolvedValue({ SSO_ENABLED: false }), +})); + +const csvFile = (text: string) => new File([text], "users.csv", { type: "text/csv" }); + +const readBlob = (blob: Blob) => + new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsText(blob); + }); + +const openDialog = async (onUsersCreated?: () => void) => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + await user.click(screen.getByRole("button", { name: "+ Bulk Invite Users" })); + return user; +}; + +const fileInput = () => screen.getByLabelText(/drag and drop your csv file here/i); + +describe("Bulk invite dialog", () => { + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + const createObjectURL = vi.fn((_blob: Blob) => "blob:download"); + const revokeObjectURL = vi.fn(); + let anchorClick: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = revokeObjectURL; + anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + }); + + afterEach(() => { + anchorClick.mockRestore(); + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }); + + const lastDownload = async () => { + const anchor = anchorClick.mock.contexts.at(-1) as HTMLAnchorElement; + return { filename: anchor.download, text: await readBlob(createObjectURL.mock.calls.at(-1)![0]) }; + }; + + it("downloads a CSV template with the columns the upload accepts", async () => { + const user = await openDialog(); + + await user.click(screen.getByRole("button", { name: /download csv template/i })); + + const { filename, text } = await lastDownload(); + expect(filename).toBe("bulk_users_template.csv"); + expect(text.split(/\r?\n/)[0]).toBe("user_email,user_role,teams,max_budget,budget_duration,models"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:download"); + }); + + it("exposes the drop zone as a label for a keyboard-reachable file input", async () => { + await openDialog(); + + const input = fileInput(); + expect(input).toHaveAttribute("type", "file"); + expect(input).toHaveAttribute("accept", ".csv"); + expect(input).toBeVisible(); + expect(input.closest("label")).toHaveAttribute("for", input.id); + const danglingLabels = [...document.querySelectorAll("label[for]")].filter( + (label) => document.getElementById(label.getAttribute("for") as string) === null, + ); + expect(danglingLabels).toEqual([]); + }); + + it("parses a CSV dropped onto the drop zone", async () => { + await openDialog(); + + fireEvent.drop(fileInput(), { + dataTransfer: { + files: [csvFile("user_email,user_role\nnew.hire@example.com,internal_user\n")], + types: ["Files"], + }, + }); + + expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument(); + }); + + it("shows a structure error for a CSV missing required columns", async () => { + await openDialog(); + + fireEvent.change(fileInput(), { target: { files: [csvFile("email\nnew.hire@example.com\n")] } }); + + expect(await screen.findByText("CSV Structure Error")).toBeInTheDocument(); + expect( + screen.getByText( + "Your CSV is missing these required columns: user_email, user_role. Please add these columns to your CSV file.", + ), + ).toBeInTheDocument(); + }); + + it("previews valid and invalid rows, creates the users, and downloads the results", async () => { + const onUsersCreated = vi.fn(); + vi.spyOn(console, "error").mockImplementation(() => {}); + userCreateCall.mockImplementation(async (_token: string, _userId: null, body: { user_email: string }) => { + const [name, domain] = body.user_email.split("@"); + if (!domain) throw new Error("invalid email"); + return { key: `sk-${name}`, user_id: `user-${name}` }; + }); + invitationCreateCall.mockImplementation(async (_token: string, userId: string) => ({ id: `inv-${userId}` })); + const user = await openDialog(onUsersCreated); + + fireEvent.change(fileInput(), { + target: { + files: [ + csvFile( + "user_email,user_role,teams,max_budget\n" + + "first@example.com,internal_user,team-a,10\n" + + "broken,nope,team-a,10\n" + + "second@example.com,proxy_admin,,\n", + ), + ], + }, + }); + + expect(await screen.findByText("Review and create users")).toBeInTheDocument(); + expect( + screen.getByText("Found 1 row(s) with errors out of 3 total rows. Please correct them before proceeding."), + ).toBeInTheDocument(); + expect(screen.getByText("2 of 3 users valid")).toBeInTheDocument(); + const brokenRow = screen.getByRole("row", { name: /broken/ }); + expect(within(brokenRow).getByText("Invalid")).toBeInTheDocument(); + expect(within(brokenRow).getByText(/Invalid email format/)).toBeInTheDocument(); + + await user.click(screen.getAllByRole("button", { name: "Create 2 Users" })[0]); + + expect(await screen.findByText("User Creation Results")).toBeInTheDocument(); + expect(await screen.findByText("2 Successful")).toBeInTheDocument(); + const firstRequest = { + user_email: "first@example.com", + user_role: "internal_user", + teams: ["team-a"], + max_budget: 10, + }; + expect(userCreateCall).toHaveBeenCalledWith("test-token", null, firstRequest); + expect(userCreateCall).toHaveBeenCalledWith("test-token", null, { + user_email: "second@example.com", + user_role: "proxy_admin", + }); + expect(invitationCreateCall).toHaveBeenCalledWith("test-token", "user-first"); + expect(invitationCreateCall).toHaveBeenCalledWith("test-token", "user-second"); + expect(onUsersCreated).toHaveBeenCalledOnce(); + + const firstRow = screen.getByRole("row", { name: /first@example.com/ }); + expect(within(firstRow).getByText("Success")).toBeInTheDocument(); + expect( + within(firstRow).getByText(`${window.location.origin}/ui/onboarding?invitation_id=inv-user-first`), + ).toBeInTheDocument(); + expect(within(firstRow).getByRole("button", { name: "Copy" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /download user credentials/i })); + + const { filename, text } = await lastDownload(); + expect(filename).toBe("bulk_users_results.csv"); + const lines = text.split(/\r?\n/); + expect(lines[0]).toBe("user_email,user_role,status,key,invitation_link,error"); + expect(lines).toContain( + `first@example.com,internal_user,success,sk-first,${window.location.origin}/ui/onboarding?invitation_id=inv-user-first,`, + ); + expect(lines).toContain( + `second@example.com,proxy_admin,success,sk-second,${window.location.origin}/ui/onboarding?invitation_id=inv-user-second,`, + ); + }); + + it("waits for each user to be created before creating the next", async () => { + const pending: Array<(value: { key: string; user_id: string }) => void> = []; + userCreateCall.mockImplementation(() => new Promise((resolve) => pending.push(resolve))); + invitationCreateCall.mockResolvedValue({ id: "inv" }); + const user = await openDialog(); + fireEvent.change(fileInput(), { + target: { files: [csvFile("user_email,user_role\na@example.com,internal_user\nb@example.com,internal_user")] }, + }); + + await user.click((await screen.findAllByRole("button", { name: "Create 2 Users" }))[0]); + + expect(userCreateCall).toHaveBeenCalledOnce(); + pending[0]({ key: "sk-a", user_id: "user-a" }); + await vi.waitFor(() => expect(userCreateCall).toHaveBeenCalledTimes(2)); + pending[1]({ key: "sk-b", user_id: "user-b" }); + expect(await screen.findByText("2 Successful")).toBeInTheDocument(); + }); + + it("pages the preview five rows at a time", async () => { + const user = await openDialog(); + const lines = Array.from({ length: 7 }, (_, i) => `user${i + 1}@example.com,internal_user`); + + fireEvent.change(fileInput(), { target: { files: [csvFile(["user_email,user_role", ...lines].join("\n"))] } }); + + expect(await screen.findByText("Page 1 of 2")).toBeInTheDocument(); + expect(screen.getByText("user5@example.com")).toBeInTheDocument(); + expect(screen.queryByText("user6@example.com")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Next" })); + + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + expect(screen.getByText("user7@example.com")).toBeInTheDocument(); + expect(screen.queryByText("user1@example.com")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/BulkInviteDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/BulkInviteDialog.tsx new file mode 100644 index 00000000000..f7ee6e34510 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/BulkInviteDialog.tsx @@ -0,0 +1,180 @@ +import { CheckCircleIcon } from "@heroicons/react/outline"; +import { Download, TriangleAlert } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +import { CsvDropzone } from "./CsvDropzone"; +import { PreviewTable } from "./PreviewTable"; +import { StepHeading } from "./StepHeading"; +import { TemplateStep } from "./TemplateStep"; +import { useBulkInvite, type BulkInviteRow } from "./useBulkInvite"; + +export interface BulkInviteDialogProps { + open: boolean; + onClose: () => void; + accessToken: string; + teams: readonly { team_id: string }[] | null; + onUsersCreated?: () => void; +} + +type BulkInvite = ReturnType; + +function ParseErrorNotice({ parseError, rows }: { parseError: string; rows: BulkInviteRow[] }) { + return ( +
+
+ +
+

{parseError}

+ {rows.some((row) => !row.isValid) && ( +
    +
  • Check the table below for specific errors in each row
  • +
  • Common issues include invalid email formats, missing required fields, or incorrect role values
  • +
  • Fix these issues in your CSV file and upload again
  • +
+ )} +
+
+
+ ); +} + +function Summary({ rows, hasResults }: { rows: BulkInviteRow[]; hasResults: boolean }) { + if (!hasResults) { + return ( +
+

User Preview

+

+ {rows.filter((row) => row.isValid).length} of {rows.length} users valid +

+
+ ); + } + const failedCount = rows.filter((row) => row.status === "failed").length; + return ( +
+

Creation Summary

+

+ {rows.filter((row) => row.status === "success").length} Successful +

+ {failedCount > 0 && ( +

{failedCount} Failed

+ )} +
+ ); +} + +function CreateButton({ bulkInvite }: { bulkInvite: BulkInvite }) { + const validCount = bulkInvite.rows.filter((row) => row.isValid).length; + return ( + + ); +} + +function CreationCompleteNotice() { + return ( +
+
+
+ +
+
+

User creation complete

+

+ Next step: Download the credentials file containing Virtual Keys and + invitation links. Users will need these Virtual Keys to make LLM requests through LiteLLM. +

+
+
+
+ ); +} + +function ReviewStep({ bulkInvite }: { bulkInvite: BulkInvite }) { + const { rows, parseError } = bulkInvite; + const hasResults = rows.some((row) => row.status === "success" || row.status === "failed"); + + return ( +
+ + + {parseError && } + +
+
+
+ +
+ + {!hasResults && ( +
+ + +
+ )} +
+ + {rows.some((row) => row.status === "success") && } + + + + {hasResults ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ ); +} + +export function BulkInviteDialog({ open, onClose, accessToken, teams, onUsersCreated }: BulkInviteDialogProps) { + const bulkInvite = useBulkInvite({ accessToken, teams, onUsersCreated }); + + return ( + !isOpen && onClose()}> + + + Bulk Invite Users + +
+ {bulkInvite.rows.length === 0 ? ( +
+ + +
+ +
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/CsvDropzone.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/CsvDropzone.tsx new file mode 100644 index 00000000000..8f0c74a800f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/CsvDropzone.tsx @@ -0,0 +1,137 @@ +import { ExclamationIcon } from "@heroicons/react/outline"; +import { FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react"; +import React, { useId, useState } from "react"; + +import { Button, buttonVariants } from "@/components/ui/button"; + +interface CsvDropzoneProps { + selectedFile: File | null; + fileError: string | null; + csvStructureError: string | null; + onFile: (file: File) => void; + onRemove: () => void; +} + +function SelectedFileCard({ + file, + fileError, + csvStructureError, + onRemove, +}: Omit & { file: File }) { + const tone = fileError ? "text-destructive" : "text-info"; + return ( +
+
+
+ {fileError ? ( + + ) : ( + + )} +
+ {file.name} + + {(file.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()} + +
+
+ +
+ + {fileError && ( +
+ + {fileError} +
+ )} + {!fileError && !csvStructureError && ( +
+
+
+
+ Processing... +
+ )} +
+ ); +} + +function DropTarget({ onFile }: Pick) { + const inputId = useId(); + const [isDraggingOver, setIsDraggingOver] = useState(false); + + const handleInputChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) onFile(file); + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(true); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(false); + const file = event.dataTransfer.files?.[0]; + if (file) onFile(file); + }; + + return ( + + ); +} + +export function CsvDropzone({ selectedFile, fileError, csvStructureError, onFile, onRemove }: CsvDropzoneProps) { + return ( + <> + {selectedFile ? ( + + ) : ( + + )} + + {csvStructureError && ( +
+
+ +
+ CSV Structure Error +

{csvStructureError}

+

+ Please download our template and ensure your CSV follows the required format. +

+
+
+
+ )} + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/PreviewTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/PreviewTable.tsx new file mode 100644 index 00000000000..2ef2a3fdcc8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/PreviewTable.tsx @@ -0,0 +1,127 @@ +import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/outline"; +import { CopyToClipboard } from "react-copy-to-clipboard"; + +import { Button } from "@/components/ui/button"; +import { + NUMERIC_CELL_CLASS, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "@/lib/toast"; + +import type { BulkInviteRow } from "./useBulkInvite"; + +const PREVIEW_PAGE_SIZE = 5; + +function FailureStatus({ label, error }: { label: string; error: string | undefined }) { + return ( +
+
+ + {label} +
+ {error && {error}} +
+ ); +} + +function SuccessStatus({ invitationLink }: { invitationLink: string | undefined }) { + return ( +
+
+ + Success +
+ {invitationLink && ( +
+
+ {invitationLink} + toast.success("Invitation link copied!")}> + + +
+
+ )} +
+ ); +} + +function StatusCell({ row }: { row: BulkInviteRow }) { + if (!row.isValid) return ; + if (!row.status || row.status === "pending") return Pending; + if (row.status === "success") return ; + return ; +} + +interface PreviewTableProps { + rows: BulkInviteRow[]; + pageIndex: number; + onPageChange: (pageIndex: number) => void; +} + +export function PreviewTable({ rows, pageIndex, onPageChange }: PreviewTableProps) { + const pageCount = Math.max(1, Math.ceil(rows.length / PREVIEW_PAGE_SIZE)); + const currentPage = Math.min(pageIndex, pageCount - 1); + const visibleRows = rows.slice(currentPage * PREVIEW_PAGE_SIZE, (currentPage + 1) * PREVIEW_PAGE_SIZE); + + return ( + <> +
+ + + + Row + Email + Role + Teams + Budget + Status + + + + {visibleRows.map((row) => ( + + {row.rowNumber} + {row.user_email} + {row.user_role} + {row.teams} + {row.max_budget} + + + + + ))} + +
+
+ + {pageCount > 1 && ( +
+ + Page {currentPage + 1} of {pageCount} + + + +
+ )} + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/StepHeading.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/StepHeading.tsx new file mode 100644 index 00000000000..21c752ddc50 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/StepHeading.tsx @@ -0,0 +1,10 @@ +export function StepHeading({ step, title }: { step: number; title: string }) { + return ( +
+
+ {step} +
+

{title}

+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/TemplateStep.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/TemplateStep.tsx new file mode 100644 index 00000000000..97c31e5a9df --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/TemplateStep.tsx @@ -0,0 +1,49 @@ +import { Download } from "lucide-react"; + +import { downloadBlob } from "@/components/EntityUsageExport/utils"; +import { Button } from "@/components/ui/button"; + +import { TEMPLATE_COLUMNS, TEMPLATE_CSV } from "./columns"; +import { StepHeading } from "./StepHeading"; + +const downloadTemplate = () => downloadBlob(new Blob([TEMPLATE_CSV], { type: "text/csv" }), "bulk_users_template.csv"); + +export function TemplateStep() { + return ( + <> + + +
+

Add multiple users at once by following these steps:

+
    +
  1. Download our CSV template
  2. +
  3. Add your users' information to the spreadsheet
  4. +
  5. Save the file and upload it here
  6. +
  7. After creation, download the results file containing the Virtual Keys for each user
  8. +
+ +
+

Template Column Names

+
+ {TEMPLATE_COLUMNS.map((column) => ( +
+
+
+

{column.name}

+

{column.help}

+
+
+ ))} +
+
+ + +
+ + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/columns.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/columns.ts new file mode 100644 index 00000000000..0e030455566 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/columns.ts @@ -0,0 +1,53 @@ +import Papa from "papaparse"; + +import { USER_ROLES } from "../userRoles"; + +export const TEMPLATE_COLUMNS = [ + { + name: "user_email", + required: true, + help: "User's email address (required)", + example: "user@example.com", + }, + { + name: "user_role", + required: true, + help: `User's role (one of: ${USER_ROLES.map((role) => `"${role}"`).join(", ")})`, + example: "internal_user", + }, + { + name: "teams", + required: false, + help: 'Comma-separated team IDs (e.g., "team-1,team-2")', + example: "team-id-1,team-id-2", + }, + { + name: "max_budget", + required: false, + help: 'Maximum budget as a number (e.g., "100")', + example: "100", + }, + { + name: "budget_duration", + required: false, + help: 'Budget reset period (e.g., "30d", "1mo")', + example: "30d", + }, + { + name: "models", + required: false, + help: 'Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4")', + example: "gpt-3.5-turbo,gpt-4", + }, +] as const; + +export type ColumnName = (typeof TEMPLATE_COLUMNS)[number]["name"]; + +export const REQUIRED_COLUMNS: readonly ColumnName[] = TEMPLATE_COLUMNS.filter((column) => column.required).map( + (column) => column.name, +); + +export const TEMPLATE_CSV = Papa.unparse([ + TEMPLATE_COLUMNS.map((column) => column.name), + TEMPLATE_COLUMNS.map((column) => column.example), +]); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/createUser.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/createUser.test.ts new file mode 100644 index 00000000000..877c190ed3b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/createUser.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createUserFromRow, type BulkInviteApi, type CreatedUser } from "./createUser"; +import type { CsvRow } from "./parseCsv"; + +const BASE_URL = "https://ui.example.test"; + +const row = (overrides: Partial = {}): CsvRow => ({ + rowNumber: 2, + user_email: "new.hire@example.com", + user_role: "internal_user", + isValid: true, + error: "", + ...overrides, +}); + +const fakeApi = (created: CreatedUser | null = { key: "sk-new", user_id: "user-new" }) => ({ + createUser: vi.fn().mockResolvedValue(created), + createInvitation: vi.fn().mockResolvedValue({ id: "inv-123" }), +}); + +const invite = { ssoEnabled: false, baseUrl: BASE_URL }; + +describe("createUserFromRow", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("create-user request", () => { + it("sends only email and role when the optional cells are absent", async () => { + const api = fakeApi(); + await createUserFromRow(row(), api, invite); + + expect(api.createUser).toHaveBeenCalledExactlyOnceWith({ + user_email: "new.hire@example.com", + user_role: "internal_user", + }); + }); + + it("splits comma-separated teams and models, trimming and dropping blanks", async () => { + const api = fakeApi(); + const cells = { + teams: "team-a, team-b,,", + models: " gpt-4o , ,claude-x", + max_budget: "12.5", + budget_duration: "30d", + }; + const expected = { + user_email: "new.hire@example.com", + user_role: "internal_user", + teams: ["team-a", "team-b"], + models: ["gpt-4o", "claude-x"], + max_budget: 12.5, + budget_duration: "30d", + }; + + await createUserFromRow(row(cells), api, invite); + + expect(api.createUser.mock.calls[0][0]).toStrictEqual(expected); + }); + + it("omits teams and models that contain only separators", async () => { + const api = fakeApi(); + await createUserFromRow(row({ teams: " , ", models: "," }), api, invite); + + expect(api.createUser.mock.calls[0][0]).toStrictEqual({ + user_email: "new.hire@example.com", + user_role: "internal_user", + }); + }); + + it.each(["", "abc", "0", "-5"])("drops a max budget of %j instead of sending it", async (maxBudget) => { + const api = fakeApi(); + await createUserFromRow(row({ max_budget: maxBudget }), api, invite); + + expect(api.createUser.mock.calls[0][0]).not.toHaveProperty("max_budget"); + }); + + it("drops an empty budget duration", async () => { + const api = fakeApi(); + await createUserFromRow(row({ budget_duration: "" }), api, invite); + + expect(api.createUser.mock.calls[0][0]).not.toHaveProperty("budget_duration"); + }); + }); + + describe("invitation link", () => { + it("creates an invitation for the new user and links to onboarding", async () => { + const api = fakeApi({ key: "sk-new", user_id: "user-new" }); + + const outcome = await createUserFromRow(row(), api, invite); + + expect(api.createInvitation).toHaveBeenCalledExactlyOnceWith("user-new"); + expect(outcome).toStrictEqual({ + status: "success", + key: "sk-new", + invitation_link: `${BASE_URL}/ui/onboarding?invitation_id=inv-123`, + }); + }); + + it("prefers the nested data.user_id when inviting", async () => { + const api = fakeApi({ key: "sk-new", user_id: "top-level", data: { user_id: "nested" } }); + + await createUserFromRow(row(), api, invite); + + expect(api.createInvitation).toHaveBeenCalledExactlyOnceWith("nested"); + }); + + it("links SSO deployments to the UI root without creating an invitation", async () => { + const api = fakeApi(); + + const outcome = await createUserFromRow(row(), api, { ssoEnabled: true, baseUrl: BASE_URL }); + + expect(api.createInvitation).not.toHaveBeenCalled(); + expect(outcome).toStrictEqual({ status: "success", key: "sk-new", invitation_link: `${BASE_URL}/ui` }); + }); + + it("still reports success with the key when the invitation fails", async () => { + const api = fakeApi(); + api.createInvitation.mockRejectedValue(new Error("invite down")); + + const outcome = await createUserFromRow(row(), api, invite); + + expect(outcome).toStrictEqual({ + status: "success", + key: "sk-new", + error: "User created but failed to generate invitation link", + }); + }); + }); + + describe("result", () => { + it("falls back to the user id as the key when no key is returned", async () => { + const outcome = await createUserFromRow(row(), fakeApi({ user_id: "user-only" }), invite); + + expect(outcome).toMatchObject({ status: "success", key: "user-only" }); + }); + + it.each([ + [{ error: "Email already exists" }, "Email already exists"], + [{}, "Failed to create user"], + [null, "Failed to create user"], + ])("fails when the response %j has neither key nor user id", async (created, message) => { + const api = fakeApi(created); + + const outcome = await createUserFromRow(row(), api, invite); + + expect(outcome).toStrictEqual({ status: "failed", error: message }); + expect(api.createInvitation).not.toHaveBeenCalled(); + }); + + it("fails with the thrown error message when the request throws", async () => { + const api = fakeApi(); + api.createUser.mockRejectedValue(new Error('{"error":"duplicate"}')); + + const outcome = await createUserFromRow(row(), api, invite); + + expect(outcome).toStrictEqual({ status: "failed", error: '{"error":"duplicate"}' }); + }); + + it("stringifies a thrown value that is not an Error", async () => { + const api = fakeApi(); + api.createUser.mockRejectedValue("network gone"); + + const outcome = await createUserFromRow(row(), api, invite); + + expect(outcome).toStrictEqual({ status: "failed", error: "network gone" }); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/createUser.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/createUser.ts new file mode 100644 index 00000000000..951ebc3c7d6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/createUser.ts @@ -0,0 +1,99 @@ +import { getUiPageUrl } from "@/components/networking"; + +import type { CsvRow } from "./parseCsv"; + +export interface CreateUserRequest { + user_email: string; + user_role: string; + teams?: string[]; + models?: string[]; + max_budget?: number; + budget_duration?: string; +} + +export interface CreatedUser { + key?: string | null; + user_id?: string | null; + data?: { user_id?: string | null } | null; + error?: string; +} + +export interface BulkInviteApi { + createUser: (request: CreateUserRequest) => Promise; + createInvitation: (userId: string) => Promise<{ id: string }>; +} + +export interface InviteSettings { + ssoEnabled: boolean; + baseUrl: string; +} + +export type CreateUserOutcome = + | { status: "success"; key: string; invitation_link: string } + | { status: "success"; key: string; error: string } + | { status: "failed"; error: string }; + +const splitList = (value: string | undefined): string[] => + (value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + +const buildRequest = (row: CsvRow): CreateUserRequest => { + const teams = splitList(row.teams); + const models = splitList(row.models); + const maxBudget = parseFloat(row.max_budget ?? ""); + const budgetDuration = row.budget_duration?.trim(); + return { + user_email: row.user_email, + user_role: row.user_role, + ...(teams.length > 0 && { teams }), + ...(models.length > 0 && { models }), + ...(maxBudget > 0 && { max_budget: maxBudget }), + ...(budgetDuration && { budget_duration: budgetDuration }), + }; +}; + +const invitationLink = async ( + userId: string | null | undefined, + api: BulkInviteApi, + settings: InviteSettings, +): Promise => { + if (settings.ssoEnabled) return new URL("/ui", settings.baseUrl).toString(); + if (!userId) throw new Error("Created user has no user_id"); + const invitation = await api.createInvitation(userId); + return getUiPageUrl(new URL(`/ui/onboarding?invitation_id=${invitation.id}`, settings.baseUrl).toString()); +}; + +const withInvitation = async ( + created: CreatedUser, + key: string, + api: BulkInviteApi, + settings: InviteSettings, +): Promise => { + try { + const link = await invitationLink(created.data?.user_id || created.user_id, api, settings); + return { status: "success", key, invitation_link: link }; + } catch (inviteError) { + console.error("Error creating invitation:", inviteError); + return { status: "success", key, error: "User created but failed to generate invitation link" }; + } +}; + +const failureMessage = (error: unknown): string => (error instanceof Error && error.message) || String(error); + +export const createUserFromRow = async ( + row: CsvRow, + api: BulkInviteApi, + settings: InviteSettings, +): Promise => { + try { + const created = await api.createUser(buildRequest(row)); + const key = created?.key || created?.user_id; + if (!created || !key) return { status: "failed", error: created?.error || "Failed to create user" }; + return await withInvitation(created, key, api, settings); + } catch (error) { + console.error("Caught error:", error); + return { status: "failed", error: failureMessage(error) }; + } +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/parseCsv.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/parseCsv.test.ts new file mode 100644 index 00000000000..bf259976893 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/parseCsv.test.ts @@ -0,0 +1,211 @@ +import Papa from "papaparse"; +import { describe, expect, it } from "vitest"; + +import { TEMPLATE_CSV } from "./columns"; +import { checkCsvRows, parseCsvFile, type CsvParseResult, type CsvRow } from "./parseCsv"; + +const HEADER = "user_email,user_role,teams,max_budget,budget_duration,models"; +const KNOWN_TEAMS = ["team-a", "team-b"]; + +const parse = (text: string, knownTeamIds: readonly string[] = KNOWN_TEAMS): CsvParseResult => + checkCsvRows(Papa.parse(text).data, knownTeamIds); + +const rowsOf = (result: CsvParseResult): CsvRow[] => { + if (result.kind !== "rows") throw new Error(`expected rows, got ${result.kind}: ${JSON.stringify(result)}`); + return result.rows; +}; + +const firstRowError = (line: string, knownTeamIds?: readonly string[]): string => + rowsOf(parse(`${HEADER}\n${line}`, knownTeamIds))[0].error; + +describe("checkCsvRows", () => { + it("accepts the downloadable template as one valid user", () => { + const result = parse(TEMPLATE_CSV, ["team-id-1", "team-id-2"]); + + expect(result).toMatchObject({ kind: "rows", rowError: null }); + expect(rowsOf(result)).toStrictEqual([ + { + rowNumber: 2, + user_email: "user@example.com", + user_role: "internal_user", + teams: "team-id-1,team-id-2", + max_budget: "100", + budget_duration: "30d", + models: "gpt-3.5-turbo,gpt-4", + isValid: true, + error: "", + }, + ]); + }); + + it("trims cells and reads columns by header name in any order", () => { + const [row] = rowsOf(parse("models,user_role,user_email\n gpt-4o , internal_user_viewer , a@b.co ")); + + expect(row).toMatchObject({ user_email: "a@b.co", user_role: "internal_user_viewer", models: "gpt-4o" }); + expect(row.isValid).toBe(true); + }); + + it("skips blank lines while keeping row numbers aligned with the file", () => { + const rows = rowsOf(parse("user_email,user_role\na@b.co,internal_user\n\nc@d.co,proxy_admin\n")); + + expect(rows.map((row) => [row.rowNumber, row.user_email])).toEqual([ + [2, "a@b.co"], + [4, "c@d.co"], + ]); + }); + + describe("file structure", () => { + it("rejects an empty file", () => { + expect(checkCsvRows([], KNOWN_TEAMS)).toStrictEqual({ + kind: "structureError", + message: "The CSV file appears to be empty. Please upload a file with data.", + }); + }); + + it("rejects a file with only a header row", () => { + expect(parse("user_email,user_role")).toStrictEqual({ + kind: "structureError", + message: "The CSV file only contains headers but no user data. Please add user data to your CSV.", + }); + }); + + it("rejects a file whose first row is blank", () => { + expect(parse("\na@b.co,internal_user")).toStrictEqual({ + kind: "structureError", + message: "The CSV file doesn't contain any column headers. Please make sure your CSV has headers.", + }); + }); + + it("names every missing required column", () => { + expect(parse("teams,models\nteam-a,gpt-4o")).toStrictEqual({ + kind: "structureError", + message: + "Your CSV is missing these required columns: user_email, user_role. Please add these columns to your CSV file.", + }); + }); + + it("names only the column that is missing", () => { + expect(parse("user_email\na@b.co")).toMatchObject({ + message: expect.stringContaining("missing these required columns: user_role."), + }); + }); + + it("rejects a file whose data rows are all blank", () => { + expect(parse("user_email,user_role\n\n")).toStrictEqual({ + kind: "structureError", + message: "No valid data rows found in the CSV file. Please check your file format.", + }); + }); + }); + + it("flags a row with fewer cells than the header", () => { + const [row] = rowsOf(parse(`${HEADER}\na@b.co,internal_user,,,`)); + + const expected = { + rowNumber: 2, + isValid: false, + error: "Row 2 has fewer columns than the header row. Please ensure all data is properly formatted.", + user_email: "", + user_role: "", + }; + expect(row).toStrictEqual(expected); + }); + + describe("row rules", () => { + it.each([ + ["missing email", ",internal_user,,,,", "Email is required"], + ["email without @", "a.example.com,internal_user,,,,", "Invalid email format (must contain @ and domain)"], + ["email without a dot", "a@localhost,internal_user,,,,", "Invalid email format (must contain @ and domain)"], + ["missing role", "a@b.co,,,,,", "Role is required"], + [ + "unknown role", + "a@b.co,admin,,,,", + 'Invalid role "admin". Must be one of: proxy_admin, proxy_admin_viewer, internal_user, internal_user_viewer', + ], + ["non-numeric budget", "a@b.co,internal_user,,lots,,", 'Max budget "lots" must be a number'], + ["zero budget", "a@b.co,internal_user,,0,,", "Max budget must be greater than 0"], + ["negative budget", "a@b.co,internal_user,,-3,,", "Max budget must be greater than 0"], + [ + "bad duration", + "a@b.co,internal_user,,,30days,", + 'Invalid budget duration format "30days". Use format like "30d", "1mo", "2w", "6h"', + ], + ["unknown team", 'a@b.co,internal_user,"team-a, team-z, team-y",,,', "Unknown team(s): team-z, team-y"], + ])("rejects a %s", (_case, line, message) => { + expect(firstRowError(line)).toBe(message); + }); + + it.each(["proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer"])( + "accepts the %s role", + (role) => { + expect(firstRowError(`a@b.co,${role},,,,`)).toBe(""); + }, + ); + + it.each(["30d", "12h", "15m", "2w", "1y", "1mo", "24mo"])("accepts a %s budget duration", (duration) => { + expect(firstRowError(`a@b.co,internal_user,,,${duration},`)).toBe(""); + }); + + it("accepts a decimal budget and known teams", () => { + expect(firstRowError('a@b.co,internal_user,"team-a,team-b",0.5,,')).toBe(""); + }); + + it("does not check team ids when no teams are known", () => { + expect(firstRowError("a@b.co,internal_user,team-z,,,", [])).toBe(""); + }); + + it("lists every failed rule for a row, in column order", () => { + expect(firstRowError("nope,boss,team-z,free,forever,")).toBe( + [ + "Invalid email format (must contain @ and domain)", + 'Invalid role "boss". Must be one of: proxy_admin, proxy_admin_viewer, internal_user, internal_user_viewer', + 'Max budget "free" must be a number', + 'Invalid budget duration format "forever". Use format like "30d", "1mo", "2w", "6h"', + "Unknown team(s): team-z", + ].join(", "), + ); + }); + }); + + describe("row error summary", () => { + it("is empty when every row is valid", () => { + expect(parse("user_email,user_role\na@b.co,internal_user\nc@d.co,proxy_admin")).toMatchObject({ + rowError: null, + }); + }); + + it("counts the invalid rows when some rows are valid", () => { + expect(parse("user_email,user_role\na@b.co,internal_user\nbad,internal_user\nworse,nope")).toMatchObject({ + rowError: "Found 2 row(s) with errors out of 3 total rows. Please correct them before proceeding.", + }); + }); + + it("says no users are valid when every row fails", () => { + expect(parse("user_email,user_role\nbad,internal_user")).toMatchObject({ + rowError: "No valid users found in the CSV. Please check the errors below and fix your CSV file.", + }); + }); + }); +}); + +describe("parseCsvFile", () => { + it("rejects a file that is neither text/csv nor named .csv", async () => { + const file = new File(["user_email,user_role"], "users.xlsx", { type: "application/vnd.ms-excel" }); + + expect(await parseCsvFile(file, KNOWN_TEAMS)).toStrictEqual({ + kind: "fileError", + message: "Invalid file type: users.xlsx. Please upload a CSV file (.csv extension).", + toast: "Invalid file type. Please upload a CSV file.", + }); + }); + + it("rejects a file larger than 5MB", async () => { + const file = new File([new Uint8Array(6 * 1024 * 1024)], "users.csv", { type: "text/csv" }); + + expect(await parseCsvFile(file, KNOWN_TEAMS)).toStrictEqual({ + kind: "fileError", + message: "File is too large (6.0 MB). Please upload a CSV file smaller than 5MB.", + toast: null, + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/parseCsv.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/parseCsv.ts new file mode 100644 index 00000000000..f8bd8332339 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/parseCsv.ts @@ -0,0 +1,130 @@ +import Papa from "papaparse"; + +import { REQUIRED_COLUMNS, type ColumnName } from "./columns"; +import { csvRowSchema } from "./schema"; + +const MAX_FILE_BYTES = 5 * 1024 * 1024; + +export interface CsvRow { + rowNumber: number; + user_email: string; + user_role: string; + teams?: string; + max_budget?: string; + budget_duration?: string; + models?: string; + isValid: boolean; + error: string; +} + +export type CsvParseResult = + | { kind: "fileError"; message: string; toast: string | null } + | { kind: "structureError"; message: string } + | { kind: "parseError"; message: string } + | { kind: "rows"; rows: CsvRow[]; rowError: string | null }; + +type RowSchema = ReturnType; + +const isEmptyRow = (cells: string[]): boolean => cells.length === 0 || (cells.length === 1 && cells[0] === ""); + +const checkRow = (headers: string[], cells: string[], rowNumber: number, schema: RowSchema): CsvRow => { + if (cells.length < headers.length) { + return { + rowNumber, + isValid: false, + error: `Row ${rowNumber} has fewer columns than the header row. Please ensure all data is properly formatted.`, + user_email: "", + user_role: "", + }; + } + + const cell = (name: ColumnName): string | undefined => cells[headers.indexOf(name)]?.trim(); + const values = { + user_email: cell("user_email") || "", + user_role: cell("user_role") || "", + teams: cell("teams"), + max_budget: cell("max_budget"), + budget_duration: cell("budget_duration"), + models: cell("models"), + }; + const result = schema.safeParse(values); + const error = result.success ? "" : result.error.issues.map((issue) => issue.message).join(", "); + return { ...values, rowNumber, isValid: result.success, error }; +}; + +const rowErrorSummary = (rows: CsvRow[]): string | null => { + const invalidCount = rows.filter((row) => !row.isValid).length; + if (invalidCount === 0) return null; + if (invalidCount === rows.length) { + return "No valid users found in the CSV. Please check the errors below and fix your CSV file."; + } + return `Found ${invalidCount} row(s) with errors out of ${rows.length} total rows. Please correct them before proceeding.`; +}; + +const checkHeaders = (headers: string[]): string | null => { + if (headers.length === 0 || (headers.length === 1 && headers[0] === "")) { + return "The CSV file doesn't contain any column headers. Please make sure your CSV has headers."; + } + const missingColumns = REQUIRED_COLUMNS.filter((column) => !headers.includes(column)); + if (missingColumns.length > 0) { + return `Your CSV is missing these required columns: ${missingColumns.join(", ")}. Please add these columns to your CSV file.`; + } + return null; +}; + +export const checkCsvRows = (data: string[][], knownTeamIds: readonly string[]): CsvParseResult => { + if (data.length === 0) { + return { kind: "structureError", message: "The CSV file appears to be empty. Please upload a file with data." }; + } + if (data.length === 1) { + return { + kind: "structureError", + message: "The CSV file only contains headers but no user data. Please add user data to your CSV.", + }; + } + + const [headers, ...body] = data; + const headerError = checkHeaders(headers); + if (headerError) return { kind: "structureError", message: headerError }; + + const schema = csvRowSchema(knownTeamIds); + const rows = body.flatMap((cells, index) => (isEmptyRow(cells) ? [] : [checkRow(headers, cells, index + 2, schema)])); + if (rows.length === 0) { + return { + kind: "structureError", + message: "No valid data rows found in the CSV file. Please check your file format.", + }; + } + return { kind: "rows", rows, rowError: rowErrorSummary(rows) }; +}; + +const checkFile = (file: File): CsvParseResult | null => { + if (file.type !== "text/csv" && !file.name.endsWith(".csv")) { + return { + kind: "fileError", + message: `Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`, + toast: "Invalid file type. Please upload a CSV file.", + }; + } + if (file.size > MAX_FILE_BYTES) { + return { + kind: "fileError", + message: `File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`, + toast: null, + }; + } + return null; +}; + +export const parseCsvFile = (file: File, knownTeamIds: readonly string[]): Promise => { + const fileProblem = checkFile(file); + if (fileProblem) return Promise.resolve(fileProblem); + + return new Promise((resolve) => { + Papa.parse(file, { + complete: (results) => resolve(checkCsvRows(results.data, knownTeamIds)), + error: (error) => resolve({ kind: "parseError", message: `Failed to parse CSV file: ${error.message}` }), + header: false, + }); + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/schema.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/schema.ts new file mode 100644 index 00000000000..7ff349c5c2f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/schema.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +import { USER_ROLES } from "../userRoles"; + +const BUDGET_DURATION = /^\d+[dhmwy]$|^\d+mo$/; + +const userEmail = z + .string() + .min(1, { error: "Email is required", abort: true }) + .refine((email) => email.includes("@") && email.includes("."), "Invalid email format (must contain @ and domain)"); + +const userRole = z + .string() + .min(1, "Role is required") + .pipe( + z.enum(USER_ROLES, { + error: (issue) => `Invalid role "${String(issue.input)}". Must be one of: ${USER_ROLES.join(", ")}`, + }), + ); + +const maxBudget = z + .string() + .refine((budget) => budget === "" || !Number.isNaN(parseFloat(budget)), { + error: (issue) => `Max budget "${String(issue.input)}" must be a number`, + abort: true, + }) + .refine((budget) => budget === "" || parseFloat(budget) > 0, "Max budget must be greater than 0"); + +const budgetDuration = z.string().refine((duration) => duration === "" || BUDGET_DURATION.test(duration), { + error: (issue) => `Invalid budget duration format "${String(issue.input)}". Use format like "30d", "1mo", "2w", "6h"`, +}); + +const unknownTeams = (teams: string, knownTeamIds: readonly string[]): string[] => + teams + .split(",") + .map((team) => team.trim()) + .filter((team) => !knownTeamIds.includes(team)); + +const teamList = (knownTeamIds: readonly string[]) => + z.string().superRefine((teams, ctx) => { + if (teams === "" || knownTeamIds.length === 0) return; + const unknown = unknownTeams(teams, knownTeamIds); + if (unknown.length > 0) ctx.addIssue({ code: "custom", message: `Unknown team(s): ${unknown.join(", ")}` }); + }); + +export const csvRowSchema = (knownTeamIds: readonly string[]) => { + const rowShape = { + user_email: userEmail, + user_role: userRole, + max_budget: maxBudget.optional(), + budget_duration: budgetDuration.optional(), + teams: teamList(knownTeamIds).optional(), + }; + return z.object(rowShape); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/useBulkInvite.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/useBulkInvite.ts new file mode 100644 index 00000000000..da01edf5964 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/bulk-invite/useBulkInvite.ts @@ -0,0 +1,118 @@ +import Papa from "papaparse"; +import { useState } from "react"; + +import { useProxySettingsQuery } from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; +import { downloadBlob } from "@/components/EntityUsageExport/utils"; +import { invitationCreateCall, userCreateCall } from "@/components/networking"; +import { toast } from "@/lib/toast"; + +import { createUserFromRow, type BulkInviteApi, type CreateUserOutcome } from "./createUser"; +import { parseCsvFile, type CsvParseResult, type CsvRow } from "./parseCsv"; + +export type BulkInviteRow = CsvRow & { + status?: "pending" | CreateUserOutcome["status"]; + key?: string; + invitation_link?: string; +}; + +type UploadProblem = { kind: "fileError" | "structureError" | "parseError"; message: string }; + +interface UseBulkInviteOptions { + accessToken: string; + teams: readonly { team_id: string }[] | null; + onUsersCreated?: () => void; +} + +const toResultRow = (row: BulkInviteRow) => ({ + user_email: row.user_email, + user_role: row.user_role, + status: row.status, + key: row.key || "", + invitation_link: row.invitation_link || "", + error: row.error || "", +}); + +export const useBulkInvite = ({ accessToken, teams, onUsersCreated }: UseBulkInviteOptions) => { + const ssoEnabled = Boolean(useProxySettingsQuery(accessToken).data?.SSO_ENABLED); + const [rows, setRows] = useState([]); + const [selectedFile, setSelectedFile] = useState(null); + const [problem, setProblem] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [pageIndex, setPageIndex] = useState(0); + + const applyParseResult = (result: CsvParseResult) => { + switch (result.kind) { + case "fileError": + setProblem({ kind: result.kind, message: result.message }); + if (result.toast) toast.fromError(result.toast); + return; + case "structureError": + case "parseError": + setRows([]); + setProblem({ kind: result.kind, message: result.message }); + return; + case "rows": + setRows(result.rows); + if (result.rowError) setProblem({ kind: "parseError", message: result.rowError }); + else toast.success(`Successfully parsed ${result.rows.length} users`); + } + }; + + const uploadFile = async (file: File) => { + setProblem(null); + setSelectedFile(file); + applyParseResult(await parseCsvFile(file, teams?.map((team) => team.team_id) ?? [])); + }; + + const removeFile = () => { + setSelectedFile(null); + setRows([]); + setProblem(null); + }; + + const backToUpload = () => { + setRows([]); + setProblem((current) => (current?.kind === "parseError" ? null : current)); + setPageIndex(0); + }; + + const createUsers = async () => { + setIsProcessing(true); + const pending = rows.map((row): BulkInviteRow => ({ ...row, status: "pending" })); + setRows(pending); + + const api: BulkInviteApi = { + createUser: (request) => userCreateCall(accessToken, null, request), + createInvitation: (userId) => invitationCreateCall(accessToken, userId), + }; + const settings = { ssoEnabled, baseUrl: window.location.origin }; + let anySuccessful = false; + for (const [index, row] of pending.entries()) { + const outcome = await createUserFromRow(row, api, settings); + anySuccessful ||= outcome.status === "success"; + setRows((current) => current.map((existing, i) => (i === index ? { ...existing, ...outcome } : existing))); + } + + setIsProcessing(false); + if (anySuccessful) onUsersCreated?.(); + }; + + const downloadResults = () => + downloadBlob(new Blob([Papa.unparse(rows.map(toResultRow))], { type: "text/csv" }), "bulk_users_results.csv"); + + return { + rows, + selectedFile, + fileError: problem?.kind === "fileError" ? problem.message : null, + csvStructureError: problem?.kind === "structureError" ? problem.message : null, + parseError: problem?.kind === "parseError" ? problem.message : null, + isProcessing, + pageIndex, + setPageIndex, + uploadFile, + removeFile, + backToUpload, + createUsers, + downloadResults, + }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.ts index 7aa3b946239..7e031c37739 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { components } from "@/lib/http/schema"; +import { USER_ROLES } from "../userRoles"; import { EMPTY_TEAM_ROW, type DefaultTeamRowValues, @@ -54,10 +55,8 @@ export const settingsToForm = (values: InternalUserSettings["values"]): DefaultU }; }; -const DEFAULT_USER_ROLES = ["internal_user", "internal_user_viewer", "proxy_admin", "proxy_admin_viewer"] as const; - const asDefaultUserRole = (raw: string): DefaultInternalUserParams["user_role"] => - DEFAULT_USER_ROLES.find((role) => role === raw) ?? null; + USER_ROLES.find((role) => role === raw) ?? null; const numberOrNull = (raw: string): number | null => (raw.trim() === "" ? null : Number(raw)); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRoles.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRoles.ts new file mode 100644 index 00000000000..ba2ccb1a9a4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRoles.ts @@ -0,0 +1 @@ +export const USER_ROLES = ["proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer"] as const; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index 1dbab3b1803..2494c824cbf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -2,7 +2,7 @@ import { parseAsString, useQueryState } from "nuqs"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import BulkEditUserModal from "./BulkEditUsers"; -import BulkCreateUsersButton from "@/components/bulk_create_users_button"; +import { BulkInviteButton } from "./bulk-invite/BulkInviteButton"; import { CreateUserButton } from "@/components/CreateUserButton"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -334,7 +334,7 @@ const ViewUserDashboard: React.FC = ({ )} {isProxyAdmin && ( - + )} {isProxyAdmin && ( diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx deleted file mode 100644 index 51d7521c909..00000000000 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; -import BulkCreateUsersButton from "./bulk_create_users_button"; - -vi.mock("./networking", () => ({ - userCreateCall: vi.fn(), - invitationCreateCall: vi.fn(), - getProxyUISettings: vi.fn().mockResolvedValue({ - PROXY_BASE_URL: null, - PROXY_LOGOUT_URL: null, - DEFAULT_TEAM_DISABLED: false, - SSO_ENABLED: false, - }), -})); - -const csvFile = () => - new File(["user_email,user_role\nnew.hire@example.com,internal_user\n"], "users.csv", { type: "text/csv" }); - -const openUploadStep = async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText("+ Bulk Invite Users")); - return user; -}; - -describe("BulkCreateUsersButton", () => { - it("should render", () => { - render(); - expect(screen.getByText("+ Bulk Invite Users")).toBeInTheDocument(); - }); - - it("downloads a CSV template with the columns the upload accepts", async () => { - const createObjectURL = vi.fn((_blob: Blob) => "blob:template"); - const revokeObjectURL = vi.fn(); - const { createObjectURL: originalCreate, revokeObjectURL: originalRevoke } = URL; - URL.createObjectURL = createObjectURL; - URL.revokeObjectURL = revokeObjectURL; - const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); - - const user = await openUploadStep(); - await user.click(screen.getByRole("button", { name: /download csv template/i })); - - expect(anchorClick).toHaveBeenCalledOnce(); - const anchor = anchorClick.mock.contexts[0] as HTMLAnchorElement; - expect(anchor.download).toBe("bulk_users_template.csv"); - expect(anchor.href).toBe("blob:template"); - - const csv = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.readAsText(createObjectURL.mock.calls[0][0]); - }); - const [headerRow] = csv.split(/\r?\n/); - expect(headerRow).toBe("user_email,user_role,teams,max_budget,budget_duration,models"); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:template"); - - anchorClick.mockRestore(); - URL.createObjectURL = originalCreate; - URL.revokeObjectURL = originalRevoke; - }); - - it("parses a CSV chosen through the file input", async () => { - await openUploadStep(); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(fileInput, { target: { files: [csvFile()] } }); - - expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument(); - }); - - it("parses a CSV dropped onto the drop zone", async () => { - await openUploadStep(); - - const dropZone = screen.getByLabelText(/drag and drop your csv file here/i).closest("label"); - fireEvent.drop(dropZone as HTMLLabelElement, { dataTransfer: { files: [csvFile()], types: ["Files"] } }); - - expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument(); - }); - - it("exposes the drop zone as a label for a keyboard-reachable file input", async () => { - await openUploadStep(); - - const fileInput = screen.getByLabelText(/drag and drop your csv file here/i) as HTMLInputElement; - expect(fileInput).toHaveAttribute("type", "file"); - expect(fileInput).toHaveAttribute("accept", ".csv"); - expect(fileInput).toBeVisible(); - - const dropZone = fileInput.closest("label") as HTMLLabelElement; - expect(fileInput.id).not.toBe(""); - expect(dropZone.htmlFor).toBe(fileInput.id); - - const danglingLabels = [...document.querySelectorAll("label[for]")].filter( - (label) => document.getElementById(label.getAttribute("for") as string) === null, - ); - expect(danglingLabels).toEqual([]); - }); -}); diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx deleted file mode 100644 index 840cc1af00e..00000000000 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx +++ /dev/null @@ -1,899 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { - NUMERIC_CELL_CLASS, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Download, FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react"; -import { userCreateCall, invitationCreateCall, getProxyUISettings, getUiPageUrl } from "./networking"; -import Papa from "papaparse"; -import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline"; -import { CopyToClipboard } from "react-copy-to-clipboard"; -import { toast } from "@/lib/toast"; -import { downloadBlob } from "@/components/EntityUsageExport/utils"; - -interface BulkCreateUsersProps { - accessToken: string; - teams: any[] | null; - possibleUIRoles: null | Record>; - onUsersCreated?: () => void; -} - -interface UserData { - user_email: string; - user_role: string; - teams?: string | string[]; - metadata?: string; - max_budget?: string | number; - budget_duration?: string; - models?: string | string[]; - status?: string; - error?: string; - rowNumber?: number; - isValid?: boolean; - key?: string; - invitation_link?: string; -} - -const PREVIEW_PAGE_SIZE = 5; - -// Define an interface for the UI settings -interface UISettings { - PROXY_BASE_URL: string | null; - PROXY_LOGOUT_URL: string | null; - DEFAULT_TEAM_DISABLED: boolean; - SSO_ENABLED: boolean; -} - -const BulkCreateUsersButton: React.FC = ({ - accessToken, - teams, - possibleUIRoles, - onUsersCreated, -}) => { - const [isModalVisible, setIsModalVisible] = useState(false); - const [parsedData, setParsedData] = useState([]); - const [isProcessing, setIsProcessing] = useState(false); - const [parseError, setParseError] = useState(null); - const [csvStructureError, setCsvStructureError] = useState(null); - const [fileError, setFileError] = useState(null); - const [selectedFile, setSelectedFile] = useState(null); - const [uiSettings, setUISettings] = useState(null); - const [baseUrl, setBaseUrl] = useState("http://localhost:4000"); - const [isDraggingOver, setIsDraggingOver] = useState(false); - const [pageIndex, setPageIndex] = useState(0); - const csvInputId = React.useId(); - - useEffect(() => { - // Get UI settings - const fetchUISettings = async () => { - try { - const uiSettingsResponse = await getProxyUISettings(accessToken); - setUISettings(uiSettingsResponse); - } catch (error) { - console.error("Error fetching UI settings:", error); - } - }; - - fetchUISettings(); - - // Set base URL - const base = new URL("/", window.location.href); - setBaseUrl(base.toString()); - }, [accessToken]); - - const downloadTemplate = () => { - const template = [ - ["user_email", "user_role", "teams", "max_budget", "budget_duration", "models"], - ["user@example.com", "internal_user", "team-id-1,team-id-2", "100", "30d", "gpt-3.5-turbo,gpt-4"], - ]; - - downloadBlob(new Blob([Papa.unparse(template)], { type: "text/csv" }), "bulk_users_template.csv"); - }; - - const handleFileUpload = (file: File) => { - // Reset all error states - setParseError(null); - setCsvStructureError(null); - setFileError(null); - - // Set the selected file - always show the file even if it's invalid - setSelectedFile(file); - - // Check file type - if (file.type !== "text/csv" && !file.name.endsWith(".csv")) { - setFileError(`Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`); - toast.fromError("Invalid file type. Please upload a CSV file."); - return; - } - - // Check file size (limit to 5MB) - if (file.size > 5 * 1024 * 1024) { - setFileError( - `File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`, - ); - return; - } - - Papa.parse(file, { - complete: (results) => { - // Check if file is empty - if (!results.data || results.data.length === 0) { - setCsvStructureError("The CSV file appears to be empty. Please upload a file with data."); - setParsedData([]); - return; - } - - // Check if there's only header row - if (results.data.length === 1) { - setCsvStructureError( - "The CSV file only contains headers but no user data. Please add user data to your CSV.", - ); - setParsedData([]); - return; - } - - const headers = results.data[0] as string[]; - - // Check if headers exist - if (headers.length === 0 || (headers.length === 1 && headers[0] === "")) { - setCsvStructureError( - "The CSV file doesn't contain any column headers. Please make sure your CSV has headers.", - ); - setParsedData([]); - return; - } - - const requiredColumns = ["user_email", "user_role"]; - - // Check if all required columns are present - const missingColumns = requiredColumns.filter((col) => !headers.includes(col)); - if (missingColumns.length > 0) { - setCsvStructureError( - `Your CSV is missing these required columns: ${missingColumns.join(", ")}. Please add these columns to your CSV file.`, - ); - setParsedData([]); - return; - } - - try { - const userData = results.data - .slice(1) - .map((row: any, index: number) => { - // Skip empty rows - if (row.length === 0 || (row.length === 1 && row[0] === "")) { - return null; - } - - // Check if row has enough columns - if (row.length < headers.length) { - return { - rowNumber: index + 2, - isValid: false, - error: `Row ${index + 2} has fewer columns than the header row. Please ensure all data is properly formatted.`, - user_email: "", - user_role: "", - } as UserData; - } - - const user: UserData = { - user_email: row[headers.indexOf("user_email")]?.trim() || "", - user_role: row[headers.indexOf("user_role")]?.trim() || "", - teams: row[headers.indexOf("teams")]?.trim(), - max_budget: row[headers.indexOf("max_budget")]?.trim(), - budget_duration: row[headers.indexOf("budget_duration")]?.trim(), - models: row[headers.indexOf("models")]?.trim(), - rowNumber: index + 2, - isValid: true, - error: "", - }; - - // Validate the row - const errors: string[] = []; - - // Email validation - if (!user.user_email) { - errors.push("Email is required"); - } else if (!user.user_email.includes("@") || !user.user_email.includes(".")) { - errors.push("Invalid email format (must contain @ and domain)"); - } - - // Role validation - if (!user.user_role) { - errors.push("Role is required"); - } else { - // Validate user role - const validRoles = ["proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer"]; - if (!validRoles.includes(user.user_role)) { - errors.push(`Invalid role "${user.user_role}". Must be one of: ${validRoles.join(", ")}`); - } - } - - // Budget validation - if (user.max_budget && user.max_budget.toString().trim() !== "") { - if (isNaN(parseFloat(user.max_budget.toString()))) { - errors.push(`Max budget "${user.max_budget}" must be a number`); - } else if (parseFloat(user.max_budget.toString()) <= 0) { - errors.push("Max budget must be greater than 0"); - } - } - - // Budget duration validation - if (user.budget_duration && !user.budget_duration.match(/^\d+[dhmwy]$|^\d+mo$/)) { - errors.push( - `Invalid budget duration format "${user.budget_duration}". Use format like "30d", "1mo", "2w", "6h"`, - ); - } - - // Teams validation - if (user.teams && typeof user.teams === "string") { - // Check if teams exist (if teams data is available) - if (teams && teams.length > 0) { - const teamIds = teams.map((t) => t.team_id); - const userTeams = user.teams.split(",").map((t) => t.trim()); - const invalidTeams = userTeams.filter((t) => !teamIds.includes(t)); - if (invalidTeams.length > 0) { - errors.push(`Unknown team(s): ${invalidTeams.join(", ")}`); - } - } - } - - if (errors.length > 0) { - user.isValid = false; - user.error = errors.join(", "); - } - - return user; - }) - .filter(Boolean) as UserData[]; // Filter out null values (empty rows) - - const validData = userData.filter((user) => user.isValid); - setParsedData(userData); - - if (userData.length === 0) { - setCsvStructureError("No valid data rows found in the CSV file. Please check your file format."); - } else if (validData.length === 0) { - setParseError("No valid users found in the CSV. Please check the errors below and fix your CSV file."); - } else if (validData.length < userData.length) { - setParseError( - `Found ${userData.length - validData.length} row(s) with errors out of ${userData.length} total rows. Please correct them before proceeding.`, - ); - } else { - toast.success(`Successfully parsed ${validData.length} users`); - } - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - setParseError(`Error parsing CSV: ${errorMessage}`); - setParsedData([]); - } - }, - error: (error) => { - setParseError(`Failed to parse CSV file: ${error.message}`); - setParsedData([]); - }, - header: false, - }); - }; - - const handleFileInputChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - handleFileUpload(file); - } - }; - - const handleDragOver = (event: React.DragEvent) => { - event.preventDefault(); - setIsDraggingOver(true); - }; - - const handleDrop = (event: React.DragEvent) => { - event.preventDefault(); - setIsDraggingOver(false); - const file = event.dataTransfer.files?.[0]; - if (file) { - handleFileUpload(file); - } - }; - - const removeSelectedFile = () => { - setSelectedFile(null); - setParsedData([]); - setParseError(null); - setCsvStructureError(null); - setFileError(null); - }; - - const resetParsedData = () => { - setParsedData([]); - setParseError(null); - setPageIndex(0); - }; - - const handleBulkCreate = async () => { - setIsProcessing(true); - const updatedData = parsedData.map((user) => ({ ...user, status: "pending" })); - setParsedData(updatedData); - - let anySuccessful = false; - - for (let index = 0; index < updatedData.length; index++) { - const user = updatedData[index]; - try { - // Create a clean user object with only non-empty values - const cleanUser: Partial = { - user_email: user.user_email, - user_role: user.user_role, - }; - - // Only add optional fields if they have values - if (user.teams && typeof user.teams === "string" && user.teams.trim() !== "") { - cleanUser.teams = user.teams - .split(",") - .map((team) => team.trim()) - .filter(Boolean); - // Only include teams if there's at least one valid team - if (cleanUser.teams.length === 0) { - delete cleanUser.teams; - } - } - - // Only add models if provided and non-empty - if (user.models && typeof user.models === "string" && user.models.trim() !== "") { - cleanUser.models = user.models - .split(",") - .map((model) => model.trim()) - .filter(Boolean); - // Only include models if there's at least one valid model - if (cleanUser.models.length === 0) { - delete cleanUser.models; - } - } - - // Only add max_budget if it's a valid number - if (user.max_budget && user.max_budget.toString().trim() !== "") { - const budgetValue = parseFloat(user.max_budget.toString()); - if (!isNaN(budgetValue) && budgetValue > 0) { - cleanUser.max_budget = budgetValue; - } - } - - // Only add budget_duration if provided and non-empty - if (user.budget_duration && user.budget_duration.trim() !== "") { - cleanUser.budget_duration = user.budget_duration.trim(); - } - - // Only add metadata if provided and non-empty - if (user.metadata && typeof user.metadata === "string" && user.metadata.trim() !== "") { - cleanUser.metadata = user.metadata.trim(); - } - - const response = await userCreateCall(accessToken, null, cleanUser); - - // Check if response has key or user_id, indicating success - if (response && (response.key || response.user_id)) { - anySuccessful = true; - const user_id = response.data?.user_id || response.user_id; - - // Create invitation link for the user - try { - if (!uiSettings?.SSO_ENABLED) { - // Regular invitation flow - const invitationData = await invitationCreateCall(accessToken, user_id); - const invitationUrl = getUiPageUrl( - new URL(`/ui/onboarding?invitation_id=${invitationData.id}`, baseUrl).toString(), - ); - - setParsedData((current) => - current.map((u, i) => - i === index - ? { - ...u, - status: "success", - key: response.key || response.user_id, - invitation_link: invitationUrl, - } - : u, - ), - ); - } else { - // SSO flow - just use the base URL - const invitationUrl = new URL("/ui", baseUrl).toString(); - - setParsedData((current) => - current.map((u, i) => - i === index - ? { - ...u, - status: "success", - key: response.key || response.user_id, - invitation_link: invitationUrl, - } - : u, - ), - ); - } - } catch (inviteError) { - console.error("Error creating invitation:", inviteError); - setParsedData((current) => - current.map((u, i) => - i === index - ? { - ...u, - status: "success", - key: response.key || response.user_id, - error: "User created but failed to generate invitation link", - } - : u, - ), - ); - } - } else { - const errorMessage = response?.error || "Failed to create user"; - setParsedData((current) => - current.map((u, i) => (i === index ? { ...u, status: "failed", error: errorMessage } : u)), - ); - } - } catch (error) { - console.error("Caught error:", error); - const errorMessage = (error as any)?.response?.data?.error || (error as Error)?.message || String(error); - setParsedData((current) => - current.map((u, i) => (i === index ? { ...u, status: "failed", error: errorMessage } : u)), - ); - } - } - - setIsProcessing(false); - - // Call the callback if any users were successfully created - if (anySuccessful && onUsersCreated) { - onUsersCreated(); - } - }; - - const downloadResults = () => { - const results = parsedData.map((user) => ({ - user_email: user.user_email, - user_role: user.user_role, - status: user.status, - key: user.key || "", - invitation_link: user.invitation_link || "", - error: user.error || "", - })); - - const csv = Papa.unparse(results); - const blob = new Blob([csv], { type: "text/csv" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "bulk_users_results.csv"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - }; - - const renderStatusCell = (record: UserData) => { - if (!record.isValid) { - return ( -
-
- - Invalid -
- {record.error && {record.error}} -
- ); - } - if (!record.status || record.status === "pending") { - return Pending; - } - if (record.status === "success") { - return ( -
-
- - Success -
- {record.invitation_link && ( -
-
- {record.invitation_link} - toast.success("Invitation link copied!")}> - - -
-
- )} -
- ); - } - return ( -
-
- - Failed -
- {record.error && {JSON.stringify(record.error)}} -
- ); - }; - - const pageCount = Math.max(1, Math.ceil(parsedData.length / PREVIEW_PAGE_SIZE)); - const currentPage = Math.min(pageIndex, pageCount - 1); - const visibleRows = parsedData.slice(currentPage * PREVIEW_PAGE_SIZE, (currentPage + 1) * PREVIEW_PAGE_SIZE); - - return ( - <> - - - !open && setIsModalVisible(false)}> - - - Bulk Invite Users - -
- {/* Step indicator */} - {parsedData.length === 0 ? ( -
-
-
- 1 -
-

Download and fill the template

-
- -
-

Add multiple users at once by following these steps:

-
    -
  1. Download our CSV template
  2. -
  3. Add your users' information to the spreadsheet
  4. -
  5. Save the file and upload it here
  6. -
  7. After creation, download the results file containing the Virtual Keys for each user
  8. -
- -
-

Template Column Names

-
-
-
-
-

user_email

-

User's email address (required)

-
-
-
-
-
-

user_role

-

- User's role (one of: "proxy_admin", "proxy_admin_viewer", - "internal_user", "internal_user_viewer") -

-
-
-
-
-
-

teams

-

- Comma-separated team IDs (e.g., "team-1,team-2") -

-
-
-
-
-
-

max_budget

-

- Maximum budget as a number (e.g., "100") -

-
-
-
-
-
-

budget_duration

-

- Budget reset period (e.g., "30d", "1mo") -

-
-
-
-
-
-

models

-

- Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4") -

-
-
-
-
- - -
- -
-
- 2 -
-

Upload your completed CSV

-
- -
- {selectedFile ? ( -
-
-
- {fileError ? ( - - ) : ( - - )} -
- - {selectedFile.name} - - - {(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()} - -
-
- -
- - {fileError ? ( -
- - {fileError} -
- ) : ( - !csvStructureError && ( -
-
-
-
- Processing... -
- ) - )} -
- ) : ( - - )} - - {csvStructureError && ( -
-
- -
- CSV Structure Error -

{csvStructureError}

-

- Please download our template and ensure your CSV follows the required format. -

-
-
-
- )} -
-
- ) : ( -
-
-
- 3 -
-

- {parsedData.some((user) => user.status === "success" || user.status === "failed") - ? "User Creation Results" - : "Review and create users"} -

-
- - {parseError && ( -
-
- -
-

{parseError}

- {parsedData.some((user) => !user.isValid) && ( -
    -
  • Check the table below for specific errors in each row
  • -
  • - Common issues include invalid email formats, missing required fields, or incorrect role - values -
  • -
  • Fix these issues in your CSV file and upload again
  • -
- )} -
-
-
- )} - -
-
-
- {parsedData.some((user) => user.status === "success" || user.status === "failed") ? ( -
-

Creation Summary

-

- {parsedData.filter((d) => d.status === "success").length} Successful -

- {parsedData.some((d) => d.status === "failed") && ( -

- {parsedData.filter((d) => d.status === "failed").length} Failed -

- )} -
- ) : ( -
-

User Preview

-

- {parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid -

-
- )} -
- - {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} -
- - {parsedData.some((user) => user.status === "success") && ( -
-
-
- -
-
-

User creation complete

-

- Next step: Download the credentials file containing - Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests - through LiteLLM. -

-
-
-
- )} - -
- - - - Row - Email - Role - Teams - Budget - Status - - - - {visibleRows.map((record) => ( - - {record.rowNumber} - {record.user_email} - {record.user_role} - {record.teams} - {record.max_budget} - {renderStatusCell(record)} - - ))} - -
-
- - {pageCount > 1 && ( -
- - Page {currentPage + 1} of {pageCount} - - - -
- )} - - {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} - - {parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} -
-
- )} -
-
-
- - ); -}; - -export default BulkCreateUsersButton;