mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user