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..2a42dd04b16 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 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"; diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index 3953bf5550d..4aa71c5fbf2 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,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: [ diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx index 9b7a292e685..9e3b32ba4ab 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), @@ -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 = ; + } else if (isLoading) { + body = ( +
+ + Loading AI budget... +
+ ); + } else if (canUpdate) { + body = ( + onOpenChange(false)} + /> + ); + } else { + body = ; + } return ( - {loadError ? ( - - ) : isLoading ? ( -
- - Loading AI budget... -
- ) : ( - onOpenChange(false)} - /> - )} + {body}
); }; -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 = ({ + user, + currentGroup, + override, + groupBudget, + 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)} group. + + ) : ( + // The group is unresolvable here, so it can't be named. + 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 + * member's budget without being able to change it. + */ +const ReadOnlyBudget: FC = (props) => ( +

+ To update this limit, contact a Coder + administrator. +

+); + +interface OverrideFormProps extends BudgetProps { + // Group marked "(default)" in the picker; null marks none. + defaultGroupId: string | null; isSubmitting: boolean; onSave: (request: UpsertUserAIBudgetOverrideRequest) => Promise; onRemove: () => Promise; @@ -212,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. @@ -266,26 +336,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. - - )} +

@@ -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`;