diff --git a/site/src/modules/groups.ts b/site/src/modules/groups.ts index 5e83d6649a..d385786c44 100644 --- a/site/src/modules/groups.ts +++ b/site/src/modules/groups.ts @@ -1,5 +1,6 @@ import type { Group, + OrganizationMemberWithUserData, ReducedUser, User, WorkspaceUser, @@ -8,7 +9,11 @@ import type { /** * Union of all user-like types that can be distinguished from Group. */ -type UserLike = User | ReducedUser | WorkspaceUser; +type UserLike = + | User + | ReducedUser + | WorkspaceUser + | OrganizationMemberWithUserData; /** * Type guard to check if the value is a Group. diff --git a/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx b/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx index 86b5546d6c..a7ccfc815d 100644 --- a/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx +++ b/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx @@ -1,15 +1,16 @@ import { groupsByOrganization } from "api/queries/groups"; -import { users } from "api/queries/users"; -import type { Group, User } from "api/typesGenerated"; +import { organizationMembers } from "api/queries/organizations"; +import type { Group, OrganizationMemberWithUserData } from "api/typesGenerated"; import { Autocomplete } from "components/Autocomplete/Autocomplete"; import { AvatarData } from "components/Avatar/AvatarData"; + import { Check } from "lucide-react"; import { getGroupSubtitle, isGroup } from "modules/groups"; import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; -import { prepareQuery } from "utils/filters"; -type AutocompleteOption = User | Group; +type OrganizationMember = OrganizationMemberWithUserData & { id: string }; +type AutocompleteOption = OrganizationMember | Group; export type UserOrGroupAutocompleteValue = AutocompleteOption | null; type ExcludableOption = { id?: string | null } | null; @@ -21,6 +22,13 @@ type UserOrGroupAutocompleteProps = { exclude: ExcludableOption[]; }; +const normalizeMember = ( + member: OrganizationMemberWithUserData, +): OrganizationMember => ({ + ...member, + id: member.user_id, +}); + export const UserOrGroupAutocomplete: FC = ({ value, onChange, @@ -37,11 +45,11 @@ export const UserOrGroupAutocomplete: FC = ({ } }; - const usersQuery = useQuery({ - ...users({ - q: prepareQuery(encodeURI(inputValue)), - limit: 25, - }), + // Use org members endpoint instead of site-wide /users endpoint. + // This allows regular org members to see other members in their org + // for workspace sharing, without needing site-wide user:read permission. + const membersQuery = useQuery({ + ...organizationMembers(organizationId), enabled: open, placeholderData: keepPreviousData, }); @@ -53,6 +61,8 @@ export const UserOrGroupAutocomplete: FC = ({ }); const filterValue = inputValue.trim().toLowerCase(); + + // Filter groups by search input (client-side filtering). const groupOptions = groupsQuery.data ? groupsQuery.data.filter((group) => { if (!filterValue) { @@ -63,13 +73,28 @@ export const UserOrGroupAutocomplete: FC = ({ }) : []; + // Filter members by search input (client-side filtering since org members + // endpoint doesn't support search params). + const userOptions = membersQuery.data?.members + ? membersQuery.data.members + .filter((member) => { + if (!filterValue) { + return true; + } + const haystack = + `${member.name ?? ""} ${member.username} ${member.email}`.toLowerCase(); + return haystack.includes(filterValue); + }) + .map(normalizeMember) + : []; + const excludeIds = exclude .map((optionToExclude) => optionToExclude?.id) .filter((id): id is string => Boolean(id)); const options: AutocompleteOption[] = [ ...groupOptions, - ...(usersQuery.data?.users ?? []), + ...userOptions, ].filter((result) => !excludeIds.includes(result.id)); return ( @@ -102,7 +127,7 @@ export const UserOrGroupAutocomplete: FC = ({ onOpenChange={handleOpenChange} inputValue={inputValue} onInputChange={setInputValue} - loading={usersQuery.isFetching || groupsQuery.isFetching} + loading={membersQuery.isFetching || groupsQuery.isFetching} placeholder="Search for user or group" noOptionsText="No users or groups found" className="w-80"