mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
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:
parent
560372456e
commit
a2318fc1e2
20 changed files with 1604 additions and 1010 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) };
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export const USER_ROLES = ["proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer"] as const;
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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' 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'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's role (one of: "proxy_admin", "proxy_admin_viewer",
|
||||
"internal_user", "internal_user_viewer")
|
||||
</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., "team-1,team-2")
|
||||
</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., "100")
|
||||
</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., "30d", "1mo")
|
||||
</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., "gpt-3.5-turbo,gpt-4")
|
||||
</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;
|
||||
Loading…
Add table
Reference in a new issue