From 3b8929be84363a5ad3e26a3ab5e804e825c71f60 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Wed, 29 Jul 2026 18:03:59 +0000 Subject: [PATCH 1/5] fix: hide AI budget override controls without permission --- .../src/pages/GroupsPage/GroupMembersPage.tsx | 6 + .../pages/GroupsPage/GroupPage.stories.tsx | 53 +++++++- .../UserAIBudgetOverrideDialog.stories.tsx | 36 ++++++ .../GroupsPage/UserAIBudgetOverrideDialog.tsx | 118 ++++++++++++++---- 4 files changed, 189 insertions(+), 24 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index ff4ee823f29..953a87c7cd6 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -47,6 +47,7 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { isEveryoneGroup } from "#/modules/groups"; import { cn } from "#/utils/cn"; @@ -77,7 +78,11 @@ const GroupMembersPage: FC = () => { const removeMemberMutation = useMutation( removeMember(queryClient, organization), ); + const { permissions: sitePermissions } = useAuthenticated(); const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; + // Setting a user's AI budget override updates both the user and the group + // its spend is charged to, so it needs permission on both. + const canUpdateBudgetOverride = canUpdateGroup && sitePermissions.updateUsers; const [budgetUser, setBudgetUser] = useState(null); const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); @@ -232,6 +237,7 @@ const GroupMembersPage: FC = () => { user={budgetUser} currentGroup={groupData} effectiveGroupId={budgetUser.spend?.effective_group_id} + canUpdate={canUpdateBudgetOverride} /> )} diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 79f0f1956b2..7ed3ad8c0ef 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -37,15 +37,20 @@ import { MockUserMember, MockUserOwner, } from "#/testHelpers/entities"; -import { withDashboardProvider } from "#/testHelpers/storybook"; +import { + withAuthProvider, + withDashboardProvider, +} from "#/testHelpers/storybook"; import GroupMembersPage from "./GroupMembersPage"; import GroupPage from "./GroupPage"; const meta: Meta = { title: "pages/OrganizationGroupsPage/GroupPage", component: GroupPage, - decorators: [withDashboardProvider], + decorators: [withDashboardProvider, withAuthProvider], parameters: { + user: MockUserOwner, + permissions: { updateUsers: true }, reactRouter: reactRouterParameters({ location: { pathParams: { @@ -674,6 +679,50 @@ export const OpenAIBudgetForCurrentGroupMember: Story = { }, }; +/** Group admins can read a member's budget without the site user permission. */ +export const AIBudgetReadOnlyWithoutUserPermission: Story = { + parameters: { + features: ["aibridge"], + permissions: { updateUsers: false }, + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ + users: [MockUserOwner], + count: 1, + }), + membersSpendQuery([{ ...mockSpend, user_id: MockUserOwner.id }]), + permissionsQuery({ canUpdateGroup: true }), + { key: meAISpendKey, data: mockUserAISpend }, + { key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null }, + { + key: getGroupsForUserQueryKey( + MockUserOwner.id, + MockGroupWithoutMembers.organization_id, + ), + data: [MockGroup2], + }, + { 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: "Manage AI budget" }), + ); + await expect( + await body.findByText( + /To update this limit, contact your deployment administrator\./, + ), + ).toBeInTheDocument(); + await expect(body.queryByRole("checkbox")).not.toBeInTheDocument(); + }, +}; + /** Unresolvable via getGroupById, standing in for another org's group. */ const unresolvedGroupId = "external-org-group"; diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index 3953bf5550d..bcffb96ef61 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx @@ -39,6 +39,7 @@ const meta: Meta = { onOpenChange: () => undefined, user: MockUserMember, currentGroup: MockGroup, + canUpdate: true, }, }; @@ -88,6 +89,41 @@ export const WithoutOverride: Story = { }, }; +/** An existing override still hides the controls, so it can't be removed. */ +export const ReadOnlyWithoutPermission: Story = { + args: { canUpdate: false }, + parameters: { + queries: [ + { + key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), + data: mockOverride, + }, + ...groupQueries, + ], + }, + play: async () => { + const body = within(document.body); + await expect(await body.findByText("$12,000 USD")).toBeInTheDocument(); + await expect( + body.getByText( + /To update this limit, contact your deployment administrator\./, + ), + ).toBeInTheDocument(); + await expect(body.queryByRole("checkbox")).not.toBeInTheDocument(); + await expect( + body.queryByLabelText("Custom monthly budget"), + ).not.toBeInTheDocument(); + await expect( + body.queryByRole("button", { name: "Budget assigned to" }), + ).not.toBeInTheDocument(); + await expect( + body.queryByRole("button", { name: "Update" }), + ).not.toBeInTheDocument(); + // Nothing to submit or cancel, so the dialog has no action buttons. + await expect(body.queryAllByRole("button")).toHaveLength(0); + }, +}; + export const Uncapped: Story = { parameters: { queries: [ diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx index 9b7a292e685..3b139e62da6 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx @@ -66,11 +66,20 @@ interface UserAIBudgetOverrideDialogProps { user: ReducedUser; currentGroup: Group; effectiveGroupId?: string | null; + // When false, the budget is shown without the controls to change it. + canUpdate: boolean; } export const UserAIBudgetOverrideDialog: FC< UserAIBudgetOverrideDialogProps -> = ({ open, onOpenChange, user, currentGroup, effectiveGroupId }) => { +> = ({ + open, + onOpenChange, + user, + currentGroup, + effectiveGroupId, + canUpdate, +}) => { const queryClient = useQueryClient(); const budgetOverrideQuery = useQuery({ ...userAIBudgetOverride(user.id), @@ -135,7 +144,7 @@ export const UserAIBudgetOverrideDialog: FC< Loading AI budget... - ) : ( + ) : canUpdate ? ( onOpenChange(false)} /> + ) : ( + )} ); }; +interface BudgetSummaryProps { + user: ReducedUser; + currentGroup: Group; + override: UserAIBudgetOverride | null; + // The group the override charges spend to, when it can be resolved. + overrideGroup: Group | undefined; + groupBudget: GroupAIBudget | null; +} + +/** The member's effective limit as a sentence, to place inside a paragraph. */ +const BudgetSummary: FC = ({ + user, + currentGroup, + override, + overrideGroup, + groupBudget, +}) => + 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. + + ); + +interface ReadOnlyBudgetProps { + user: ReducedUser; + currentGroup: Group; + override: UserAIBudgetOverride | null; + groupBudget: GroupAIBudget | null; + userGroups: readonly Group[]; +} + +/** + * The budget without any editing controls. Setting an override requires + * updating both the user and the group it charges, so group admins can read a + * member's budget without being able to change it. + */ +const ReadOnlyBudget: FC = ({ + user, + currentGroup, + override, + groupBudget, + userGroups, +}) => ( +

+ group.id === override?.group_id, + )} + groupBudget={groupBudget} + />{" "} + To update this limit, contact your deployment administrator. +

+); + interface OverrideFormProps { user: ReducedUser; currentGroup: Group; @@ -266,26 +353,13 @@ const OverrideForm: FC = ({ 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. - - )} +

From 2a8eceecc06b67bdfae313082fe9b1f47e66b939 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Thu, 30 Jul 2026 09:23:33 +0000 Subject: [PATCH 2/5] chore: address comments --- .../pages/GroupsPage/GroupPage.stories.tsx | 2 +- .../UserAIBudgetOverrideDialog.stories.tsx | 33 +++- .../GroupsPage/UserAIBudgetOverrideDialog.tsx | 164 +++++++++--------- 3 files changed, 105 insertions(+), 94 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 7ed3ad8c0ef..2a42dd04b16 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -716,7 +716,7 @@ export const AIBudgetReadOnlyWithoutUserPermission: Story = { ); await expect( await body.findByText( - /To update this limit, contact your deployment administrator\./, + /To update this limit, contact a Coder administrator\./, ), ).toBeInTheDocument(); await expect(body.queryByRole("checkbox")).not.toBeInTheDocument(); diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index bcffb96ef61..a8efd3a8076 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx @@ -105,9 +105,7 @@ export const ReadOnlyWithoutPermission: Story = { const body = within(document.body); await expect(await body.findByText("$12,000 USD")).toBeInTheDocument(); await expect( - body.getByText( - /To update this limit, contact your deployment administrator\./, - ), + body.getByText(/To update this limit, contact a Coder administrator\./), ).toBeInTheDocument(); await expect(body.queryByRole("checkbox")).not.toBeInTheDocument(); await expect( @@ -116,11 +114,30 @@ export const ReadOnlyWithoutPermission: Story = { await expect( body.queryByRole("button", { name: "Budget assigned to" }), ).not.toBeInTheDocument(); - await expect( - body.queryByRole("button", { name: "Update" }), - ).not.toBeInTheDocument(); - // Nothing to submit or cancel, so the dialog has no action buttons. - await expect(body.queryAllByRole("button")).toHaveLength(0); + for (const name of ["Update", "Cancel", "Close"]) { + await expect( + body.queryByRole("button", { name }), + ).not.toBeInTheDocument(); + } + }, +}; + +/** The assigned group is in another organization, so it can't be named. */ +export const OverrideWithUnresolvableGroup: Story = { + parameters: { + queries: [ + { + key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), + data: { ...mockOverride, group_id: "another-org-group" }, + }, + ...groupQueries, + ], + }, + play: async () => { + const body = within(document.body); + const summary = await body.findByText(/charged to/); + await expect(summary).toHaveTextContent("charged to their group."); + await expect(summary).not.toHaveTextContent("group group"); }, }; diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx index 3b139e62da6..9e3b32ba4ab 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx @@ -109,6 +109,40 @@ export const UserAIBudgetOverrideDialog: FC< userGroupsQuery.isLoading || groupBudgetQuery.isLoading; const isSubmitting = saveMutation.isPending || deleteMutation.isPending; + const budget: BudgetProps = { + user, + currentGroup, + override: budgetOverrideQuery.data ?? null, + groupBudget: groupBudgetQuery.data ?? null, + userGroups: userGroupsQuery.data ?? [], + }; + + let body: ReactNode; + if (loadError) { + body = ; + } else if (isLoading) { + body = ( +
+ + Loading AI budget... +
+ ); + } else if (canUpdate) { + body = ( + onOpenChange(false)} + /> + ); + } else { + body = ; + } return ( - {loadError ? ( - - ) : isLoading ? ( -
- - Loading AI budget... -
- ) : canUpdate ? ( - onOpenChange(false)} - /> - ) : ( - - )} + {body}
); }; -interface BudgetSummaryProps { +interface BudgetProps { user: ReducedUser; currentGroup: Group; override: UserAIBudgetOverride | null; - // The group the override charges spend to, when it can be resolved. - overrideGroup: Group | undefined; groupBudget: GroupAIBudget | null; + userGroups: readonly Group[]; } /** The member's effective limit as a sentence, to place inside a paragraph. */ -const BudgetSummary: FC = ({ +const BudgetSummary: FC = ({ user, currentGroup, override, - overrideGroup, groupBudget, -}) => - override ? ( + userGroups, +}) => { + if (!override) { + return ( + <> + {user.username}'s monthly limit is{" "} + + {groupBudget ? formatUSD(groupBudget.spend_limit_micros) : "uncapped"} + + , charged to {groupDisplayName(currentGroup)} group. + + ); + } + + const overrideGroup = findGroup(currentGroup, userGroups, override.group_id); + return ( <> {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. + {overrideGroup ? ( + <> + {groupDisplayName(overrideGroup)} group. + + ) : ( + // The group is unresolvable here, so it can't be named. + their group. + )} ); - -interface ReadOnlyBudgetProps { - user: ReducedUser; - currentGroup: Group; - override: UserAIBudgetOverride | null; - groupBudget: GroupAIBudget | null; - userGroups: readonly Group[]; -} +}; /** * The budget without any editing controls. Setting an override requires * updating both the user and the group it charges, so group admins can read a * member's budget without being able to change it. */ -const ReadOnlyBudget: FC = ({ - user, - currentGroup, - override, - groupBudget, - userGroups, -}) => ( +const ReadOnlyBudget: FC = (props) => (

- group.id === override?.group_id, - )} - groupBudget={groupBudget} - />{" "} - To update this limit, contact your deployment administrator. + To update this limit, contact a Coder + administrator.

); -interface OverrideFormProps { - user: ReducedUser; - currentGroup: Group; +interface OverrideFormProps extends BudgetProps { // Group marked "(default)" in the picker; null marks none. defaultGroupId: string | null; - override: UserAIBudgetOverride | null; - groupBudget: GroupAIBudget | null; - userGroups: readonly Group[]; isSubmitting: boolean; onSave: (request: UpsertUserAIBudgetOverrideRequest) => Promise; onRemove: () => Promise; @@ -299,7 +283,6 @@ const OverrideForm: FC = ({ }, [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, negative, or above the // configurable maximum is not. @@ -357,8 +340,8 @@ const OverrideForm: FC = ({ user={user} currentGroup={currentGroup} override={override} - overrideGroup={overrideGroup} groupBudget={groupBudget} + userGroups={userGroups} />

@@ -503,4 +486,15 @@ const Bold: FC<{ children: ReactNode }> = ({ children }) => ( const groupDisplayName = (group: Group): string => group.display_name || group.name; +/** + * Finds a group among the ones this dialog knows about. Groups in another + * organization aren't fetchable here, so they resolve to undefined. + */ +const findGroup = ( + currentGroup: Group, + userGroups: readonly Group[], + groupID: string, +): Group | undefined => + [currentGroup, ...userGroups].find((group) => group.id === groupID); + const formatUSD = (micros: number): string => `${formatBudgetUSD(micros)} USD`; From 63e9058a232cc0384a1765c34489b51bfd7dcc7a Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Thu, 30 Jul 2026 11:17:46 +0000 Subject: [PATCH 3/5] fix: name the correct effective group in the AI budget dialog --- .../UserAIBudgetOverrideDialog.stories.tsx | 37 +++++++++++- .../GroupsPage/UserAIBudgetOverrideDialog.tsx | 56 ++++++++++++------- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index a8efd3a8076..9ab17ad9539 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx @@ -5,7 +5,12 @@ import { groupAIBudget, groupsForUser } from "#/api/queries/groups"; import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users"; import type { GroupAIBudget, UserAIBudgetOverride } from "#/api/typesGenerated"; import { maxAIBudgetDollars } from "#/modules/groups"; -import { MockGroup, MockGroup2, MockUserMember } from "#/testHelpers/entities"; +import { + MockEveryoneGroup, + MockGroup, + MockGroup2, + MockUserMember, +} from "#/testHelpers/entities"; import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog"; const mockOverride: UserAIBudgetOverride = { @@ -122,6 +127,36 @@ export const ReadOnlyWithoutPermission: Story = { }, }; +/** + * No group the member belongs to sets a budget, so the backend attributes their + * spend to the Everyone group rather than the group being viewed. + */ +export const UnbudgetedGroupFallsBackToEveryone: Story = { + args: { effectiveGroupId: MockEveryoneGroup.id }, + parameters: { + queries: [ + { key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), data: null }, + { + key: groupsForUser(MockUserMember.id, MockGroup.organization_id) + .queryKey, + data: [MockGroup, MockEveryoneGroup], + }, + { key: groupAIBudget(MockGroup.id).queryKey, data: null }, + ], + }, + play: async () => { + const body = within(document.body); + const summary = await body.findByText(/charged to/); + await expect(summary).toHaveTextContent( + "uncapped, charged to Everyone group.", + ); + // The group being viewed must not be named as the charging group. + await expect(summary).not.toHaveTextContent( + MockGroup.display_name || MockGroup.name, + ); + }, +}; + /** The assigned group is in another organization, so it can't be named. */ export const OverrideWithUnresolvableGroup: Story = { parameters: { diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx index 9e3b32ba4ab..320a1b8ea19 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx @@ -115,6 +115,7 @@ export const UserAIBudgetOverrideDialog: FC< override: budgetOverrideQuery.data ?? null, groupBudget: groupBudgetQuery.data ?? null, userGroups: userGroupsQuery.data ?? [], + effectiveGroupId, }; let body: ReactNode; @@ -131,9 +132,6 @@ export const UserAIBudgetOverrideDialog: FC< body = ( = ({ override, groupBudget, userGroups, + effectiveGroupId, }) => { if (!override) { + // The governing group is not necessarily the group being viewed. + const effectiveGroup = + effectiveGroupId === undefined + ? currentGroup + : findGroup(currentGroup, userGroups, effectiveGroupId); return ( <> {user.username}'s monthly limit is{" "} {groupBudget ? formatUSD(groupBudget.spend_limit_micros) : "uncapped"} - , charged to {groupDisplayName(currentGroup)} group. + , ); } - const overrideGroup = findGroup(currentGroup, userGroups, override.group_id); return ( <> {user.username}'s custom monthly limit is{" "} - {formatUSD(override.spend_limit_micros)}, charged to{" "} - {overrideGroup ? ( - <> - {groupDisplayName(overrideGroup)} group. - - ) : ( - // The group is unresolvable here, so it can't be named. - their group. - )} + {formatUSD(override.spend_limit_micros)},{" "} + ); }; +const ChargedTo: FC<{ group: Group | undefined }> = ({ group }) => + group ? ( + <> + charged to {groupDisplayName(group)} group. + + ) : ( + // The group is unresolvable here, so it can't be named. + <> + charged to their group. + + ); + /** * The budget without any editing controls. Setting an override requires * updating both the user and the group it charges, so group admins can read a @@ -235,8 +249,6 @@ const ReadOnlyBudget: FC = (props) => ( ); interface OverrideFormProps extends BudgetProps { - // Group marked "(default)" in the picker; null marks none. - defaultGroupId: string | null; isSubmitting: boolean; onSave: (request: UpsertUserAIBudgetOverrideRequest) => Promise; onRemove: () => Promise; @@ -247,15 +259,18 @@ interface OverrideFormProps extends BudgetProps { const OverrideForm: FC = ({ user, currentGroup, - defaultGroupId, override, groupBudget, userGroups, + effectiveGroupId, isSubmitting, onSave, onRemove, onClose, }) => { + // Group marked "(default)" in the picker; null marks none. + const defaultGroupId = + effectiveGroupId === undefined ? currentGroup.id : effectiveGroupId; const budgetId = useId(); const groupId = useId(); const overrideId = useId(); @@ -342,6 +357,7 @@ const OverrideForm: FC = ({ override={override} groupBudget={groupBudget} userGroups={userGroups} + effectiveGroupId={effectiveGroupId} />

@@ -493,8 +509,10 @@ const groupDisplayName = (group: Group): string => const findGroup = ( currentGroup: Group, userGroups: readonly Group[], - groupID: string, + groupID: string | null, ): Group | undefined => - [currentGroup, ...userGroups].find((group) => group.id === groupID); + groupID === null + ? undefined + : [currentGroup, ...userGroups].find((group) => group.id === groupID); const formatUSD = (micros: number): string => `${formatBudgetUSD(micros)} USD`; From 5b7428d0551c2598a98c4e79a7446da9a2043541 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Thu, 30 Jul 2026 11:38:28 +0000 Subject: [PATCH 4/5] revert: name the viewed group in the AI budget dialog --- .../UserAIBudgetOverrideDialog.stories.tsx | 37 +----------- .../GroupsPage/UserAIBudgetOverrideDialog.tsx | 56 +++++++------------ 2 files changed, 20 insertions(+), 73 deletions(-) diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index 9ab17ad9539..a8efd3a8076 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx @@ -5,12 +5,7 @@ import { groupAIBudget, groupsForUser } from "#/api/queries/groups"; import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users"; import type { GroupAIBudget, UserAIBudgetOverride } from "#/api/typesGenerated"; import { maxAIBudgetDollars } from "#/modules/groups"; -import { - MockEveryoneGroup, - MockGroup, - MockGroup2, - MockUserMember, -} from "#/testHelpers/entities"; +import { MockGroup, MockGroup2, MockUserMember } from "#/testHelpers/entities"; import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog"; const mockOverride: UserAIBudgetOverride = { @@ -127,36 +122,6 @@ export const ReadOnlyWithoutPermission: Story = { }, }; -/** - * No group the member belongs to sets a budget, so the backend attributes their - * spend to the Everyone group rather than the group being viewed. - */ -export const UnbudgetedGroupFallsBackToEveryone: Story = { - args: { effectiveGroupId: MockEveryoneGroup.id }, - parameters: { - queries: [ - { key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), data: null }, - { - key: groupsForUser(MockUserMember.id, MockGroup.organization_id) - .queryKey, - data: [MockGroup, MockEveryoneGroup], - }, - { key: groupAIBudget(MockGroup.id).queryKey, data: null }, - ], - }, - play: async () => { - const body = within(document.body); - const summary = await body.findByText(/charged to/); - await expect(summary).toHaveTextContent( - "uncapped, charged to Everyone group.", - ); - // The group being viewed must not be named as the charging group. - await expect(summary).not.toHaveTextContent( - MockGroup.display_name || MockGroup.name, - ); - }, -}; - /** The assigned group is in another organization, so it can't be named. */ export const OverrideWithUnresolvableGroup: Story = { parameters: { diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx index 320a1b8ea19..9e3b32ba4ab 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx @@ -115,7 +115,6 @@ export const UserAIBudgetOverrideDialog: FC< override: budgetOverrideQuery.data ?? null, groupBudget: groupBudgetQuery.data ?? null, userGroups: userGroupsQuery.data ?? [], - effectiveGroupId, }; let body: ReactNode; @@ -132,6 +131,9 @@ export const UserAIBudgetOverrideDialog: FC< body = ( = ({ override, groupBudget, userGroups, - effectiveGroupId, }) => { if (!override) { - // The governing group is not necessarily the group being viewed. - const effectiveGroup = - effectiveGroupId === undefined - ? currentGroup - : findGroup(currentGroup, userGroups, effectiveGroupId); return ( <> {user.username}'s monthly limit is{" "} {groupBudget ? formatUSD(groupBudget.spend_limit_micros) : "uncapped"} - , + , charged to {groupDisplayName(currentGroup)} group. ); } + const overrideGroup = findGroup(currentGroup, userGroups, override.group_id); return ( <> {user.username}'s custom monthly limit is{" "} - {formatUSD(override.spend_limit_micros)},{" "} - + {formatUSD(override.spend_limit_micros)}, charged to{" "} + {overrideGroup ? ( + <> + {groupDisplayName(overrideGroup)} group. + + ) : ( + // The group is unresolvable here, so it can't be named. + their group. + )} ); }; -const ChargedTo: FC<{ group: Group | undefined }> = ({ group }) => - group ? ( - <> - charged to {groupDisplayName(group)} group. - - ) : ( - // The group is unresolvable here, so it can't be named. - <> - charged to their group. - - ); - /** * The budget without any editing controls. Setting an override requires * updating both the user and the group it charges, so group admins can read a @@ -249,6 +235,8 @@ const ReadOnlyBudget: FC = (props) => ( ); interface OverrideFormProps extends BudgetProps { + // Group marked "(default)" in the picker; null marks none. + defaultGroupId: string | null; isSubmitting: boolean; onSave: (request: UpsertUserAIBudgetOverrideRequest) => Promise; onRemove: () => Promise; @@ -259,18 +247,15 @@ interface OverrideFormProps extends BudgetProps { const OverrideForm: FC = ({ user, currentGroup, + defaultGroupId, override, groupBudget, userGroups, - effectiveGroupId, isSubmitting, onSave, onRemove, onClose, }) => { - // Group marked "(default)" in the picker; null marks none. - const defaultGroupId = - effectiveGroupId === undefined ? currentGroup.id : effectiveGroupId; const budgetId = useId(); const groupId = useId(); const overrideId = useId(); @@ -357,7 +342,6 @@ const OverrideForm: FC = ({ override={override} groupBudget={groupBudget} userGroups={userGroups} - effectiveGroupId={effectiveGroupId} />

@@ -509,10 +493,8 @@ const groupDisplayName = (group: Group): string => const findGroup = ( currentGroup: Group, userGroups: readonly Group[], - groupID: string | null, + groupID: string, ): Group | undefined => - groupID === null - ? undefined - : [currentGroup, ...userGroups].find((group) => group.id === groupID); + [currentGroup, ...userGroups].find((group) => group.id === groupID); const formatUSD = (micros: number): string => `${formatBudgetUSD(micros)} USD`; From 51fb7d1a6eec282af645b69627b011d2c7d087d1 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Thu, 30 Jul 2026 12:19:27 +0000 Subject: [PATCH 5/5] chore: minor improvements --- .../UserAIBudgetOverrideDialog.stories.tsx | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index a8efd3a8076..4aa71c5fbf2 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx @@ -89,8 +89,8 @@ export const WithoutOverride: Story = { }, }; -/** An existing override still hides the controls, so it can't be removed. */ -export const ReadOnlyWithoutPermission: Story = { +/** Without permission, an existing override can be read but not removed. */ +export const ReadOnlyWithOverride: Story = { args: { canUpdate: false }, parameters: { queries: [ @@ -122,6 +122,28 @@ export const ReadOnlyWithoutPermission: Story = { }, }; +/** Without permission or an override, the group's budget is shown as-is. */ +export const ReadOnlyWithoutOverride: Story = { + args: { canUpdate: false }, + 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(/To update this limit, contact a Coder administrator\./), + ).toBeInTheDocument(); + await expect(body.queryByRole("checkbox")).not.toBeInTheDocument(); + await expect( + body.queryByRole("button", { name: "Update" }), + ).not.toBeInTheDocument(); + }, +}; + /** The assigned group is in another organization, so it can't be named. */ export const OverrideWithUnresolvableGroup: Story = { parameters: {