teamMembers.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. import {Fragment, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  4. import {
  5. openInviteMembersModal,
  6. openTeamAccessRequestModal,
  7. } from 'sentry/actionCreators/modal';
  8. import {joinTeamPromise, leaveTeamPromise} from 'sentry/actionCreators/teams';
  9. import {hasEveryAccess} from 'sentry/components/acl/access';
  10. import UserAvatar from 'sentry/components/avatar/userAvatar';
  11. import {Flex} from 'sentry/components/container/flex';
  12. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  13. import type {Item} from 'sentry/components/dropdownAutoComplete/types';
  14. import DropdownButton from 'sentry/components/dropdownButton';
  15. import EmptyMessage from 'sentry/components/emptyMessage';
  16. import Link from 'sentry/components/links/link';
  17. import LoadingError from 'sentry/components/loadingError';
  18. import LoadingIndicator from 'sentry/components/loadingIndicator';
  19. import Pagination from 'sentry/components/pagination';
  20. import Panel from 'sentry/components/panels/panel';
  21. import PanelHeader from 'sentry/components/panels/panelHeader';
  22. import {TeamRoleColumnLabel} from 'sentry/components/teamRoleUtils';
  23. import {IconUser} from 'sentry/icons';
  24. import {t} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import type {Member, Organization, Team, TeamMember} from 'sentry/types/organization';
  27. import {
  28. type ApiQueryKey,
  29. setApiQueryData,
  30. useApiQuery,
  31. useMutation,
  32. useQueryClient,
  33. } from 'sentry/utils/queryClient';
  34. import useApi from 'sentry/utils/useApi';
  35. import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
  36. import {useLocation} from 'sentry/utils/useLocation';
  37. import useOrganization from 'sentry/utils/useOrganization';
  38. import {useParams} from 'sentry/utils/useParams';
  39. import {useUser} from 'sentry/utils/useUser';
  40. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  41. import TeamMembersRow, {
  42. GRID_TEMPLATE,
  43. } from 'sentry/views/settings/organizationTeams/teamMembersRow';
  44. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  45. import {getButtonHelpText} from './utils';
  46. interface TeamMembersProps {
  47. team: Team;
  48. }
  49. function getTeamMembersQueryKey({
  50. organization,
  51. teamId,
  52. location,
  53. }: {
  54. location: ReturnType<typeof useLocation>;
  55. organization: Organization;
  56. teamId: string;
  57. }): ApiQueryKey {
  58. return [
  59. `/teams/${organization.slug}/${teamId}/members/`,
  60. {
  61. query: {
  62. cursor: location.query.cursor,
  63. query: location.query.query,
  64. },
  65. },
  66. ];
  67. }
  68. function AddMemberDropdown({
  69. teamMembers,
  70. organization,
  71. team,
  72. teamId,
  73. isTeamAdmin,
  74. onAddMember,
  75. }: {
  76. isTeamAdmin: boolean;
  77. onAddMember: (variables: {orgMember: TeamMember}) => void;
  78. organization: Organization;
  79. team: Team;
  80. teamId: string;
  81. teamMembers: TeamMember[];
  82. }) {
  83. const [memberQuery, setMemberQuery] = useState('');
  84. const debouncedMemberQuery = useDebouncedValue(memberQuery, 50);
  85. const {data: orgMembers = [], isLoading: isOrgMembersLoading} = useApiQuery<Member[]>(
  86. [
  87. `/organizations/${organization.slug}/members/`,
  88. {
  89. query: debouncedMemberQuery ? {query: debouncedMemberQuery} : undefined,
  90. },
  91. ],
  92. {
  93. staleTime: 30_000,
  94. }
  95. );
  96. // members can add other members to a team if the `Open Membership` setting is enabled
  97. // otherwise, `org:write` or `team:admin` permissions are required
  98. const hasOpenMembership = !!organization?.openMembership;
  99. const canAddMembers = hasOpenMembership || isTeamAdmin;
  100. const isDropdownDisabled = team.flags['idp:provisioned'];
  101. const addTeamMember = (selection: Item) => {
  102. const orgMember = orgMembers.find(member => member.id === selection.value);
  103. if (orgMember === undefined) {
  104. return;
  105. }
  106. // Reset members list after adding member to team
  107. setMemberQuery('');
  108. onAddMember({orgMember});
  109. };
  110. /**
  111. * We perform an API request to support orgs with > 100 members (since that's the max API returns)
  112. */
  113. const handleMemberFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  114. setMemberQuery(e.target.value);
  115. };
  116. const items = useMemo(() => {
  117. const existingMembers = new Set(teamMembers.map(member => member.id));
  118. return (orgMembers || [])
  119. .filter(m => !existingMembers.has(m.id))
  120. .map(m => ({
  121. searchKey: `${m.name} ${m.email}`,
  122. value: m.id,
  123. label: (
  124. <StyledUserListElement>
  125. <UserAvatar
  126. user={{
  127. id: m.user?.id ?? m.id,
  128. name: m.user?.name ?? m.name,
  129. email: m.user?.email ?? m.email,
  130. avatar: m.user?.avatar ?? undefined,
  131. avatarUrl: m.user?.avatarUrl ?? undefined,
  132. type: 'user',
  133. }}
  134. title={m.user?.name ?? m.name ?? m.user?.email ?? m.email}
  135. size={24}
  136. className="avatar"
  137. />
  138. <StyledNameOrEmail>{m.name || m.email}</StyledNameOrEmail>
  139. </StyledUserListElement>
  140. ),
  141. }));
  142. }, [teamMembers, orgMembers]);
  143. return (
  144. <DropdownAutoComplete
  145. closeOnSelect={false}
  146. items={items}
  147. alignMenu="right"
  148. onSelect={
  149. canAddMembers
  150. ? addTeamMember
  151. : selection =>
  152. openTeamAccessRequestModal({
  153. teamId,
  154. orgId: organization.slug,
  155. memberId: selection.value,
  156. })
  157. }
  158. menuHeader={
  159. <StyledMembersLabel>
  160. {t('Members')}
  161. <StyledCreateMemberLink
  162. to=""
  163. onClick={() => openInviteMembersModal({source: 'teams'})}
  164. data-test-id="invite-member"
  165. >
  166. {t('Invite Member')}
  167. </StyledCreateMemberLink>
  168. </StyledMembersLabel>
  169. }
  170. emptyMessage={t('No members')}
  171. onChange={handleMemberFilterChange}
  172. onClose={() => setMemberQuery('')}
  173. disabled={isDropdownDisabled}
  174. data-test-id="add-member-menu"
  175. busy={isOrgMembersLoading}
  176. >
  177. {({isOpen}) => (
  178. <DropdownButton
  179. isOpen={isOpen}
  180. size="xs"
  181. data-test-id="add-member"
  182. disabled={isDropdownDisabled}
  183. >
  184. {t('Add Member')}
  185. </DropdownButton>
  186. )}
  187. </DropdownAutoComplete>
  188. );
  189. }
  190. function TeamMembers({team}: TeamMembersProps) {
  191. const user = useUser();
  192. const api = useApi({persistInFlight: true});
  193. const queryClient = useQueryClient();
  194. const organization = useOrganization();
  195. const {teamId} = useParams<{teamId: string}>();
  196. const location = useLocation();
  197. const {
  198. data: teamMembers = [],
  199. isError: isTeamMembersError,
  200. isLoading: isTeamMembersLoading,
  201. refetch: refetchTeamMembers,
  202. getResponseHeader: getTeamMemberResponseHeader,
  203. } = useApiQuery<TeamMember[]>(
  204. getTeamMembersQueryKey({organization, teamId, location}),
  205. {
  206. staleTime: 30_000,
  207. }
  208. );
  209. const teamMembersPageLinks = getTeamMemberResponseHeader?.('Link');
  210. const hasOrgWriteAccess = hasEveryAccess(['org:write'], {organization, team});
  211. const hasTeamAdminAccess = hasEveryAccess(['team:admin'], {organization, team});
  212. const isTeamAdmin = hasOrgWriteAccess || hasTeamAdminAccess;
  213. const {mutate: handleRemoveTeamMember} = useMutation({
  214. mutationFn: ({memberId}: {memberId: string}) => {
  215. return leaveTeamPromise(api, {
  216. orgId: organization.slug,
  217. teamId,
  218. memberId,
  219. });
  220. },
  221. onSuccess: (_data, variables) => {
  222. setApiQueryData<TeamMember[]>(
  223. queryClient,
  224. getTeamMembersQueryKey({organization, teamId, location}),
  225. existingData => {
  226. if (!existingData) {
  227. return existingData;
  228. }
  229. return existingData.filter(member => member.id !== variables.memberId);
  230. }
  231. );
  232. addSuccessMessage(t('Successfully removed member from team.'));
  233. },
  234. onError: () => {
  235. addErrorMessage(
  236. t('There was an error while trying to remove a member from the team.')
  237. );
  238. },
  239. });
  240. const {mutate: updateTeamMemberRole} = useMutation({
  241. mutationFn: ({memberId, newRole}: {memberId: string; newRole: string}) => {
  242. return api.requestPromise(
  243. `/organizations/${organization.slug}/members/${memberId}/teams/${teamId}/`,
  244. {
  245. method: 'PUT',
  246. data: {teamRole: newRole},
  247. }
  248. );
  249. },
  250. onSuccess: (_data, variables) => {
  251. addSuccessMessage(t('Successfully changed role for team member.'));
  252. setApiQueryData<TeamMember[]>(
  253. queryClient,
  254. getTeamMembersQueryKey({organization, teamId, location}),
  255. existingData => {
  256. if (!existingData) {
  257. return existingData;
  258. }
  259. return existingData.map(member => {
  260. if (member.id === variables.memberId) {
  261. return {
  262. ...member,
  263. teamRole: variables.newRole,
  264. };
  265. }
  266. return member;
  267. });
  268. }
  269. );
  270. },
  271. onError: () => {
  272. addErrorMessage(
  273. t('There was an error while trying to change the roles for a team member.')
  274. );
  275. },
  276. });
  277. const {mutate: handleAddTeamMember} = useMutation({
  278. mutationFn: ({orgMember}: {orgMember: TeamMember}) => {
  279. return joinTeamPromise(api, {
  280. orgId: organization.slug,
  281. teamId,
  282. memberId: orgMember.id,
  283. });
  284. },
  285. onSuccess: (_data, {orgMember}) => {
  286. setApiQueryData<TeamMember[]>(
  287. queryClient,
  288. getTeamMembersQueryKey({organization, teamId, location}),
  289. existingData => {
  290. if (!existingData) {
  291. return existingData;
  292. }
  293. return existingData.concat([orgMember]);
  294. }
  295. );
  296. addSuccessMessage(t('Successfully added member to team.'));
  297. },
  298. onError: () => {
  299. addErrorMessage(t('Unable to add team member.'));
  300. },
  301. });
  302. if (isTeamMembersError) {
  303. return <LoadingError onRetry={refetchTeamMembers} />;
  304. }
  305. const renderPageTextBlock = () => {
  306. const {openMembership} = organization;
  307. const isIdpProvisioned = team.flags['idp:provisioned'];
  308. if (isIdpProvisioned) {
  309. return getButtonHelpText(isIdpProvisioned);
  310. }
  311. return openMembership
  312. ? t(
  313. '"Open Membership" is enabled for the organization. Anyone can add members for this team.'
  314. )
  315. : t(
  316. '"Open Membership" is disabled for the organization. Org Owner/Manager/Admin, or Team Admins can add members for this team.'
  317. );
  318. };
  319. const renderMembers = () => {
  320. if (isTeamMembersLoading) {
  321. return <LoadingIndicator />;
  322. }
  323. if (teamMembers.length) {
  324. return teamMembers.map(member => {
  325. return (
  326. <TeamMembersRow
  327. key={member.id}
  328. hasWriteAccess={isTeamAdmin}
  329. organization={organization}
  330. team={team}
  331. member={member}
  332. user={user}
  333. removeMember={handleRemoveTeamMember}
  334. updateMemberRole={updateTeamMemberRole}
  335. />
  336. );
  337. });
  338. }
  339. return (
  340. <EmptyMessage icon={<IconUser size="xl" />} size="large">
  341. {t('This team has no members')}
  342. </EmptyMessage>
  343. );
  344. };
  345. return (
  346. <Fragment>
  347. <TextBlock>{renderPageTextBlock()}</TextBlock>
  348. <PermissionAlert
  349. access={organization.openMembership ? ['org:read'] : ['team:write']}
  350. team={team}
  351. />
  352. <Panel>
  353. <StyledPanelHeader hasButtons>
  354. <div>{t('Members')}</div>
  355. <div>
  356. <TeamRoleColumnLabel />
  357. </div>
  358. <Flex justify="end">
  359. <AddMemberDropdown
  360. teamMembers={teamMembers}
  361. organization={organization}
  362. team={team}
  363. teamId={teamId}
  364. isTeamAdmin={isTeamAdmin}
  365. onAddMember={handleAddTeamMember}
  366. />
  367. </Flex>
  368. </StyledPanelHeader>
  369. {renderMembers()}
  370. </Panel>
  371. <Pagination pageLinks={teamMembersPageLinks} />
  372. </Fragment>
  373. );
  374. }
  375. const StyledUserListElement = styled('div')`
  376. display: grid;
  377. grid-template-columns: max-content 1fr;
  378. gap: ${space(0.5)};
  379. align-items: center;
  380. text-transform: initial;
  381. font-weight: normal;
  382. `;
  383. const StyledNameOrEmail = styled('div')`
  384. font-size: ${p => p.theme.fontSizeSmall};
  385. ${p => p.theme.overflowEllipsis};
  386. `;
  387. const StyledMembersLabel = styled('div')`
  388. display: grid;
  389. grid-template-columns: 1fr max-content;
  390. padding: ${space(1)} 0;
  391. font-size: ${p => p.theme.fontSizeExtraSmall};
  392. text-transform: uppercase;
  393. `;
  394. const StyledCreateMemberLink = styled(Link)`
  395. text-transform: initial;
  396. `;
  397. const StyledPanelHeader = styled(PanelHeader)`
  398. ${GRID_TEMPLATE}
  399. `;
  400. export default TeamMembers;