Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b4eda32

Browse files
authored
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
1 parent e819dd4 commit b4eda32

4 files changed

Lines changed: 250 additions & 52 deletions

File tree

site/src/pages/GroupsPage/GroupMembersPage.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
TableHeader,
4848
TableRow,
4949
} from "#/components/Table/Table";
50+
import { useAuthenticated } from "#/hooks/useAuthenticated";
5051
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
5152
import { isEveryoneGroup } from "#/modules/groups";
5253
import { cn } from "#/utils/cn";
@@ -77,7 +78,11 @@ const GroupMembersPage: FC = () => {
7778
const removeMemberMutation = useMutation(
7879
removeMember(queryClient, organization),
7980
);
81+
const { permissions: sitePermissions } = useAuthenticated();
8082
const canUpdateGroup = permissions ? permissions.canUpdateGroup : false;
83+
// Setting a user's AI budget override updates both the user and the group
84+
// its spend is charged to, so it needs permission on both.
85+
const canUpdateBudgetOverride = canUpdateGroup && sitePermissions.updateUsers;
8186
const [budgetUser, setBudgetUser] = useState<MemberWithSpend | null>(null);
8287

8388
const aibridgeVisible = Boolean(useFeatureVisibility().aibridge);
@@ -232,6 +237,7 @@ const GroupMembersPage: FC = () => {
232237
user={budgetUser}
233238
currentGroup={groupData}
234239
effectiveGroupId={budgetUser.spend?.effective_group_id}
240+
canUpdate={canUpdateBudgetOverride}
235241
/>
236242
)}
237243
</div>

site/src/pages/GroupsPage/GroupPage.stories.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,20 @@ import {
3737
MockUserMember,
3838
MockUserOwner,
3939
} from "#/testHelpers/entities";
40-
import { withDashboardProvider } from "#/testHelpers/storybook";
40+
import {
41+
withAuthProvider,
42+
withDashboardProvider,
43+
} from "#/testHelpers/storybook";
4144
import GroupMembersPage from "./GroupMembersPage";
4245
import GroupPage from "./GroupPage";
4346

4447
const meta: Meta<typeof GroupPage> = {
4548
title: "pages/OrganizationGroupsPage/GroupPage",
4649
component: GroupPage,
47-
decorators: [withDashboardProvider],
50+
decorators: [withDashboardProvider, withAuthProvider],
4851
parameters: {
52+
user: MockUserOwner,
53+
permissions: { updateUsers: true },
4954
reactRouter: reactRouterParameters({
5055
location: {
5156
pathParams: {
@@ -674,6 +679,50 @@ export const OpenAIBudgetForCurrentGroupMember: Story = {
674679
},
675680
};
676681

682+
/** Group admins can read a member's budget without the site user permission. */
683+
export const AIBudgetReadOnlyWithoutUserPermission: Story = {
684+
parameters: {
685+
features: ["aibridge"],
686+
permissions: { updateUsers: false },
687+
queries: [
688+
groupQuery(MockGroupWithoutMembers),
689+
groupMembersQuery({
690+
users: [MockUserOwner],
691+
count: 1,
692+
}),
693+
membersSpendQuery([{ ...mockSpend, user_id: MockUserOwner.id }]),
694+
permissionsQuery({ canUpdateGroup: true }),
695+
{ key: meAISpendKey, data: mockUserAISpend },
696+
{ key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null },
697+
{
698+
key: getGroupsForUserQueryKey(
699+
MockUserOwner.id,
700+
MockGroupWithoutMembers.organization_id,
701+
),
702+
data: [MockGroup2],
703+
},
704+
{ key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null },
705+
],
706+
},
707+
play: async ({ canvasElement }) => {
708+
const canvas = within(canvasElement);
709+
const body = within(document.body);
710+
711+
await userEvent.click(
712+
canvas.getAllByRole("button", { name: "Open menu" })[0],
713+
);
714+
await userEvent.click(
715+
await body.findByRole("menuitem", { name: "Manage AI budget" }),
716+
);
717+
await expect(
718+
await body.findByText(
719+
/To update this limit, contact a Coder administrator\./,
720+
),
721+
).toBeInTheDocument();
722+
await expect(body.queryByRole("checkbox")).not.toBeInTheDocument();
723+
},
724+
};
725+
677726
/** Unresolvable via getGroupById, standing in for another org's group. */
678727
const unresolvedGroupId = "external-org-group";
679728

site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const meta: Meta<typeof UserAIBudgetOverrideDialog> = {
3939
onOpenChange: () => undefined,
4040
user: MockUserMember,
4141
currentGroup: MockGroup,
42+
canUpdate: true,
4243
},
4344
};
4445

@@ -88,6 +89,80 @@ export const WithoutOverride: Story = {
8889
},
8990
};
9091

92+
/** Without permission, an existing override can be read but not removed. */
93+
export const ReadOnlyWithOverride: Story = {
94+
args: { canUpdate: false },
95+
parameters: {
96+
queries: [
97+
{
98+
key: getUserAIBudgetOverrideQueryKey(MockUserMember.id),
99+
data: mockOverride,
100+
},
101+
...groupQueries,
102+
],
103+
},
104+
play: async () => {
105+
const body = within(document.body);
106+
await expect(await body.findByText("$12,000 USD")).toBeInTheDocument();
107+
await expect(
108+
body.getByText(/To update this limit, contact a Coder administrator\./),
109+
).toBeInTheDocument();
110+
await expect(body.queryByRole("checkbox")).not.toBeInTheDocument();
111+
await expect(
112+
body.queryByLabelText("Custom monthly budget"),
113+
).not.toBeInTheDocument();
114+
await expect(
115+
body.queryByRole("button", { name: "Budget assigned to" }),
116+
).not.toBeInTheDocument();
117+
for (const name of ["Update", "Cancel", "Close"]) {
118+
await expect(
119+
body.queryByRole("button", { name }),
120+
).not.toBeInTheDocument();
121+
}
122+
},
123+
};
124+
125+
/** Without permission or an override, the group's budget is shown as-is. */
126+
export const ReadOnlyWithoutOverride: Story = {
127+
args: { canUpdate: false },
128+
parameters: {
129+
queries: [
130+
{ key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), data: null },
131+
...groupQueries,
132+
],
133+
},
134+
play: async () => {
135+
const body = within(document.body);
136+
await expect(await body.findByText("$5,000 USD")).toBeInTheDocument();
137+
await expect(
138+
body.getByText(/To update this limit, contact a Coder administrator\./),
139+
).toBeInTheDocument();
140+
await expect(body.queryByRole("checkbox")).not.toBeInTheDocument();
141+
await expect(
142+
body.queryByRole("button", { name: "Update" }),
143+
).not.toBeInTheDocument();
144+
},
145+
};
146+
147+
/** The assigned group is in another organization, so it can't be named. */
148+
export const OverrideWithUnresolvableGroup: Story = {
149+
parameters: {
150+
queries: [
151+
{
152+
key: getUserAIBudgetOverrideQueryKey(MockUserMember.id),
153+
data: { ...mockOverride, group_id: "another-org-group" },
154+
},
155+
...groupQueries,
156+
],
157+
},
158+
play: async () => {
159+
const body = within(document.body);
160+
const summary = await body.findByText(/charged to/);
161+
await expect(summary).toHaveTextContent("charged to their group.");
162+
await expect(summary).not.toHaveTextContent("group group");
163+
},
164+
};
165+
91166
export const Uncapped: Story = {
92167
parameters: {
93168
queries: [

0 commit comments

Comments
 (0)