diff --git a/site/src/api/api.ts b/site/src/api/api.ts index f2c0a747f0..32fffce83a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1673,6 +1673,36 @@ class ApiMethods { return response.data; }; + getUserAIBudgetOverride = async ( + userId: TypesGen.User["id"], + ): Promise => { + const response = await this.axios.get( + `/api/v2/users/${encodeURIComponent(userId)}/ai/budget`, + ); + + return response.data; + }; + + upsertUserAIBudgetOverride = async ( + userId: TypesGen.User["id"], + data: TypesGen.UpsertUserAIBudgetOverrideRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${encodeURIComponent(userId)}/ai/budget`, + data, + ); + + return response.data; + }; + + deleteUserAIBudgetOverride = async ( + userId: TypesGen.User["id"], + ): Promise => { + await this.axios.delete( + `/api/v2/users/${encodeURIComponent(userId)}/ai/budget`, + ); + }; + activateUser = async ( userId: TypesGen.User["id"], ): Promise => { @@ -2152,12 +2182,15 @@ class ApiMethods { }; getGroups = async ( - options: { userId?: string } = {}, + options: { userId?: string; organization?: string } = {}, ): Promise => { const params: Record = {}; if (options.userId !== undefined) { params.has_member = options.userId; } + if (options.organization !== undefined) { + params.organization = options.organization; + } const response = await this.axios.get("/api/v2/groups", { params }); return response.data; diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 51611e6b05..e8ba81be7d 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -133,10 +133,20 @@ function selectGroupsByUserId(groups: Group[]): GroupsByUserId { return userIdMapper as GroupsByUserId; } -export function groupsForUser(userId: string) { +export const getGroupsForUserQueryKey = ( + userId: string, + organizationId?: string, +) => [ + ...groupsQueryKey, + "user", + userId, + ...(organizationId ? ["organization", organizationId] : []), +]; + +export function groupsForUser(userId: string, organizationId?: string) { return { - queryKey: groupsQueryKey, - queryFn: () => API.getGroups({ userId }), + queryKey: getGroupsForUserQueryKey(userId, organizationId), + queryFn: () => API.getGroups({ userId, organization: organizationId }), } as const satisfies UseQueryOptions; } diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index d2dd38adc1..8cffea9ad3 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -5,6 +5,7 @@ import type { UseQueryOptions, } from "react-query"; import { API } from "#/api/api"; +import { isApiError } from "#/api/errors"; import type { AuthorizationRequest, GenerateAPIKeyResponse, @@ -15,7 +16,9 @@ import type { UpdateUserPasswordRequest, UpdateUserPreferenceSettingsRequest, UpdateUserProfileRequest, + UpsertUserAIBudgetOverrideRequest, User, + UserAIBudgetOverride, UserAppearanceSettings, UserPreferenceSettings, UsersRequest, @@ -163,6 +166,60 @@ export const user = (usernameOrId: string) => { }; }; +export const getUserAIBudgetOverrideQueryKey = (userId: string) => [ + "user", + userId, + "aiBudgetOverride", +]; + +export const userAIBudgetOverride = ( + userId: string, +): UseQueryOptions => { + return { + queryKey: getUserAIBudgetOverrideQueryKey(userId), + queryFn: async () => { + try { + return await API.getUserAIBudgetOverride(userId); + } catch (error) { + if (isApiError(error) && error.response.status === 404) { + return null; + } + + throw error; + } + }, + }; +}; + +export const saveUserAIBudgetOverride = ( + queryClient: QueryClient, + userId: string, +) => { + return { + mutationFn: (request: UpsertUserAIBudgetOverrideRequest) => + API.upsertUserAIBudgetOverride(userId, request), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: getUserAIBudgetOverrideQueryKey(userId), + }); + }, + }; +}; + +export const deleteUserAIBudgetOverride = ( + queryClient: QueryClient, + userId: string, +) => { + return { + mutationFn: () => API.deleteUserAIBudgetOverride(userId), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: getUserAIBudgetOverrideQueryKey(userId), + }); + }, + }; +}; + export function apiKey(): UseQueryOptions { return { queryKey: [...meKey, "apiKey"], diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index 00273344f5..be2487e38a 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -39,9 +39,12 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; +import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { isEveryoneGroup } from "#/modules/groups"; import { cn } from "#/utils/cn"; import type { GroupPageOutletContext } from "./GroupPage"; +import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog"; const GroupMembersPage: FC = () => { const { @@ -58,6 +61,14 @@ const GroupMembersPage: FC = () => { removeMember(queryClient, organization), ); const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; + const [budgetUser, setBudgetUser] = useState(null); + + const { experiments } = useDashboard(); + // TODO(AIGOV-443): remove the ai-gateway-cost-control experiment gate once + // the cost-control feature is stable. + const aibridgeVisible = + Boolean(useFeatureVisibility().aibridge) && + experiments.includes("ai-gateway-cost-control"); return (
@@ -78,7 +89,7 @@ const GroupMembersPage: FC = () => {
- +
User @@ -101,6 +112,8 @@ const GroupMembersPage: FC = () => { group={groupData} key={member.id} canUpdate={canUpdateGroup} + aiBudgetVisible={aibridgeVisible} + onManageAIBudget={() => setBudgetUser(member)} onRemove={async () => { const mutation = removeMemberMutation.mutateAsync({ groupId: groupData.id, @@ -121,6 +134,21 @@ const GroupMembersPage: FC = () => {
+ + {aibridgeVisible && budgetUser && ( + { + if (!open) { + setBudgetUser(null); + } + }} + user={budgetUser} + // TODO(#26401): pass the member's effective group, not the page's + // group, once the effective-group API exists. + currentGroup={groupData} + /> + )} ); }; @@ -221,6 +249,8 @@ interface GroupMemberRowProps { member: ReducedUser; group: Group; canUpdate: boolean; + aiBudgetVisible: boolean; + onManageAIBudget: () => void; onRemove: () => void; } @@ -228,6 +258,8 @@ const GroupMemberRow: FC = ({ member, group, canUpdate, + aiBudgetVisible, + onManageAIBudget, onRemove, }) => { return ( @@ -267,6 +299,11 @@ const GroupMemberRow: FC = ({ + {aiBudgetVisible && ( + + AI Budget + + )} = { title: "pages/OrganizationGroupsPage/GroupPage", component: GroupPage, + decorators: [withDashboardProvider], parameters: { reactRouter: reactRouterParameters({ location: { @@ -224,3 +231,55 @@ export const FiltersByMembers: Story = { ); }, }; + +const mockOwnerOverride: UserAIBudgetOverride = { + user_id: MockUserOwner.id, + group_id: MockGroup.id, + spend_limit_micros: 12_000_000_000, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +export const OpenAIBudgetFromMemberMenu: Story = { + parameters: { + features: ["aibridge"], + experiments: ["ai-gateway-cost-control"], + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ + users: MockGroup.members, + count: MockGroup.members.length, + }), + permissionsQuery({ canUpdateGroup: true }), + { + key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), + data: mockOwnerOverride, + }, + { + key: getGroupsForUserQueryKey( + MockUserOwner.id, + MockGroupWithoutMembers.organization_id, + ), + data: [MockGroup], + }, + { + key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, + data: null, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click( + canvas.getAllByRole("button", { name: "Open menu" })[0], + ); + await userEvent.click( + await body.findByRole("menuitem", { name: "AI Budget" }), + ); + await expect( + await body.findByText("Custom monthly budget"), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx index 964a3690ef..46982ddca8 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx @@ -9,20 +9,13 @@ import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; import { Spinner } from "#/components/Spinner/Spinner"; import { isEveryoneGroup } from "#/modules/groups"; +import { usdBudgetFormatter } from "#/utils/currency"; import { getFormHelpers, nameValidator, onChangeTrimmed, } from "#/utils/formUtils"; -// Drops the cents when the amount is a whole dollar (the common case). -const usdMaximumFormatter = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 0, - maximumFractionDigits: 2, -}); - type FormData = { name: string; display_name: string; @@ -86,7 +79,7 @@ const UpdateGroupForm: FC = ({ const budgetField = getFieldHelpers("monthly_budget_per_member"); const budgetDollars = form.values.monthly_budget_per_member; const memberCount = group.total_member_count; - const monthlyMaximum = usdMaximumFormatter.format( + const monthlyMaximum = usdBudgetFormatter.format( Number(budgetDollars) * memberCount, ); diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx new file mode 100644 index 0000000000..1b002f6556 --- /dev/null +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx @@ -0,0 +1,259 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, within } from "storybook/test"; +import { API } from "#/api/api"; +import { groupAIBudget, groupsForUser } from "#/api/queries/groups"; +import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users"; +import type { GroupAIBudget, UserAIBudgetOverride } from "#/api/typesGenerated"; +import { MockGroup, MockGroup2, MockUserMember } from "#/testHelpers/entities"; +import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog"; + +const mockOverride: UserAIBudgetOverride = { + user_id: MockUserMember.id, + group_id: MockGroup2.id, + spend_limit_micros: 12_000_000_000, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const mockGroupBudget: GroupAIBudget = { + group_id: MockGroup.id, + spend_limit_micros: 5_000_000_000, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const groupQueries = [ + { + key: groupsForUser(MockUserMember.id, MockGroup.organization_id).queryKey, + data: [MockGroup, MockGroup2], + }, + { key: groupAIBudget(MockGroup.id).queryKey, data: mockGroupBudget }, +]; + +const meta: Meta = { + title: "pages/OrganizationGroupsPage/UserAIBudgetOverrideDialog", + component: UserAIBudgetOverrideDialog, + args: { + open: true, + onOpenChange: () => undefined, + user: MockUserMember, + currentGroup: MockGroup, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithOverride: Story = { + parameters: { + queries: [ + { + key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), + data: mockOverride, + }, + ...groupQueries, + ], + }, + play: async () => { + const body = within(document.body); + await expect(await body.findByText("AI Budget")).toBeInTheDocument(); + await expect(body.getByText("$12,000 USD")).toBeInTheDocument(); + await expect(body.getByText(/charged to/)).toBeInTheDocument(); + await expect(body.getByRole("checkbox")).toBeChecked(); + await expect(body.getByLabelText("Custom monthly budget")).toHaveValue( + 12000, + ); + }, +}; + +export const WithoutOverride: Story = { + parameters: { + queries: [ + { key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), data: null }, + ...groupQueries, + ], + }, + play: async () => { + const body = within(document.body); + await expect(await body.findByText("$5,000 USD")).toBeInTheDocument(); + await expect(body.getByText(/charged to/)).toBeInTheDocument(); + await expect(body.getByRole("checkbox")).not.toBeChecked(); + await expect( + body.queryByLabelText("Custom monthly budget"), + ).not.toBeInTheDocument(); + await expect( + body.queryByRole("button", { name: "Update" }), + ).not.toBeInTheDocument(); + }, +}; + +export const Uncapped: Story = { + parameters: { + queries: [ + { key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), data: null }, + { + key: groupsForUser(MockUserMember.id, MockGroup.organization_id) + .queryKey, + data: [MockGroup, MockGroup2], + }, + { key: groupAIBudget(MockGroup.id).queryKey, data: null }, + ], + }, + play: async ({ step }) => { + const body = within(document.body); + await expect(await body.findByText("uncapped")).toBeInTheDocument(); + await expect(body.getByRole("checkbox")).not.toBeChecked(); + + await step( + "enabling the override starts empty, with no $0 warning", + async () => { + await userEvent.click(body.getByRole("checkbox")); + await expect(body.getByLabelText("Custom monthly budget")).toHaveValue( + null, + ); + await expect( + body.queryByText("A $0 limit disables AI access for this member."), + ).not.toBeInTheDocument(); + }, + ); + }, +}; + +export const ZeroBudgetDisablesAI: Story = { + parameters: { + queries: [ + { + key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), + data: { ...mockOverride, spend_limit_micros: 0 }, + }, + ...groupQueries, + ], + }, + play: async () => { + const body = within(document.body); + await expect(body.getByLabelText("Custom monthly budget")).toHaveValue(0); + await expect( + await body.findByText("A $0 limit disables AI access for this member."), + ).toBeInTheDocument(); + }, +}; + +export const SelectAssignedGroup: Story = { + parameters: { + queries: [ + { + key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), + data: mockOverride, + }, + ...groupQueries, + ], + }, + play: async ({ step }) => { + const body = within(document.body); + + await step("open the group combobox", async () => { + await userEvent.click( + body.getByRole("button", { name: "Budget assigned to" }), + ); + }); + + await step("search filters the group list", async () => { + await userEvent.type( + await body.findByPlaceholderText("Search..."), + "front", + ); + await expect( + await body.findByRole("option", { name: /Front-End \(default\)/ }), + ).toBeInTheDocument(); + await expect( + body.queryByRole("option", { name: /developer/ }), + ).not.toBeInTheDocument(); + }); + + await step("selecting a group updates the trigger", async () => { + await userEvent.click( + body.getByRole("option", { name: /Front-End \(default\)/ }), + ); + await expect( + await body.findByText("Front-End (default)"), + ).toBeInTheDocument(); + }); + }, +}; + +// Clearing the budget on an enabled override blocks submit: the admin must +// enter a value or uncheck the override (to remove it) before saving. +export const SubmitRequiresValueOrUncheck: Story = { + parameters: { + queries: [ + { + key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), + data: mockOverride, + }, + ...groupQueries, + ], + }, + play: async ({ step }) => { + const body = within(document.body); + const budgetInput = await body.findByLabelText("Custom monthly budget"); + const updateButton = body.getByRole("button", { name: "Update" }); + + await step("an existing override seeds a submittable value", async () => { + await expect(updateButton).toBeEnabled(); + }); + + await step("clearing the budget blocks submit", async () => { + await userEvent.clear(budgetInput); + await expect( + await body.findByText("Enter a monthly budget of 0 or more."), + ).toBeInTheDocument(); + await expect(updateButton).toBeDisabled(); + }); + + await step("entering a value unblocks submit", async () => { + await userEvent.type(budgetInput, "25"); + await expect(updateButton).toBeEnabled(); + }); + + await step( + "unchecking unblocks submit to remove the override", + async () => { + await userEvent.clear(budgetInput); + await expect(updateButton).toBeDisabled(); + await userEvent.click(body.getByRole("checkbox")); + await expect(updateButton).toBeEnabled(); + }, + ); + }, +}; + +export const Loading: Story = { + beforeEach: () => { + spyOn(API, "getUserAIBudgetOverride").mockReturnValue( + // Stay pending so the loading state renders. + new Promise(() => {}), + ); + }, + parameters: { queries: groupQueries }, + play: async () => { + const body = within(document.body); + await expect( + await body.findByText("Loading AI budget..."), + ).toBeInTheDocument(); + }, +}; + +export const LoadError: Story = { + beforeEach: () => { + spyOn(API, "getUserAIBudgetOverride").mockRejectedValue( + new Error("test budget error"), + ); + }, + parameters: { queries: groupQueries }, + play: async () => { + const body = within(document.body); + await expect( + await body.findByText("test budget error"), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx new file mode 100644 index 0000000000..87a3fa636b --- /dev/null +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx @@ -0,0 +1,419 @@ +import { + type FC, + type ReactNode, + type SyntheticEvent, + useId, + useMemo, + useState, +} from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; +import { getErrorDetail } from "#/api/errors"; +import { groupAIBudget, groupsForUser } from "#/api/queries/groups"; +import { + deleteUserAIBudgetOverride, + saveUserAIBudgetOverride, + userAIBudgetOverride, +} from "#/api/queries/users"; +import type { + Group, + GroupAIBudget, + ReducedUser, + UpsertUserAIBudgetOverrideRequest, + UserAIBudgetOverride, +} from "#/api/typesGenerated"; +import { Alert } from "#/components/Alert/Alert"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Button } from "#/components/Button/Button"; +import { Checkbox } from "#/components/Checkbox/Checkbox"; +import { + Combobox, + ComboboxButton, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "#/components/Combobox/Combobox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "#/components/InputGroup/InputGroup"; +import { Label } from "#/components/Label/Label"; +import { Separator } from "#/components/Separator/Separator"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { cn } from "#/utils/cn"; +import { + dollarsToMicros, + microsToDollars, + usdBudgetFormatter, +} from "#/utils/currency"; + +interface UserAIBudgetOverrideDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + user: ReducedUser; + currentGroup: Group; +} + +export const UserAIBudgetOverrideDialog: FC< + UserAIBudgetOverrideDialogProps +> = ({ open, onOpenChange, user, currentGroup }) => { + const queryClient = useQueryClient(); + const budgetOverrideQuery = useQuery({ + ...userAIBudgetOverride(user.id), + enabled: open, + }); + const userGroupsQuery = useQuery({ + ...groupsForUser(user.id, currentGroup.organization_id), + enabled: open, + }); + const groupBudgetQuery = useQuery({ + ...groupAIBudget(currentGroup.id), + enabled: open, + }); + const saveMutation = useMutation( + saveUserAIBudgetOverride(queryClient, user.id), + ); + const deleteMutation = useMutation( + deleteUserAIBudgetOverride(queryClient, user.id), + ); + + const loadError = + budgetOverrideQuery.error ?? + userGroupsQuery.error ?? + groupBudgetQuery.error; + const isLoading = + budgetOverrideQuery.isLoading || + userGroupsQuery.isLoading || + groupBudgetQuery.isLoading; + const isSubmitting = saveMutation.isPending || deleteMutation.isPending; + + return ( + { + // Don't close while a mutation is in flight. + if (!isSubmitting) { + onOpenChange(nextOpen); + } + }} + > + +
+ + AI Budget + + + } + title={user.username} + subtitle={user.is_service_account ? "Service Account" : user.email} + /> +
+ + {loadError ? ( + + ) : isLoading ? ( +
+ + Loading AI budget... +
+ ) : ( + onOpenChange(false)} + /> + )} +
+
+ ); +}; + +interface OverrideFormProps { + user: ReducedUser; + currentGroup: Group; + override: UserAIBudgetOverride | null; + groupBudget: GroupAIBudget | null; + userGroups: readonly Group[]; + isSubmitting: boolean; + onSave: (request: UpsertUserAIBudgetOverrideRequest) => Promise; + onRemove: () => Promise; + onClose: () => void; +} + +/** Mounted only after budget data loads, so state seeds from it without a sync effect. */ +const OverrideForm: FC = ({ + user, + currentGroup, + override, + groupBudget, + userGroups, + isSubmitting, + onSave, + onRemove, + onClose, +}) => { + const budgetId = useId(); + const groupId = useId(); + const overrideId = useId(); + + const [overrideEnabled, setOverrideEnabled] = useState(override !== null); + // Seed from the override, else the group budget. Neither (uncapped) seeds + // empty, so enabling the override prompts for a value. + const [budgetDollars, setBudgetDollars] = useState(() => { + const seedMicros = (override ?? groupBudget)?.spend_limit_micros; + return seedMicros === undefined ? "" : String(microsToDollars(seedMicros)); + }); + const [selectedGroupId, setSelectedGroupId] = useState( + override?.group_id ?? currentGroup.id, + ); + + // The current group may also be in the user's groups; dedupe by id. + const groupOptions = useMemo(() => { + const byId = new Map([[currentGroup.id, currentGroup]]); + for (const group of userGroups) { + byId.set(group.id, group); + } + return [...byId.values()].sort((left, right) => + groupDisplayName(left).localeCompare(groupDisplayName(right)), + ); + }, [currentGroup, userGroups]); + + const selectedGroup = groupOptions.find((g) => g.id === selectedGroupId); + const overrideGroup = groupOptions.find((g) => g.id === override?.group_id); + + // A "0" budget is valid and disables AI; empty or negative is not. + const budgetAmount = Number(budgetDollars); + const budgetValid = budgetDollars.trim() !== "" && budgetAmount >= 0; + const budgetInvalid = overrideEnabled && !budgetValid; + const budgetDisablesAI = budgetValid && budgetAmount === 0; + // Footer shows only when there's something to save or remove. + const showFooter = overrideEnabled || override !== null; + // Submittable with a valid amount to write, or an existing override to remove. + const canSubmit = + !isSubmitting && (overrideEnabled ? budgetValid : override !== null); + + const groupLabel = (group: Group) => + group.id === currentGroup.id + ? `${groupDisplayName(group)} (default)` + : groupDisplayName(group); + + const handleSubmit = async (event: SyntheticEvent) => { + event.preventDefault(); + if (!canSubmit) { + return; + } + + const removing = !overrideEnabled; + const mutation = removing + ? onRemove() + : onSave({ + group_id: selectedGroupId, + spend_limit_micros: dollarsToMicros(budgetDollars), + }); + + toast.promise(mutation, { + loading: `${removing ? "Removing" : "Updating"} AI budget override for "${user.username}"...`, + success: `AI budget override for "${user.username}" ${removing ? "removed" : "updated"} successfully.`, + error: (error) => ({ + message: `Failed to ${removing ? "remove" : "update"} AI budget override for "${user.username}".`, + description: getErrorDetail(error), + }), + }); + try { + await mutation; + onClose(); + } catch { + // The toast surfaces the error details. + } + }; + + return ( +
+

+ {override ? ( + <> + {user.username}'s custom monthly limit is{" "} + {formatUSD(override.spend_limit_micros)}, charged to{" "} + + {overrideGroup ? groupDisplayName(overrideGroup) : "their group"} + {" "} + group. + + ) : ( + <> + {user.username}'s monthly limit is{" "} + + {groupBudget + ? formatUSD(groupBudget.spend_limit_micros) + : "uncapped"} + + , charged to {groupDisplayName(currentGroup)} group. + + )} +

+ + + + + + {overrideEnabled && ( + <> +
+ + + setBudgetDollars(event.target.value)} + type="number" + min="0" + step="1" + aria-invalid={budgetInvalid} + aria-describedby={ + budgetInvalid ? `${budgetId}-error` : undefined + } + /> + USD + + {budgetInvalid && ( +

+ Enter a monthly budget of 0 or more. +

+ )} +
+ + {budgetDisablesAI && ( + + A $0 limit disables AI access for this member. + + )} + +
+ + { + // Ignore clearing; a group assignment is always required. + if (value) { + setSelectedGroupId(value); + } + }} + > + + + ), + } + } + placeholder="Select a group" + /> + + + + + {groupOptions.map((group) => ( + + + + {groupLabel(group)} + + + ))} + + No groups found + + +
+ + )} + + {showFooter && ( + + + + + )} + + ); +}; + +const Bold: FC<{ children: ReactNode }> = ({ children }) => ( + {children} +); + +const groupDisplayName = (group: Group): string => + group.display_name || group.name; + +const formatUSD = (micros: number): string => + `${usdBudgetFormatter.format(microsToDollars(micros))} USD`; diff --git a/site/src/utils/currency.ts b/site/src/utils/currency.ts index d52def94d1..c527830b41 100644 --- a/site/src/utils/currency.ts +++ b/site/src/utils/currency.ts @@ -16,6 +16,14 @@ const usdSubCentCurrencyFormatter = new Intl.NumberFormat("en-US", { signDisplay: "auto", }); +/** Drops the cents when the amount is a whole dollar, used for budget displays. */ +export const usdBudgetFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 2, +}); + export function microsToDollars(micros: number): number { return micros / MICROS_PER_DOLLAR; }