mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
feat(ui): add a quick user budget editor
Add an admin-only budget action to Internal Users Preserve unchanged reset schedules and refresh user rows after saving Keep the logo license inside its SVG so Next.js can build the dashboard
This commit is contained in:
parent
4187e1ae89
commit
c9dfd6411d
11 changed files with 302 additions and 4 deletions
|
|
@ -1,3 +1,4 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
Source: https://github.com/lobehub/lobe-icons/blob/master/packages/static-svg/icons/claude-color.svg
|
||||
Retrieved: 2026-10-22
|
||||
|
|
@ -24,4 +25,4 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
-->
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
<title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
|
@ -0,0 +1,86 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { UserInfo } from "@/components/networking";
|
||||
import { chooseSelectOption } from "../../../../../tests/test-utils";
|
||||
|
||||
import UserBudgetDialog from "./UserBudgetDialog";
|
||||
|
||||
const userInfo: Pick<UserInfo, "user_id" | "user_email" | "max_budget" | "budget_duration"> = {
|
||||
user_id: "budget-user",
|
||||
user_email: "budget@example.test",
|
||||
max_budget: 12.345,
|
||||
budget_duration: "7d",
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
const renderDialog = () => {
|
||||
const onClose = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UserBudgetDialog user={userInfo} accessToken="test-token" onClose={onClose} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onClose;
|
||||
};
|
||||
|
||||
describe("UserBudgetDialog", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(Response.json({ user_id: userInfo.user_id }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("submits only the selected user's budget and preserves the existing reset schedule", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = renderDialog();
|
||||
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(12.345);
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "42.765" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save budget" }));
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/user/update"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: "budget-user", max_budget: 42.765 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can remove the limit and change its reset period", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = renderDialog();
|
||||
await user.click(screen.getByRole("checkbox", { name: "Unlimited budget" }));
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Reset Budget" }), "monthly");
|
||||
await user.click(screen.getByRole("button", { name: "Save budget" }));
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/user/update"),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ user_id: "budget-user", max_budget: null, budget_duration: "30d" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the edited values available after an update fails so the admin can retry", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Update failed" }, { status: 500 }));
|
||||
const onClose = renderDialog();
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "25" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save budget" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Save budget" })).toBeEnabled());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(25);
|
||||
await user.click(screen.getByRole("button", { name: "Save budget" }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { useId } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import { UserInfo, userUpdateUserCall } from "@/components/networking";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
import { userBudgetSchema } from "./userBudgetForm";
|
||||
|
||||
interface UserBudgetDialogProps {
|
||||
user: Pick<UserInfo, "user_id" | "user_email" | "max_budget" | "budget_duration">;
|
||||
accessToken: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function UserBudgetDialog({ user, accessToken, onClose }: UserBudgetDialogProps) {
|
||||
const formId = useId();
|
||||
const queryClient = useQueryClient();
|
||||
const form = useZodForm(userBudgetSchema, {
|
||||
defaultValues: {
|
||||
unlimited: user.max_budget == null,
|
||||
max_budget: user.max_budget?.toString() ?? "",
|
||||
budget_duration: user.budget_duration ?? null,
|
||||
},
|
||||
});
|
||||
const unlimited = form.watch("unlimited");
|
||||
const { isSubmitting, dirtyFields } = form.formState;
|
||||
|
||||
const save = async (values: z.output<typeof userBudgetSchema>) => {
|
||||
try {
|
||||
await userUpdateUserCall(
|
||||
accessToken,
|
||||
{
|
||||
user_id: user.user_id,
|
||||
max_budget: values.unlimited ? null : Number(values.max_budget),
|
||||
...(dirtyFields.budget_duration ? { budget_duration: values.budget_duration } : {}),
|
||||
},
|
||||
null,
|
||||
);
|
||||
await queryClient.invalidateQueries({ queryKey: ["userList"] });
|
||||
toast.success("Budget updated");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !isSubmitting && onClose()}>
|
||||
<DialogContent showCloseButton={!isSubmitting}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adjust budget</DialogTitle>
|
||||
<DialogDescription>{user.user_email || user.user_id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id={formId} onSubmit={form.handleSubmit(save)} noValidate>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="unlimited" label="Unlimited budget" orientation="horizontal">
|
||||
{({ id, value, onChange }) => <Checkbox id={id} checked={value} onCheckedChange={onChange} />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
|
||||
{({ ref, value, onChange, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={unlimited || isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="budget_duration" label="Reset Budget">
|
||||
{({ id, value, onChange }) => <BudgetDurationDropdown id={id} value={value} onChange={onChange} />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save budget"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { userBudgetSchema } from "./userBudgetForm";
|
||||
|
||||
describe("userBudgetSchema", () => {
|
||||
it.each(["0", "0.001", "42.765"])("accepts a finite non-negative budget of %s", (max_budget) => {
|
||||
expect(userBudgetSchema.safeParse({ unlimited: false, max_budget, budget_duration: "7d" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["", " ", "-1", "NaN", "Infinity", "12abc"])("rejects an invalid finite budget of %s", (max_budget) => {
|
||||
expect(userBudgetSchema.safeParse({ unlimited: false, max_budget, budget_duration: null }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("represents unlimited as null without requiring a disabled amount", () => {
|
||||
expect(userBudgetSchema.safeParse({ unlimited: true, max_budget: "", budget_duration: null }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const userBudgetSchema = z
|
||||
.object({
|
||||
unlimited: z.boolean(),
|
||||
max_budget: z.string(),
|
||||
budget_duration: z.string().nullable(),
|
||||
})
|
||||
.refine(
|
||||
({ unlimited, max_budget }) => {
|
||||
const amount = Number(max_budget);
|
||||
const validAmount = max_budget.trim() !== "" && Number.isFinite(amount) && amount >= 0;
|
||||
return unlimited || validAmount;
|
||||
},
|
||||
{ path: ["max_budget"], message: "Enter a non-negative budget" },
|
||||
);
|
||||
|
|
@ -81,6 +81,7 @@ const defaultProps = {
|
|||
accessToken: "test-token",
|
||||
token: "test-token",
|
||||
userRole: "Admin",
|
||||
isViewOnly: false,
|
||||
userID: "admin-user-id",
|
||||
teams: [],
|
||||
};
|
||||
|
|
@ -104,6 +105,23 @@ describe("ViewUserDashboard", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("offers the budget quick action to a write-capable proxy admin", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
await user.click(await screen.findByTestId("user-actions-user-1"));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Adjust budget" }));
|
||||
expect(screen.getByRole("dialog", { name: "Adjust budget" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "Unlimited budget" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("hides budget updates from a read-only session even when its display role is Admin", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard({ isViewOnly: true });
|
||||
await user.click(await screen.findByTestId("user-actions-user-1"));
|
||||
await screen.findByTestId("user-action-edit");
|
||||
expect(screen.queryByRole("menuitem", { name: "Adjust budget" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the ViewUserDashboard component", async () => {
|
||||
renderDashboard();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { parseAsString, useQueryState } from "nuqs";
|
|||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import BulkEditUserModal from "./BulkEditUsers";
|
||||
import UserBudgetDialog from "./UserBudgetDialog";
|
||||
import BulkCreateUsersButton from "@/components/bulk_create_users_button";
|
||||
import { CreateUserButton } from "@/components/CreateUserButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -39,6 +40,7 @@ interface ViewUserDashboardProps {
|
|||
accessToken: string | null;
|
||||
token: string | null;
|
||||
userRole: string | null;
|
||||
isViewOnly?: boolean;
|
||||
userID: string | null;
|
||||
teams: any[] | null;
|
||||
orgAdminOrgIds?: Array<{ organization_id: string; organization_alias: string }> | null;
|
||||
|
|
@ -54,6 +56,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
accessToken,
|
||||
token,
|
||||
userRole,
|
||||
isViewOnly,
|
||||
userID,
|
||||
teams,
|
||||
orgAdminOrgIds,
|
||||
|
|
@ -77,6 +80,9 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<UserInfo | null>(null);
|
||||
const [budgetUser, setBudgetUser] = useState<UserInfo | null>(null);
|
||||
const canAdjustBudget = isProxyAdmin && isViewOnly === false;
|
||||
const budgetDialogUser = canAdjustBudget ? budgetUser : null;
|
||||
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
|
||||
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState<string | null>(null);
|
||||
|
|
@ -313,6 +319,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
onUserClick={handleUserClick}
|
||||
onDeleteUser={handleDelete}
|
||||
onResetPassword={handleResetPassword}
|
||||
onAdjustBudget={canAdjustBudget ? setBudgetUser : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -401,6 +408,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
usersTable
|
||||
)}
|
||||
|
||||
{budgetDialogUser && accessToken && (
|
||||
<UserBudgetDialog user={budgetDialogUser} accessToken={accessToken} onClose={() => setBudgetUser(null)} />
|
||||
)}
|
||||
|
||||
{/* Existing Modals */}
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ interface HarnessOverrides {
|
|||
onUserClick?: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser?: (user: UserInfo) => void;
|
||||
onResetPassword?: (userId: string) => void;
|
||||
onAdjustBudget?: (user: UserInfo) => void;
|
||||
onSortingChange?: Mock;
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +57,7 @@ function Harness({
|
|||
onUserClick = vi.fn(),
|
||||
onDeleteUser = vi.fn(),
|
||||
onResetPassword = vi.fn(),
|
||||
onAdjustBudget,
|
||||
onSortingChange,
|
||||
}: HarnessOverrides) {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
|
|
@ -94,6 +96,7 @@ function Harness({
|
|||
onUserClick={onUserClick}
|
||||
onDeleteUser={onDeleteUser}
|
||||
onResetPassword={onResetPassword}
|
||||
onAdjustBudget={onAdjustBudget}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -104,6 +107,24 @@ const openRowMenu = async (user: ReturnType<typeof userEvent.setup>, userId: str
|
|||
};
|
||||
|
||||
describe("UsersTable", () => {
|
||||
it("opens the budget editor for the selected row when the action is allowed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdjustBudget = vi.fn();
|
||||
const selected = makeUser({ user_id: "budget-user", max_budget: 25 });
|
||||
render(<Harness data={[selected]} onAdjustBudget={onAdjustBudget} />);
|
||||
await openRowMenu(user, "budget-user");
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Adjust budget" }));
|
||||
expect(onAdjustBudget).toHaveBeenCalledWith(selected);
|
||||
});
|
||||
|
||||
it("does not offer a budget update without permission", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await openRowMenu(user, "user-1");
|
||||
await screen.findByTestId("user-action-edit");
|
||||
expect(screen.queryByRole("menuitem", { name: "Adjust budget" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders every migrated column header", () => {
|
||||
render(<Harness />);
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ interface UsersTableProps {
|
|||
onUserClick: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser: (user: UserInfo) => void;
|
||||
onResetPassword: (userId: string) => void;
|
||||
onAdjustBudget?: (user: UserInfo) => void;
|
||||
}
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
|
|
@ -90,6 +91,7 @@ export function UsersTable({
|
|||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
onAdjustBudget,
|
||||
}: UsersTableProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
|
|
@ -101,9 +103,10 @@ export function UsersTable({
|
|||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
onAdjustBudget,
|
||||
};
|
||||
return getUsersTableColumns(columnDeps);
|
||||
}, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword]);
|
||||
}, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword, onAdjustBudget]);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -35,9 +35,17 @@ interface UserRowActionsProps {
|
|||
onUserClick: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser: (user: UserInfo) => void;
|
||||
onResetPassword: (userId: string) => void;
|
||||
onAdjustBudget?: (user: UserInfo) => void;
|
||||
}
|
||||
|
||||
function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) {
|
||||
function UserRowActions({
|
||||
user,
|
||||
canViewUserUsage,
|
||||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
onAdjustBudget,
|
||||
}: UserRowActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
@ -52,6 +60,12 @@ function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onR
|
|||
<Pencil />
|
||||
Edit user
|
||||
</DropdownMenuItem>
|
||||
{onAdjustBudget && (
|
||||
<DropdownMenuItem onClick={() => onAdjustBudget(user)}>
|
||||
<Pencil />
|
||||
Adjust budget
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onResetPassword(user.user_id)} data-testid="user-action-reset-password">
|
||||
<KeyRound />
|
||||
Reset password
|
||||
|
|
@ -89,6 +103,7 @@ export interface UsersTableColumnsDeps {
|
|||
onUserClick: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser: (user: UserInfo) => void;
|
||||
onResetPassword: (userId: string) => void;
|
||||
onAdjustBudget?: (user: UserInfo) => void;
|
||||
}
|
||||
|
||||
export const getUsersTableColumns = ({
|
||||
|
|
@ -98,6 +113,7 @@ export const getUsersTableColumns = ({
|
|||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
onAdjustBudget,
|
||||
}: UsersTableColumnsDeps): ColumnDef<UserInfo>[] => {
|
||||
const baseColumns: ColumnDef<UserInfo>[] = [
|
||||
{
|
||||
|
|
@ -282,6 +298,7 @@ export const getUsersTableColumns = ({
|
|||
onUserClick={onUserClick}
|
||||
onDeleteUser={onDeleteUser}
|
||||
onResetPassword={onResetPassword}
|
||||
onAdjustBudget={onAdjustBudget}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
export default function UsersPage() {
|
||||
const { accessToken, token, userRole, userId } = useAuthorized();
|
||||
const { accessToken, token, userRole, userId, isViewOnly } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
return (
|
||||
<ViewUserDashboard
|
||||
userID={userId}
|
||||
userRole={userRole}
|
||||
isViewOnly={isViewOnly}
|
||||
token={token}
|
||||
teams={teams ?? null}
|
||||
accessToken={accessToken}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue