fix: hide AI budget override controls without permission (#27654)

### Description

Setting a user's AI budget override updates both the user and the group
its spend is charged to, so it requires `user:update` and
`group:update`. Organization admins have group update but only site-wide
user read, so they could tick "Override group budget", enter an amount,
and then fail on save.

The dialog now shows the member's budget as read-only when the viewer
can't change it.

### Changes

- Gate the override controls on `user:update` (site-wide) in addition to
the group permission the page already checks
- Replace the form with a read-only view: the group's budget, followed
by "To update this limit, contact a Coder administrator."
- Swap the whole view rather than disabling the checkbox, since an
existing override seeds the form enabled and unchecking it would call
the delete endpoint and fail the same way
- Add stories for the read-only dialog and for the page-level wiring

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by
@ssncferreira
This commit is contained in:
Susana Ferreira
2026-07-30 14:28:41 +01:00
committed by GitHub
parent e819dd4af6
commit b4eda32a2e
4 changed files with 250 additions and 52 deletions
@@ -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<MemberWithSpend | null>(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}
/>
)}
</div>
@@ -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<typeof GroupPage> = {
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 a Coder administrator\./,
),
).toBeInTheDocument();
await expect(body.queryByRole("checkbox")).not.toBeInTheDocument();
},
};
/** Unresolvable via getGroupById, standing in for another org's group. */
const unresolvedGroupId = "external-org-group";
@@ -39,6 +39,7 @@ const meta: Meta<typeof UserAIBudgetOverrideDialog> = {
onOpenChange: () => undefined,
user: MockUserMember,
currentGroup: MockGroup,
canUpdate: true,
},
};
@@ -88,6 +89,80 @@ export const WithoutOverride: Story = {
},
};
/** Without permission, an existing override can be read but not removed. */
export const ReadOnlyWithOverride: 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 a Coder 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();
for (const name of ["Update", "Cancel", "Close"]) {
await expect(
body.queryByRole("button", { name }),
).not.toBeInTheDocument();
}
},
};
/** 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: {
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");
},
};
export const Uncapped: Story = {
parameters: {
queries: [
@@ -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),
@@ -100,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 = <ErrorAlert error={loadError} />;
} else if (isLoading) {
body = (
<div className="flex items-center gap-2 text-sm text-content-secondary">
<Spinner loading />
Loading AI budget...
</div>
);
} else if (canUpdate) {
body = (
<OverrideForm
{...budget}
defaultGroupId={
effectiveGroupId === undefined ? currentGroup.id : effectiveGroupId
}
isSubmitting={isSubmitting}
onSave={saveMutation.mutateAsync}
onRemove={deleteMutation.mutateAsync}
onClose={() => onOpenChange(false)}
/>
);
} else {
body = <ReadOnlyBudget {...budget} />;
}
return (
<Dialog
@@ -128,44 +171,72 @@ export const UserAIBudgetOverrideDialog: FC<
/>
</div>
{loadError ? (
<ErrorAlert error={loadError} />
) : isLoading ? (
<div className="flex items-center gap-2 text-sm text-content-secondary">
<Spinner loading />
Loading AI budget...
</div>
) : (
<OverrideForm
user={user}
currentGroup={currentGroup}
defaultGroupId={
effectiveGroupId === undefined
? currentGroup.id
: effectiveGroupId
}
override={budgetOverrideQuery.data ?? null}
groupBudget={groupBudgetQuery.data ?? null}
userGroups={userGroupsQuery.data ?? []}
isSubmitting={isSubmitting}
onSave={saveMutation.mutateAsync}
onRemove={deleteMutation.mutateAsync}
onClose={() => onOpenChange(false)}
/>
)}
{body}
</DialogContent>
</Dialog>
);
};
interface OverrideFormProps {
interface BudgetProps {
user: ReducedUser;
currentGroup: Group;
// Group marked "(default)" in the picker; null marks none.
defaultGroupId: string | null;
override: UserAIBudgetOverride | null;
groupBudget: GroupAIBudget | null;
userGroups: readonly Group[];
}
/** The member's effective limit as a sentence, to place inside a paragraph. */
const BudgetSummary: FC<BudgetProps> = ({
user,
currentGroup,
override,
groupBudget,
userGroups,
}) => {
if (!override) {
return (
<>
{user.username}'s monthly limit is{" "}
<Bold>
{groupBudget ? formatUSD(groupBudget.spend_limit_micros) : "uncapped"}
</Bold>
, charged to <Bold>{groupDisplayName(currentGroup)}</Bold> group.
</>
);
}
const overrideGroup = findGroup(currentGroup, userGroups, override.group_id);
return (
<>
{user.username}'s <Bold>custom</Bold> monthly limit is{" "}
<Bold>{formatUSD(override.spend_limit_micros)}</Bold>, charged to{" "}
{overrideGroup ? (
<>
<Bold>{groupDisplayName(overrideGroup)}</Bold> group.
</>
) : (
// The group is unresolvable here, so it can't be named.
<Bold>their group.</Bold>
)}
</>
);
};
/**
* 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<BudgetProps> = (props) => (
<p className="m-0 text-sm text-content-secondary">
<BudgetSummary {...props} /> To update this limit, contact a Coder
administrator.
</p>
);
interface OverrideFormProps extends BudgetProps {
// Group marked "(default)" in the picker; null marks none.
defaultGroupId: string | null;
isSubmitting: boolean;
onSave: (request: UpsertUserAIBudgetOverrideRequest) => Promise<unknown>;
onRemove: () => Promise<unknown>;
@@ -212,7 +283,6 @@ const OverrideForm: FC<OverrideFormProps> = ({
}, [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.
@@ -266,26 +336,13 @@ const OverrideForm: FC<OverrideFormProps> = ({
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<p className="m-0 text-sm text-content-secondary">
{override ? (
<>
{user.username}'s <Bold>custom</Bold> monthly limit is{" "}
<Bold>{formatUSD(override.spend_limit_micros)}</Bold>, charged to{" "}
<Bold>
{overrideGroup ? groupDisplayName(overrideGroup) : "their group"}
</Bold>{" "}
group.
</>
) : (
<>
{user.username}'s monthly limit is{" "}
<Bold>
{groupBudget
? formatUSD(groupBudget.spend_limit_micros)
: "uncapped"}
</Bold>
, charged to <Bold>{groupDisplayName(currentGroup)}</Bold> group.
</>
)}
<BudgetSummary
user={user}
currentGroup={currentGroup}
override={override}
groupBudget={groupBudget}
userGroups={userGroups}
/>
</p>
<Separator />
@@ -429,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`;