refactor(ui): split bulk invite users into users route modules

Move the 900-line bulk_create_users_button.tsx into users/_components/bulk-invite as a button, dialog, template step, dropzone, preview table, CSV parsing, row schema, user creation and a hook. Share the four assignable user roles with default user settings. Behavior is unchanged
This commit is contained in:
ryan-crabbe-berri 2026-10-24 14:15:02 -07:00
parent 560372456e
commit a2318fc1e2
20 changed files with 1604 additions and 1010 deletions

View file

@ -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

View file

@ -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<string, Record<string, string>>;
onUsersCreated?: () => void;
}
export function BulkInviteButton({ accessToken, teams, onUsersCreated }: BulkInviteButtonProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button className="mb-0" onClick={() => setIsOpen(true)}>
+ Bulk Invite Users
</Button>
<BulkInviteDialog
open={isOpen}
onClose={() => setIsOpen(false)}
accessToken={accessToken}
teams={teams}
onUsersCreated={onUsersCreated}
/>
</>
);
}

View file

@ -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<string>((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(
<BulkInviteButton
accessToken="test-token"
teams={[{ team_id: "team-a" }]}
possibleUIRoles={null}
onUsersCreated={onUsersCreated}
/>,
);
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<typeof vi.spyOn>;
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();
});
});

View file

