mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(usage): redesign the create-spending-limit modal (frontend 1/2) (#13783)
This commit is contained in:
1 parent
08b75ae8c9
commit
a7ba98fef8
2 files changed
+662
-116
No files matched your search
@@ -1,19 +1,99 @@
|
||||
import { render, screen, setupUser } from "@tests/setup/test-utils";
|
||||
import { fireEvent, render, screen, waitFor } from "@tests/setup/test-utils";
|
||||
import CreateRateLimitModal from "@/app/admin/token-rate-limits/CreateRateLimitModal";
|
||||
|
||||
test("requires at least one enforcement budget", async () => {
|
||||
const user = setupUser();
|
||||
beforeEach(() => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, json: async () => [] } as Response);
|
||||
});
|
||||
|
||||
test("requires a cost budget when no token budget is set", async () => {
|
||||
const onSubmit = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<CreateRateLimitModal isOpen setIsOpen={jest.fn()} onSubmit={onSubmit} />
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText("Time Window (UTC Days)"), "1");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
const modal = screen.getByRole("dialog");
|
||||
const resetPeriod = screen.getByRole("textbox", { name: /Reset period/ });
|
||||
|
||||
expect(modal).toHaveAttribute("data-width", "sm");
|
||||
expect(modal).toHaveAttribute("data-height", "fit");
|
||||
expect(resetPeriod).toHaveAttribute("type", "text");
|
||||
expect(resetPeriod).toHaveAttribute("inputmode", "numeric");
|
||||
expect(resetPeriod).toHaveValue("30");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create limit" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Set a token budget and/or a cost budget")
|
||||
await screen.findByText("Enter a cost budget, a token budget, or both")
|
||||
).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("formats and submits a token-only budget", async () => {
|
||||
const onSubmit = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<CreateRateLimitModal isOpen setIsOpen={jest.fn()} onSubmit={onSubmit} />
|
||||
);
|
||||
|
||||
const tokenBudget = screen.getByRole("textbox", {
|
||||
name: /Token budget/,
|
||||
});
|
||||
|
||||
fireEvent.change(tokenBudget, {
|
||||
target: { value: "1500000000" },
|
||||
});
|
||||
|
||||
expect(tokenBudget).toHaveValue("1,500,000,000");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create limit" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
"global",
|
||||
720,
|
||||
1_500_000,
|
||||
null,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a cost budget that rounds below one cent", async () => {
|
||||
const onSubmit = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<CreateRateLimitModal isOpen setIsOpen={jest.fn()} onSubmit={onSubmit} />
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: /Cost budget/ }), {
|
||||
target: { value: "0.001" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create limit" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Cost budget must be at least $0.01")
|
||||
).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("supports arrow-key navigation between scope options", async () => {
|
||||
render(
|
||||
<CreateRateLimitModal
|
||||
isOpen
|
||||
setIsOpen={jest.fn()}
|
||||
onSubmit={jest.fn().mockResolvedValue(undefined)}
|
||||
/>
|
||||
);
|
||||
|
||||
const workspace = screen.getByRole("radio", { name: "Workspace" });
|
||||
const perUser = screen.getByRole("radio", { name: "Per user" });
|
||||
|
||||
workspace.focus();
|
||||
fireEvent.keyDown(workspace, { key: "ArrowRight" });
|
||||
|
||||
await waitFor(() => expect(perUser).toHaveAttribute("aria-checked", "true"));
|
||||
expect(perUser).toHaveFocus();
|
||||
});
|
||||
@@ -1,17 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { Button } from "@opal/components";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "@opal/components";
|
||||
import { Form, Formik } from "formik";
|
||||
import { SelectorFormField, TextFormField } from "@/components/Field";
|
||||
import { UserGroup } from "@/lib/types";
|
||||
import {
|
||||
Button,
|
||||
InputTypeIn,
|
||||
LineItemButton,
|
||||
Modal,
|
||||
Popover,
|
||||
SelectCard,
|
||||
Text,
|
||||
} from "@opal/components";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Formik, useField, useFormikContext } from "formik";
|
||||
import { Scope } from "./types";
|
||||
import { toast } from "@opal/layouts";
|
||||
import { SvgSettings } from "@opal/icons";
|
||||
import {
|
||||
ContentAction,
|
||||
InputErrorText,
|
||||
InputVertical,
|
||||
Section,
|
||||
toast,
|
||||
} from "@opal/layouts";
|
||||
import { SvgCheck, SvgGlobe, SvgUser, SvgUsers } from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
|
||||
|
||||
const HOURS_PER_DAY = 24;
|
||||
// 1T tokens stored as thousands (1e9) stays well inside the column's int32.
|
||||
const MAX_TOKEN_BUDGET = 1_000_000_000_000;
|
||||
// Keeps *100 finite: an unbounded value can overflow to Infinity, which JSON.stringify serializes as null.
|
||||
const MAX_COST_BUDGET_DOLLARS = 10_000_000_000;
|
||||
|
||||
interface RateLimitFormValues {
|
||||
enabled: boolean;
|
||||
period_days: string;
|
||||
token_budget: string;
|
||||
cost_budget_dollars: string;
|
||||
target_scope: Scope;
|
||||
user_group_id: number | undefined;
|
||||
}
|
||||
|
||||
interface ScopeOptionConfig {
|
||||
value: Scope;
|
||||
icon: IconFunctionComponent;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS: ScopeOptionConfig[] = [
|
||||
{ value: Scope.GLOBAL, icon: SvgGlobe, title: "Workspace" },
|
||||
{ value: Scope.USER, icon: SvgUser, title: "Per user" },
|
||||
{ value: Scope.USER_GROUP, icon: SvgUsers, title: "User group" },
|
||||
];
|
||||
|
||||
interface ScopeOptionProps extends React.HTMLAttributes<HTMLElement> {
|
||||
option: ScopeOptionConfig;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function handleRadioOptionKeyDown(
|
||||
event: React.KeyboardEvent<HTMLElement>,
|
||||
onSelect: () => void
|
||||
): void {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key !== "ArrowRight" &&
|
||||
event.key !== "ArrowDown" &&
|
||||
event.key !== "ArrowLeft" &&
|
||||
event.key !== "ArrowUp" &&
|
||||
event.key !== "Home" &&
|
||||
event.key !== "End"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const group = event.currentTarget.closest('[role="radiogroup"]');
|
||||
const options = Array.from(
|
||||
group?.querySelectorAll<HTMLElement>('[role="radio"]') ?? []
|
||||
);
|
||||
const currentIndex = options.indexOf(event.currentTarget);
|
||||
const nextIndex =
|
||||
event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? options.length - 1
|
||||
: event.key === "ArrowRight" || event.key === "ArrowDown"
|
||||
? (currentIndex + 1) % options.length
|
||||
: (currentIndex - 1 + options.length) % options.length;
|
||||
options[nextIndex]?.focus();
|
||||
options[nextIndex]?.click();
|
||||
}
|
||||
|
||||
function ScopeOption({
|
||||
option,
|
||||
selected,
|
||||
onSelect,
|
||||
ref,
|
||||
...rest
|
||||
}: ScopeOptionProps) {
|
||||
return (
|
||||
<SelectCard
|
||||
state={selected ? "selected" : "empty"}
|
||||
padding="sm"
|
||||
rounding="sm"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={option.title}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
onClick={(event) => {
|
||||
rest.onClick?.(event);
|
||||
onSelect();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
rest.onKeyDown?.(event);
|
||||
handleRadioOptionKeyDown(event, onSelect);
|
||||
}}
|
||||
>
|
||||
<ContentAction
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={option.icon}
|
||||
title={option.title}
|
||||
padding="fit"
|
||||
color="interactive"
|
||||
center
|
||||
rightChildren={
|
||||
selected ? (
|
||||
<SvgCheck
|
||||
size={16}
|
||||
className="shrink-0 stroke-action-selection-05"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</SelectCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupScopeOptionProps {
|
||||
option: ScopeOptionConfig;
|
||||
selected: boolean;
|
||||
groups: { name: string; value: number }[];
|
||||
selectedGroupId: number | undefined;
|
||||
onSelectScope: () => void;
|
||||
onSelectGroup: (groupId: number) => void;
|
||||
}
|
||||
|
||||
interface GroupMenuContentProps {
|
||||
groups: { name: string; value: number }[];
|
||||
selectedGroupId: number | undefined;
|
||||
onSelectGroup: (groupId: number) => void;
|
||||
}
|
||||
|
||||
function GroupMenuContent({
|
||||
groups,
|
||||
selectedGroupId,
|
||||
onSelectGroup,
|
||||
}: GroupMenuContentProps) {
|
||||
return (
|
||||
<Popover.Content align="start" side="bottom" width="lg">
|
||||
<Popover.Menu>
|
||||
{groups.length === 0
|
||||
? [
|
||||
<div className="p-2" key="empty">
|
||||
<Text font="secondary-body" color="text-03" as="p">
|
||||
No user groups yet. Create one under Users & Groups.
|
||||
</Text>
|
||||
</div>,
|
||||
]
|
||||
: groups.map((group) => (
|
||||
<Popover.Close asChild key={group.value}>
|
||||
<LineItemButton
|
||||
onClick={() => onSelectGroup(group.value)}
|
||||
rounding="md"
|
||||
selectVariant="select-heavy"
|
||||
sizePreset="main-ui"
|
||||
state={
|
||||
String(group.value) === String(selectedGroupId)
|
||||
? "selected"
|
||||
: "empty"
|
||||
}
|
||||
title={group.name}
|
||||
variant="section"
|
||||
width="full"
|
||||
/>
|
||||
</Popover.Close>
|
||||
))}
|
||||
</Popover.Menu>
|
||||
</Popover.Content>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupScopeOption({
|
||||
option,
|
||||
selected,
|
||||
groups,
|
||||
selectedGroupId,
|
||||
onSelectScope,
|
||||
onSelectGroup,
|
||||
}: GroupScopeOptionProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<ScopeOption
|
||||
option={option}
|
||||
selected={selected}
|
||||
onSelect={onSelectScope}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<GroupMenuContent
|
||||
groups={groups}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onSelectGroup={onSelectGroup}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenBudgetField() {
|
||||
const [field, meta, helpers] = useField<string>("token_budget");
|
||||
const digits = String(field.value ?? "")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 15);
|
||||
const formattedValue = digits
|
||||
? new Intl.NumberFormat("en-US").format(Number(digits))
|
||||
: "";
|
||||
|
||||
return (
|
||||
<InputTypeIn
|
||||
id="token_budget"
|
||||
name="token_budget"
|
||||
value={formattedValue}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9,]*"
|
||||
maxLength={19}
|
||||
placeholder="No token limit"
|
||||
onChange={(event) => {
|
||||
const nextDigits = event.target.value.replace(/\D/g, "").slice(0, 15);
|
||||
void helpers.setValue(nextDigits);
|
||||
}}
|
||||
onBlur={() => void helpers.setTouched(true)}
|
||||
variant={meta.touched && meta.error ? "error" : "primary"}
|
||||
rightChildren={
|
||||
<div className="pr-1">
|
||||
<Text font="secondary-action" color="text-03" nowrap>
|
||||
tokens
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDollarBudgetInput(raw: string): string | null {
|
||||
const amount = Number(raw);
|
||||
if (raw === "" || !Number.isFinite(amount) || amount <= 0) return null;
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatTokenBudgetInput(raw: string): string | null {
|
||||
const amount = Number(raw);
|
||||
if (raw === "" || !Number.isFinite(amount) || amount <= 0) return null;
|
||||
return `${new Intl.NumberFormat("en-US").format(amount)} tokens`;
|
||||
}
|
||||
|
||||
interface GroupPickerProps {
|
||||
groups: { name: string; value: number }[];
|
||||
selectedGroupId: number | undefined;
|
||||
onSelectGroup: (groupId: number) => void;
|
||||
}
|
||||
|
||||
function GroupPicker({
|
||||
groups,
|
||||
selectedGroupId,
|
||||
onSelectGroup,
|
||||
}: GroupPickerProps) {
|
||||
const selectedGroup = groups.find(
|
||||
(group) => String(group.value) === String(selectedGroupId)
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<Button prominence="secondary" width="full">
|
||||
{selectedGroup?.name ?? "Choose a group"}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<GroupMenuContent
|
||||
groups={groups}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onSelectGroup={onSelectGroup}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface LimitSummaryProps {
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
function LimitSummary({ groupName }: LimitSummaryProps) {
|
||||
const { values } = useFormikContext<RateLimitFormValues>();
|
||||
|
||||
const period = Number(values.period_days);
|
||||
if (!Number.isInteger(period) || period < 1) return null;
|
||||
|
||||
const budgets = [
|
||||
formatDollarBudgetInput(values.cost_budget_dollars),
|
||||
formatTokenBudgetInput(values.token_budget),
|
||||
].filter((budget): budget is string => budget !== null);
|
||||
if (budgets.length === 0) return null;
|
||||
|
||||
const budgetText = budgets.join(" or ");
|
||||
const periodText = period === 1 ? "every day" : `every ${period} days`;
|
||||
|
||||
const subjectText =
|
||||
values.target_scope === Scope.GLOBAL
|
||||
? "Everyone in the workspace shares"
|
||||
: values.target_scope === Scope.USER
|
||||
? "Each user can spend"
|
||||
: groupName
|
||||
? `Members of ${groupName} share`
|
||||
: "Group members share";
|
||||
|
||||
const summary = `${subjectText} up to ${budgetText} ${periodText}. Usage is blocked until the period resets.`;
|
||||
|
||||
return (
|
||||
<div className="rounded-08 bg-background-tint-01 p-2">
|
||||
<Text font="secondary-body" color="text-03" as="p">
|
||||
{summary}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateRateLimitModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -21,7 +351,7 @@ interface CreateRateLimitModalProps {
|
||||
period_hours: number,
|
||||
token_budget: number | null,
|
||||
cost_budget_cents: number | null,
|
||||
group_id: number
|
||||
group_id?: number
|
||||
) => Promise<void>;
|
||||
forSpecificScope?: Scope;
|
||||
forSpecificUserGroup?: number;
|
||||
@@ -34,44 +364,55 @@ export default function CreateRateLimitModal({
|
||||
forSpecificScope,
|
||||
forSpecificUserGroup,
|
||||
}: CreateRateLimitModalProps) {
|
||||
const [modalUserGroups, setModalUserGroups] = useState([]);
|
||||
const [shouldFetchUserGroups, setShouldFetchUserGroups] = useState(
|
||||
forSpecificScope === Scope.USER_GROUP
|
||||
);
|
||||
const [modalUserGroups, setModalUserGroups] = useState<
|
||||
{ name: string; value: number }[]
|
||||
>([]);
|
||||
const groupScopeAvailable =
|
||||
forSpecificScope === undefined || forSpecificScope === Scope.USER_GROUP;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !groupScopeAvailable || forSpecificUserGroup !== undefined) {
|
||||
return;
|
||||
}
|
||||
let stale = false;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/manage/admin/user-group");
|
||||
const data = await response.json();
|
||||
const options = data.map((userGroup: UserGroup) => ({
|
||||
const response = await fetch("/api/manage/user-groups/minimal");
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || "Request failed");
|
||||
}
|
||||
const data = (await response.json()) as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
}>;
|
||||
if (stale) return;
|
||||
const options = data.map((userGroup) => ({
|
||||
name: userGroup.name,
|
||||
value: userGroup.id,
|
||||
}));
|
||||
setModalUserGroups(options);
|
||||
setShouldFetchUserGroups(false);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to fetch user groups: ${error}`);
|
||||
console.error("Failed to fetch user groups:", error);
|
||||
if (!stale) toast.error(`Failed to fetch user groups: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldFetchUserGroups) {
|
||||
fetchData();
|
||||
}
|
||||
}, [shouldFetchUserGroups]);
|
||||
fetchData();
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [isOpen, groupScopeAvailable, forSpecificUserGroup]);
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={() => setIsOpen(false)}>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Modal.Content width="sm" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgSettings}
|
||||
title="Create a Token Rate Limit"
|
||||
title="Create spending limit"
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
<Formik
|
||||
<Formik<RateLimitFormValues>
|
||||
initialValues={{
|
||||
enabled: true,
|
||||
period_days: "",
|
||||
period_days: "30",
|
||||
token_budget: "",
|
||||
cost_budget_dollars: "",
|
||||
target_scope: forSpecificScope || Scope.GLOBAL,
|
||||
@@ -79,36 +420,51 @@ export default function CreateRateLimitModal({
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
period_days: Yup.number()
|
||||
.required("Time Window is a required field")
|
||||
.integer("Time Window must be a whole number of days")
|
||||
.min(1, "Time Window must be at least 1 day"),
|
||||
token_budget: Yup.number()
|
||||
// Empty (no token budget) is allowed — a cost-only limit. Without
|
||||
// this, "" coerces to NaN and trips .min(1) even when only a cost
|
||||
// budget is set. Mirrors the cost_budget_dollars transform below.
|
||||
.transform((value, original) =>
|
||||
original === "" ? undefined : value
|
||||
)
|
||||
.min(1, "Token Budget must be at least 1")
|
||||
.test(
|
||||
"budget-required",
|
||||
"Set a token budget and/or a cost budget",
|
||||
(value, context) =>
|
||||
value != null || context.parent.cost_budget_dollars != null
|
||||
),
|
||||
.required("Enter a reset period")
|
||||
.integer("Enter a whole number of days")
|
||||
.min(1, "Use at least 1 day"),
|
||||
cost_budget_dollars: Yup.number()
|
||||
// Empty (no cost budget) is allowed; a 0 would make the gate fire
|
||||
// on the first request (cost_since >= 0 is always true).
|
||||
.transform((value, original) =>
|
||||
original === "" ? undefined : value
|
||||
)
|
||||
.moreThan(0, "Cost Budget must be greater than 0"),
|
||||
.moreThan(0, "Cost budget must be greater than 0")
|
||||
.max(
|
||||
MAX_COST_BUDGET_DOLLARS,
|
||||
`The maximum cost budget is $${new Intl.NumberFormat("en-US").format(MAX_COST_BUDGET_DOLLARS)}`
|
||||
)
|
||||
.test(
|
||||
"minimum-cents",
|
||||
"Cost budget must be at least $0.01",
|
||||
(value) => value == null || Math.round(value * 100) > 0
|
||||
)
|
||||
.when("token_budget", {
|
||||
is: (value: string | undefined) =>
|
||||
value === undefined || value === "",
|
||||
then: (schema) =>
|
||||
schema.required(
|
||||
"Enter a cost budget, a token budget, or both"
|
||||
),
|
||||
otherwise: (schema) => schema.notRequired(),
|
||||
}),
|
||||
token_budget: Yup.number()
|
||||
.transform((value, original) =>
|
||||
original === "" ? undefined : value
|
||||
)
|
||||
.notRequired()
|
||||
.integer("Enter a whole number of tokens")
|
||||
.min(1_000, "Use at least 1,000 tokens")
|
||||
.max(MAX_TOKEN_BUDGET, "The maximum token budget is 1 trillion")
|
||||
.test(
|
||||
"whole-thousands",
|
||||
"Use increments of 1,000 tokens",
|
||||
(value) => value == null || value % 1_000 === 0
|
||||
),
|
||||
target_scope: Yup.string().required(
|
||||
"Target Scope is a required field"
|
||||
),
|
||||
user_group_id: Yup.string().test(
|
||||
"user_group_id",
|
||||
"User Group is a required field",
|
||||
"Select a user group",
|
||||
(value, context) => {
|
||||
return (
|
||||
context.parent.target_scope !== "user_group" ||
|
||||
@@ -119,10 +475,10 @@ export default function CreateRateLimitModal({
|
||||
),
|
||||
})}
|
||||
onSubmit={async (values) => {
|
||||
// Empty token field → null (cost-only); the gate skips a null budget.
|
||||
// Sending 0 would mean "0-token limit" and block every request.
|
||||
const tokenBudget =
|
||||
values.token_budget === "" ? null : Number(values.token_budget);
|
||||
values.token_budget === ""
|
||||
? null
|
||||
: Number(values.token_budget) / 1_000;
|
||||
const costBudgetCents =
|
||||
values.cost_budget_dollars === ""
|
||||
? null
|
||||
@@ -132,66 +488,176 @@ export default function CreateRateLimitModal({
|
||||
Number(values.period_days) * HOURS_PER_DAY,
|
||||
tokenBudget,
|
||||
costBudgetCents,
|
||||
Number(values.user_group_id)
|
||||
values.target_scope === Scope.USER_GROUP
|
||||
? Number(values.user_group_id)
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values, setFieldValue }) => (
|
||||
<Form className="flex flex-col h-full min-h-0 overflow-visible">
|
||||
<Modal.Body>
|
||||
{!forSpecificScope && (
|
||||
<SelectorFormField
|
||||
name="target_scope"
|
||||
label="Target Scope"
|
||||
options={[
|
||||
{ name: "Global", value: Scope.GLOBAL },
|
||||
{ name: "User", value: Scope.USER },
|
||||
{ name: "User Group", value: Scope.USER_GROUP },
|
||||
]}
|
||||
includeDefault={false}
|
||||
onSelect={(selected) => {
|
||||
setFieldValue("target_scope", selected);
|
||||
if (selected === Scope.USER_GROUP) {
|
||||
setShouldFetchUserGroups(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{forSpecificUserGroup === undefined &&
|
||||
values.target_scope === Scope.USER_GROUP && (
|
||||
<SelectorFormField
|
||||
name="user_group_id"
|
||||
label="User Group"
|
||||
options={modalUserGroups}
|
||||
includeDefault={false}
|
||||
/>
|
||||
)}
|
||||
<TextFormField
|
||||
name="period_days"
|
||||
label="Time Window (UTC Days)"
|
||||
type="number"
|
||||
placeholder=""
|
||||
/>
|
||||
<TextFormField
|
||||
name="token_budget"
|
||||
label="Token Budget (Thousands, optional)"
|
||||
type="number"
|
||||
placeholder=""
|
||||
/>
|
||||
<TextFormField
|
||||
name="cost_budget_dollars"
|
||||
label="Cost Budget (USD per period, optional)"
|
||||
type="number"
|
||||
placeholder=""
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button disabled={isSubmitting} type="submit">
|
||||
Create
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
)}
|
||||
{({ isSubmitting, values, setFieldValue, errors, touched }) => {
|
||||
const selectedGroupName = modalUserGroups.find(
|
||||
(group) => String(group.value) === String(values.user_group_id)
|
||||
)?.name;
|
||||
const scopeCaption =
|
||||
values.target_scope === Scope.GLOBAL
|
||||
? "Everyone in the workspace shares one budget."
|
||||
: values.target_scope === Scope.USER
|
||||
? "Every user gets their own budget."
|
||||
: selectedGroupName
|
||||
? `Members of ${selectedGroupName} share one budget.`
|
||||
: "Members of the chosen group share one budget.";
|
||||
|
||||
return (
|
||||
<Form className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Modal.Body>
|
||||
<Section alignItems="stretch" height="auto" gap={1}>
|
||||
{!forSpecificScope && (
|
||||
<InputVertical
|
||||
title="Applies to"
|
||||
subDescription={scopeCaption}
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Applies to"
|
||||
className="grid w-full grid-cols-3 gap-1"
|
||||
>
|
||||
{SCOPE_OPTIONS.map((option) =>
|
||||
option.value === Scope.USER_GROUP &&
|
||||
forSpecificUserGroup === undefined ? (
|
||||
<GroupScopeOption
|
||||
key={option.value}
|
||||
option={option}
|
||||
selected={values.target_scope === option.value}
|
||||
groups={modalUserGroups}
|
||||
selectedGroupId={values.user_group_id}
|
||||
onSelectScope={() =>
|
||||
void setFieldValue(
|
||||
"target_scope",
|
||||
option.value
|
||||
)
|
||||
}
|
||||
onSelectGroup={(groupId) =>
|
||||
void setFieldValue("user_group_id", groupId)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ScopeOption
|
||||
key={option.value}
|
||||
option={option}
|
||||
selected={values.target_scope === option.value}
|
||||
onSelect={() =>
|
||||
void setFieldValue(
|
||||
"target_scope",
|
||||
option.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{touched.user_group_id && errors.user_group_id && (
|
||||
<InputErrorText>
|
||||
{errors.user_group_id}
|
||||
</InputErrorText>
|
||||
)}
|
||||
</InputVertical>
|
||||
)}
|
||||
|
||||
{forSpecificScope === Scope.USER_GROUP &&
|
||||
forSpecificUserGroup === undefined && (
|
||||
<InputVertical
|
||||
title="User group"
|
||||
description="Choose which group shares this budget"
|
||||
>
|
||||
<GroupPicker
|
||||
groups={modalUserGroups}
|
||||
selectedGroupId={values.user_group_id}
|
||||
onSelectGroup={(groupId) =>
|
||||
void setFieldValue("user_group_id", groupId)
|
||||
}
|
||||
/>
|
||||
{touched.user_group_id && errors.user_group_id && (
|
||||
<InputErrorText>
|
||||
{errors.user_group_id}
|
||||
</InputErrorText>
|
||||
)}
|
||||
</InputVertical>
|
||||
)}
|
||||
|
||||
<InputVertical
|
||||
withLabel="cost_budget_dollars"
|
||||
title="Cost budget"
|
||||
description="Maximum spend per reset period. Set a cost budget, a token budget, or both."
|
||||
>
|
||||
<InputTypeInField
|
||||
name="cost_budget_dollars"
|
||||
inputMode="decimal"
|
||||
prefixText="$"
|
||||
placeholder="No cost limit"
|
||||
rightChildren={
|
||||
<div className="pr-1">
|
||||
<Text
|
||||
font="secondary-action"
|
||||
color="text-03"
|
||||
nowrap
|
||||
>
|
||||
USD
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</InputVertical>
|
||||
|
||||
<InputVertical
|
||||
withLabel="token_budget"
|
||||
title="Token budget"
|
||||
description="Maximum tokens per reset period"
|
||||
>
|
||||
<TokenBudgetField />
|
||||
</InputVertical>
|
||||
|
||||
<InputVertical
|
||||
withLabel="period_days"
|
||||
title="Reset period"
|
||||
description="Budgets reset at midnight UTC"
|
||||
>
|
||||
<InputTypeInField
|
||||
name="period_days"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
rightChildren={
|
||||
<div className="pr-1">
|
||||
<Text
|
||||
font="secondary-action"
|
||||
color="text-03"
|
||||
nowrap
|
||||
>
|
||||
days
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</InputVertical>
|
||||
|
||||
<LimitSummary groupName={selectedGroupName} />
|
||||
</Section>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
disabled={isSubmitting}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isSubmitting} type="submit">
|
||||
Create limit
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
|
||||
Reference in new issue
Block a user