@ -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<typeof useBulkInvite>;
function ParseErrorNotice({ parseError, rows }: { parseError: string; rows: BulkInviteRow[] }) {
return (
<div className="ml-11 mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex items-start">
<TriangleAlert className="size-4 shrink-0 text-destructive mr-2 mt-1" />
<div className="min-w-0">
<p className="text-destructive font-medium break-words">{parseError}</p>
{rows.some((row) => !row.isValid) && (
<ul className="mt-2 list-disc list-inside text-destructive text-sm">
<li>Check the table below for specific errors in each row</li>
<li>Common issues include invalid email formats, missing required fields, or incorrect role values</li>
<li>Fix these issues in your CSV file and upload again</li>
</ul>
)}
</div>
</div>
</div>
);
}
function Summary({ rows, hasResults }: { rows: BulkInviteRow[]; hasResults: boolean }) {
if (!hasResults) {
return (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">User Preview</p>
<p className="text-sm bg-info/15 text-info px-2 py-1 rounded-sm">
{rows.filter((row) => row.isValid).length} of {rows.length} users valid
</p>
</div>
);
}
const failedCount = rows.filter((row) => row.status === "failed").length;
return (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">Creation Summary</p>
<p className="text-sm bg-success/15 text-success px-2 py-1 rounded-sm mr-2">
{rows.filter((row) => row.status === "success").length} Successful
</p>
{failedCount > 0 && (
<p className="text-sm bg-destructive/15 text-destructive px-2 py-1 rounded-sm">{failedCount} Failed</p>
)}
</div>
);
}
function CreateButton({ bulkInvite }: { bulkInvite: BulkInvite }) {
const validCount = bulkInvite.rows.filter((row) => row.isValid).length;
return (
<Button onClick={bulkInvite.createUsers} disabled={validCount === 0 || bulkInvite.isProcessing}>
{bulkInvite.isProcessing ? "Creating..." : `Create ${validCount} Users`}
</Button>
);
}
function CreationCompleteNotice() {
return (
<div className="mb-4 p-4 bg-info/10 border border-info/20 rounded-md">
<div className="flex items-start">
<div className="mr-3 mt-1">
<CheckCircleIcon className="h-5 w-5 text-info" />
</div>
<div>
<p className="font-medium text-info">User creation complete</p>
<p className="block text-sm text-info mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing Virtual Keys and
invitation links. Users will need these Virtual Keys to make LLM requests through LiteLLM.
</p>
</div>
</div>
</div>
);
}
function ReviewStep({ bulkInvite }: { bulkInvite: BulkInvite }) {
const { rows, parseError } = bulkInvite;
const hasResults = rows.some((row) => row.status === "success" || row.status === "failed");
return (
<div className="mb-6">
<StepHeading step={3} title={hasResults ? "User Creation Results" : "Review and create users"} />
{parseError && <ParseErrorNotice parseError={parseError} rows={rows} />}
<div className="ml-11">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center">
<Summary rows={rows} hasResults={hasResults} />
</div>
{!hasResults && (
<div className="flex space-x-3">
<Button variant="outline" onClick={bulkInvite.backToUpload}>
Back
</Button>
<CreateButton bulkInvite={bulkInvite} />
</div>
)}
</div>
{rows.some((row) => row.status === "success") && <CreationCompleteNotice />}
<PreviewTable rows={rows} pageIndex={bulkInvite.pageIndex} onPageChange={bulkInvite.setPageIndex} />
{hasResults ? (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={bulkInvite.backToUpload} className="mr-3">
Start New Bulk Import
</Button>
<Button onClick={bulkInvite.downloadResults}>
<Download className="size-4" />
Download User Credentials
</Button>
</div>
) : (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={bulkInvite.backToUpload} className="mr-3">
Back
</Button>
<CreateButton bulkInvite={bulkInvite} />
</div>
)}
</div>
</div>
);
}
export function BulkInviteDialog({ open, onClose, accessToken, teams, onUsersCreated }: BulkInviteDialogProps) {
const bulkInvite = useBulkInvite({ accessToken, teams, onUsersCreated });
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Bulk Invite Users</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
{bulkInvite.rows.length === 0 ? (
<div className="mb-6">
<TemplateStep />
<StepHeading step={2} title="Upload your completed CSV" />
<div className="ml-11">
<CsvDropzone
selectedFile={bulkInvite.selectedFile}
fileError={bulkInvite.fileError}
csvStructureError={bulkInvite.csvStructureError}
onFile={bulkInvite.uploadFile}
onRemove={bulkInvite.removeFile}
/>
</div>
</div>
) : (
<ReviewStep bulkInvite={bulkInvite} />
)}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -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<CsvDropzoneProps, "selectedFile" | "onFile"> & { file: File }) {
const tone = fileError ? "text-destructive" : "text-info";
return (
<div
className={`mb-4 p-4 rounded-md border ${fileError ? "bg-destructive/10 border-destructive/20" : "bg-info/10 border-info/20"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center min-w-0">
{fileError ? (
<FileWarning className="size-5 shrink-0 text-destructive mr-3" />
) : (
<FileText className="size-5 shrink-0 text-info mr-3" />
)}
<div className="min-w-0">
<strong className={`break-words ${tone}`}>{file.name}</strong>
<span className={`block text-xs ${tone}`}>
{(file.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()}
</span>
</div>
</div>
<Button variant="outline" size="sm" onClick={onRemove} className="flex items-center">
<Trash2 className="size-4" />
Remove
</Button>
</div>
{fileError && (
<div className="mt-3 text-destructive text-sm flex items-start">
<TriangleAlert className="size-3.5 shrink-0 mr-2 mt-0.5" />
<span className="min-w-0 break-words">{fileError}</span>
</div>
)}
{!fileError && !csvStructureError && (
<div className="mt-3 flex items-center">
<div className="w-full bg-border rounded-full h-1.5">
<div className="bg-info h-1.5 rounded-full w-full animate-pulse"></div>
</div>
<span className="ml-2 text-xs text-info">Processing...</span>
</div>
)}
</div>
);
}
function DropTarget({ onFile }: Pick<CsvDropzoneProps, "onFile">) {
const inputId = useId();
const [isDraggingOver, setIsDraggingOver] = useState(false);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) onFile(file);
};
const handleDragOver = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDraggingOver(true);
};
const handleDrop = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDraggingOver(false);
const file = event.dataTransfer.files?.[0];
if (file) onFile(file);
};
return (
<label
htmlFor={inputId}
className="block"
onDragOver={handleDragOver}
onDragLeave={() => setIsDraggingOver(false)}
onDrop={handleDrop}
>
<div
className={`border-2 border-dashed ${isDraggingOver ? "border-info" : "border-border"} rounded-lg p-8 text-center hover:border-info focus-within:border-info transition-colors cursor-pointer`}
>
<input id={inputId} type="file" accept=".csv" className="sr-only" onChange={handleInputChange} />
<Upload className="size-[30px] text-muted-foreground mb-2" />
<p className="mb-1">Drag and drop your CSV file here</p>
<p className="text-sm text-muted-foreground mb-3">or</p>
<span className={buttonVariants({ variant: "outline", size: "sm" })}>Browse files</span>
<p className="text-xs text-muted-foreground mt-4">Only CSV files (.csv) are supported</p>
</div>
</label>
);
}
export function CsvDropzone({ selectedFile, fileError, csvStructureError, onFile, onRemove }: CsvDropzoneProps) {
return (
<>
{selectedFile ? (
<SelectedFileCard
file={selectedFile}
fileError={fileError}
csvStructureError={csvStructureError}
onRemove={onRemove}
/>
) : (
<DropTarget onFile={onFile} />
)}
{csvStructureError && (
<div className="mb-4 p-4 bg-warning/10 border border-warning/20 rounded-md">
<div className="flex items-start">
<ExclamationIcon className="h-5 w-5 shrink-0 text-warning mr-2 mt-0.5" />
<div className="min-w-0">
<strong className="text-warning">CSV Structure Error</strong>
<p className="text-warning mt-1 mb-0 break-words">{csvStructureError}</p>
<p className="text-warning mt-2 mb-0">
Please download our template and ensure your CSV follows the required format.
</p>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -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 (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-destructive mr-2" />
<span className="text-destructive">{label}</span>
</div>
{error && <span className="text-sm text-destructive ml-7">{error}</span>}
</div>
);
}
function SuccessStatus({ invitationLink }: { invitationLink: string | undefined }) {
return (
<div>
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-success mr-2" />
<span className="text-success">Success</span>
</div>
{invitationLink && (
<div className="mt-1">
<div className="flex items-center">
<span className="text-xs text-muted-foreground truncate max-w-[150px]">{invitationLink}</span>
<CopyToClipboard text={invitationLink} onCopy={() => toast.success("Invitation link copied!")}>
<button className="ml-1 text-info text-xs hover:text-info/80">Copy</button>
</CopyToClipboard>
</div>
</div>
)}
</div>
);
}
function StatusCell({ row }: { row: BulkInviteRow }) {
if (!row.isValid) return <FailureStatus label="Invalid" error={row.error} />;
if (!row.status || row.status === "pending") return <span className="text-muted-foreground">Pending</span>;
if (row.status === "success") return <SuccessStatus invitationLink={row.invitation_link} />;
return <FailureStatus label="Failed" error={row.error && JSON.stringify(row.error)} />;
}
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 (
<>
<div className="max-h-[300px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Row</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Teams</TableHead>
<TableHead className={NUMERIC_CELL_CLASS}>Budget</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.rowNumber} className={!row.isValid ? "bg-destructive/10" : ""}>
<TableCell>{row.rowNumber}</TableCell>
<TableCell className="whitespace-normal break-words">{row.user_email}</TableCell>
<TableCell className="whitespace-normal break-words">{row.user_role}</TableCell>
<TableCell className="whitespace-normal break-words">{row.teams}</TableCell>
<TableCell className={NUMERIC_CELL_CLASS}>{row.max_budget}</TableCell>
<TableCell className="whitespace-normal break-words">
<StatusCell row={row} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pageCount > 1 && (
<div className="flex items-center justify-end gap-3 mt-2">
<span className="text-sm text-muted-foreground">
Page {currentPage + 1} of {pageCount}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= pageCount - 1}
>
Next
</Button>
</div>
)}
</>
);
}

View file

@ -0,0 +1,10 @@
export function StepHeading({ step, title }: { step: number; title: string }) {
return (
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-info text-info-foreground flex items-center justify-center mr-3">
{step}
</div>
<h3 className="text-lg font-medium">{title}</h3>
</div>
);
}

View file

@ -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 (
<>
<StepHeading step={1} title="Download and fill the template" />
<div className="ml-11 mb-6">
<p className="mb-4">Add multiple users at once by following these steps:</p>
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
<li>Download our CSV template</li>
<li>Add your users&apos; information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the Virtual Keys for each user</li>
</ol>
<div className="bg-muted p-4 rounded-md border border-border mb-4">
<h4 className="font-medium mb-2">Template Column Names</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TEMPLATE_COLUMNS.map((column) => (
<div key={column.name} className="flex items-start">
<div
className={`w-3 h-3 rounded-full ${column.required ? "bg-destructive" : "bg-border"} mt-1.5 mr-2 shrink-0`}
></div>
<div>
<p className="font-medium">{column.name}</p>
<p className="text-sm text-muted-foreground">{column.help}</p>
</div>
</div>
))}
</div>
</div>
<Button size="lg" className="w-full md:w-auto" onClick={downloadTemplate}>
<Download className="size-4" />
Download CSV Template
</Button>
</div>
</>
);
}

View file

@ -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),
]);

View file

@ -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> = {}): 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<BulkInviteApi["createUser"]>().mockResolvedValue(created),
createInvitation: vi.fn<BulkInviteApi["createInvitation"]>().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" });
});
});
});

View file

@ -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<CreatedUser | null>;
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<string> => {
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<CreateUserOutcome> => {
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<CreateUserOutcome> => {
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) };
}
};

View file

@ -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<string[]>(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,
});
});
});

View file

@ -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<typeof csvRowSchema>;
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<CsvParseResult> => {
const fileProblem = checkFile(file);
if (fileProblem) return Promise.resolve(fileProblem);
return new Promise((resolve) => {
Papa.parse<string[]>(file, {
complete: (results) => resolve(checkCsvRows(results.data, knownTeamIds)),
error: (error) => resolve({ kind: "parseError", message: `Failed to parse CSV file: ${error.message}` }),
header: false,
});
});
};

View file

@ -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);
};

View file

@ -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<BulkInviteRow[]>([]);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [problem, setProblem] = useState<UploadProblem | null>(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,
};
};

View file

@ -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));

View file

@ -0,0 +1 @@
export const USER_ROLES = ["proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer"] as const;

View file

@ -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<ViewUserDashboardProps> = ({
)}
{isProxyAdmin && (
<BulkCreateUsersButton accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
<BulkInviteButton accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
)}
{isProxyAdmin && (

View file

@ -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(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
await user.click(screen.getByText("+ Bulk Invite Users"));
return user;
};
describe("BulkCreateUsersButton", () => {
it("should render", () => {
render(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
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<string>((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([]);
});
});

View file

@ -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<string, Record<string, string>>;
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<BulkCreateUsersProps> = ({
accessToken,
teams,
possibleUIRoles,
onUsersCreated,
}) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [parsedData, setParsedData] = useState<UserData[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const [csvStructureError, setCsvStructureError] = useState<string | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uiSettings, setUISettings] = useState<UISettings | null>(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<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const handleDragOver = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDraggingOver(true);
};
const handleDrop = (event: React.DragEvent<HTMLLabelElement>) => {
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<UserData> = {
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 (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-destructive mr-2" />
<span className="text-destructive">Invalid</span>
</div>
{record.error && <span className="text-sm text-destructive ml-7">{record.error}</span>}
</div>
);
}
if (!record.status || record.status === "pending") {
return <span className="text-muted-foreground">Pending</span>;
}
if (record.status === "success") {
return (
<div>
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-success mr-2" />
<span className="text-success">Success</span>
</div>
{record.invitation_link && (
<div className="mt-1">
<div className="flex items-center">
<span className="text-xs text-muted-foreground truncate max-w-[150px]">{record.invitation_link}</span>
<CopyToClipboard text={record.invitation_link} onCopy={() => toast.success("Invitation link copied!")}>
<button className="ml-1 text-info text-xs hover:text-info/80">Copy</button>
</CopyToClipboard>
</div>
</div>
)}
</div>
);
}
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-destructive mr-2" />
<span className="text-destructive">Failed</span>
</div>
{record.error && <span className="text-sm text-destructive ml-7">{JSON.stringify(record.error)}</span>}
</div>
);
};
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 (
<>
<Button className="mb-0" onClick={() => setIsModalVisible(true)}>
+ Bulk Invite Users
</Button>
<Dialog open={isModalVisible} onOpenChange={(open) => !open && setIsModalVisible(false)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Bulk Invite Users</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
{/* Step indicator */}
{parsedData.length === 0 ? (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-info text-info-foreground flex items-center justify-center mr-3">
1
</div>
<h3 className="text-lg font-medium">Download and fill the template</h3>
</div>
<div className="ml-11 mb-6">
<p className="mb-4">Add multiple users at once by following these steps:</p>
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
<li>Download our CSV template</li>
<li>Add your users&apos; information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the Virtual Keys for each user</li>
</ol>
<div className="bg-muted p-4 rounded-md border border-border mb-4">
<h4 className="font-medium mb-2">Template Column Names</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-destructive mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_email</p>
<p className="text-sm text-muted-foreground">User&apos;s email address (required)</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-destructive mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_role</p>
<p className="text-sm text-muted-foreground">
User&apos;s role (one of: &quot;proxy_admin&quot;, &quot;proxy_admin_viewer&quot;,
&quot;internal_user&quot;, &quot;internal_user_viewer&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-border mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">teams</p>
<p className="text-sm text-muted-foreground">
Comma-separated team IDs (e.g., &quot;team-1,team-2&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-border mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">max_budget</p>
<p className="text-sm text-muted-foreground">
Maximum budget as a number (e.g., &quot;100&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-border mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">budget_duration</p>
<p className="text-sm text-muted-foreground">
Budget reset period (e.g., &quot;30d&quot;, &quot;1mo&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-border mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">models</p>
<p className="text-sm text-muted-foreground">
Comma-separated allowed models (e.g., &quot;gpt-3.5-turbo,gpt-4&quot;)
</p>
</div>
</div>
</div>
</div>
<Button size="lg" className="w-full md:w-auto" onClick={downloadTemplate}>
<Download className="size-4" />
Download CSV Template
</Button>
</div>
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-info text-info-foreground flex items-center justify-center mr-3">
2
</div>
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
</div>
<div className="ml-11">
{selectedFile ? (
<div
className={`mb-4 p-4 rounded-md border ${fileError ? "bg-destructive/10 border-destructive/20" : "bg-info/10 border-info/20"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center min-w-0">
{fileError ? (
<FileWarning className="size-5 shrink-0 text-destructive mr-3" />
) : (
<FileText className="size-5 shrink-0 text-info mr-3" />
)}
<div className="min-w-0">
<strong className={`break-words ${fileError ? "text-destructive" : "text-info"}`}>
{selectedFile.name}
</strong>
<span className={`block text-xs ${fileError ? "text-destructive" : "text-info"}`}>
{(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()}
</span>
</div>
</div>
<Button variant="outline" size="sm" onClick={removeSelectedFile} className="flex items-center">
<Trash2 className="size-4" />
Remove
</Button>
</div>
{fileError ? (
<div className="mt-3 text-destructive text-sm flex items-start">
<TriangleAlert className="size-3.5 shrink-0 mr-2 mt-0.5" />
<span className="min-w-0 break-words">{fileError}</span>
</div>
) : (
!csvStructureError && (
<div className="mt-3 flex items-center">
<div className="w-full bg-border rounded-full h-1.5">
<div className="bg-info h-1.5 rounded-full w-full animate-pulse"></div>
</div>
<span className="ml-2 text-xs text-info">Processing...</span>
</div>
)
)}
</div>
) : (
<label
htmlFor={csvInputId}
className="block"
onDragOver={handleDragOver}
onDragLeave={() => setIsDraggingOver(false)}
onDrop={handleDrop}
>
<div
className={`border-2 border-dashed ${isDraggingOver ? "border-info" : "border-border"} rounded-lg p-8 text-center hover:border-info focus-within:border-info transition-colors cursor-pointer`}
>
<input
id={csvInputId}
type="file"
accept=".csv"
className="sr-only"
onChange={handleFileInputChange}
/>
<Upload className="size-[30px] text-muted-foreground mb-2" />
<p className="mb-1">Drag and drop your CSV file here</p>
<p className="text-sm text-muted-foreground mb-3">or</p>
<span className={buttonVariants({ variant: "outline", size: "sm" })}>Browse files</span>
<p className="text-xs text-muted-foreground mt-4">Only CSV files (.csv) are supported</p>
</div>
</label>
)}
{csvStructureError && (
<div className="mb-4 p-4 bg-warning/10 border border-warning/20 rounded-md">
<div className="flex items-start">
<ExclamationIcon className="h-5 w-5 shrink-0 text-warning mr-2 mt-0.5" />
<div className="min-w-0">
<strong className="text-warning">CSV Structure Error</strong>
<p className="text-warning mt-1 mb-0 break-words">{csvStructureError}</p>
<p className="text-warning mt-2 mb-0">
Please download our template and ensure your CSV follows the required format.
</p>
</div>
</div>
</div>
)}
</div>
</div>
) : (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-info text-info-foreground flex items-center justify-center mr-3">
3
</div>
<h3 className="text-lg font-medium">
{parsedData.some((user) => user.status === "success" || user.status === "failed")
? "User Creation Results"
: "Review and create users"}
</h3>
</div>
{parseError && (
<div className="ml-11 mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex items-start">
<TriangleAlert className="size-4 shrink-0 text-destructive mr-2 mt-1" />
<div className="min-w-0">
<p className="text-destructive font-medium break-words">{parseError}</p>
{parsedData.some((user) => !user.isValid) && (
<ul className="mt-2 list-disc list-inside text-destructive text-sm">
<li>Check the table below for specific errors in each row</li>
<li>
Common issues include invalid email formats, missing required fields, or incorrect role
values
</li>
<li>Fix these issues in your CSV file and upload again</li>
</ul>
)}
</div>
</div>
</div>
)}
<div className="ml-11">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center">
{parsedData.some((user) => user.status === "success" || user.status === "failed") ? (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">Creation Summary</p>
<p className="text-sm bg-success/15 text-success px-2 py-1 rounded-sm mr-2">
{parsedData.filter((d) => d.status === "success").length} Successful
</p>
{parsedData.some((d) => d.status === "failed") && (
<p className="text-sm bg-destructive/15 text-destructive px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.status === "failed").length} Failed
</p>
)}
</div>
) : (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">User Preview</p>
<p className="text-sm bg-info/15 text-info px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid
</p>
</div>
)}
</div>
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex space-x-3">
<Button variant="outline" onClick={resetParsedData}>
Back
</Button>
<Button
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
</Button>
</div>
)}
</div>
{parsedData.some((user) => user.status === "success") && (
<div className="mb-4 p-4 bg-info/10 border border-info/20 rounded-md">
<div className="flex items-start">
<div className="mr-3 mt-1">
<CheckCircleIcon className="h-5 w-5 text-info" />
</div>
<div>
<p className="font-medium text-info">User creation complete</p>
<p className="block text-sm text-info mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
through LiteLLM.
</p>
</div>
</div>
</div>
)}
<div className="max-h-[300px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Row</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Teams</TableHead>
<TableHead className={NUMERIC_CELL_CLASS}>Budget</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((record) => (
<TableRow key={record.rowNumber} className={!record.isValid ? "bg-destructive/10" : ""}>
<TableCell>{record.rowNumber}</TableCell>
<TableCell className="whitespace-normal break-words">{record.user_email}</TableCell>
<TableCell className="whitespace-normal break-words">{record.user_role}</TableCell>
<TableCell className="whitespace-normal break-words">{record.teams}</TableCell>
<TableCell className={NUMERIC_CELL_CLASS}>{record.max_budget}</TableCell>
<TableCell className="whitespace-normal break-words">{renderStatusCell(record)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pageCount > 1 && (
<div className="flex items-center justify-end gap-3 mt-2">
<span className="text-sm text-muted-foreground">
Page {currentPage + 1} of {pageCount}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPage - 1)}
disabled={currentPage === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPage + 1)}
disabled={currentPage >= pageCount - 1}
>
Next
</Button>
</div>
)}
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={resetParsedData} className="mr-3">
Back
</Button>
<Button
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
</Button>
</div>
)}
{parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={resetParsedData} className="mr-3">
Start New Bulk Import
</Button>
<Button onClick={downloadResults}>
<Download className="size-4" />
Download User Credentials
</Button>
</div>
)}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
);
};
export default BulkCreateUsersButton;