organizationMemberRow.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import {Fragment, PureComponent} from 'react';
  2. import styled from '@emotion/styled';
  3. import UserAvatar from 'sentry/components/avatar/userAvatar';
  4. import {Button} from 'sentry/components/button';
  5. import Confirm from 'sentry/components/confirm';
  6. import HookOrDefault from 'sentry/components/hookOrDefault';
  7. import Link from 'sentry/components/links/link';
  8. import LoadingIndicator from 'sentry/components/loadingIndicator';
  9. import PanelItem from 'sentry/components/panels/panelItem';
  10. import {IconCheckmark, IconClose, IconFlag, IconMail, IconSubtract} from 'sentry/icons';
  11. import {t, tct} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {Member, Organization} from 'sentry/types/organization';
  14. import type {AvatarUser} from 'sentry/types/user';
  15. import isMemberDisabledFromLimit from 'sentry/utils/isMemberDisabledFromLimit';
  16. import {capitalize} from 'sentry/utils/string/capitalize';
  17. type Props = {
  18. canAddMembers: boolean;
  19. canRemoveMembers: boolean;
  20. currentUser: AvatarUser;
  21. member: Member;
  22. memberCanLeave: boolean;
  23. onLeave: (member: Member) => void;
  24. onRemove: (member: Member) => void;
  25. onSendInvite: (member: Member) => void;
  26. organization: Organization;
  27. requireLink: boolean;
  28. status: '' | 'loading' | 'success' | 'error' | null;
  29. };
  30. type State = {
  31. busy: boolean;
  32. };
  33. const DisabledMemberTooltip = HookOrDefault({
  34. hookName: 'component:disabled-member-tooltip',
  35. defaultComponent: ({children}) => <Fragment>{children}</Fragment>,
  36. });
  37. export default class OrganizationMemberRow extends PureComponent<Props, State> {
  38. state: State = {
  39. busy: false,
  40. };
  41. handleRemove = () => {
  42. const {onRemove} = this.props;
  43. if (typeof onRemove !== 'function') {
  44. return;
  45. }
  46. this.setState({busy: true});
  47. onRemove(this.props.member);
  48. };
  49. handleLeave = () => {
  50. const {onLeave} = this.props;
  51. if (typeof onLeave !== 'function') {
  52. return;
  53. }
  54. this.setState({busy: true});
  55. onLeave(this.props.member);
  56. };
  57. handleSendInvite = () => {
  58. const {onSendInvite, member} = this.props;
  59. if (typeof onSendInvite !== 'function') {
  60. return;
  61. }
  62. onSendInvite(member);
  63. };
  64. renderMemberRole() {
  65. const {member} = this.props;
  66. const {roleName, pending, expired} = member;
  67. if (isMemberDisabledFromLimit(member)) {
  68. return <DisabledMemberTooltip>{t('Deactivated')}</DisabledMemberTooltip>;
  69. }
  70. if (pending) {
  71. return (
  72. <InvitedRole>
  73. <IconMail size="md" />
  74. {expired ? t('Expired Invite') : tct('Invited [roleName]', {roleName})}
  75. </InvitedRole>
  76. );
  77. }
  78. return <Fragment>{capitalize(member.orgRole)}</Fragment>;
  79. }
  80. render() {
  81. const {
  82. member,
  83. organization,
  84. status,
  85. requireLink,
  86. memberCanLeave,
  87. currentUser,
  88. canRemoveMembers,
  89. canAddMembers,
  90. } = this.props;
  91. const {id, flags, email, name, pending, user, inviterName} = member;
  92. const {access} = organization;
  93. // if member is not the only owner, they can leave
  94. const isIdpProvisioned = flags['idp:provisioned'];
  95. const isPartnershipUser = flags['partnership:restricted'];
  96. const needsSso = !flags['sso:linked'] && requireLink;
  97. const isCurrentUser = currentUser.email === email;
  98. const showRemoveButton = !isCurrentUser;
  99. const showLeaveButton = isCurrentUser;
  100. const isInviteFromCurrentUser = pending && inviterName === currentUser.name;
  101. const canInvite =
  102. organization.features?.includes('members-invite-teammates') &&
  103. organization.allowMemberInvite &&
  104. access.includes('member:invite');
  105. // members can remove invites they sent if allowMemberInvite is true
  106. const canEditInvite = canInvite && isInviteFromCurrentUser;
  107. const canRemoveMember =
  108. (canRemoveMembers && !isCurrentUser && !isIdpProvisioned && !isPartnershipUser) ||
  109. canEditInvite;
  110. // member has a `user` property if they are registered with sentry
  111. // i.e. has accepted an invite to join org
  112. const has2fa = user?.has2fa;
  113. const detailsUrl = `/settings/${organization.slug}/members/${id}/`;
  114. const isInviteSuccessful = status === 'success';
  115. const isInviting = status === 'loading';
  116. const showResendButton = pending || needsSso;
  117. return (
  118. <StyledPanelItem data-test-id={email}>
  119. <MemberHeading>
  120. <UserAvatar
  121. size={32}
  122. user={user ?? {email, id: email, name: email, type: 'user'}}
  123. />
  124. <MemberDescription to={detailsUrl}>
  125. <h5 style={{margin: '0 0 3px'}}>
  126. <UserName>{name}</UserName>
  127. </h5>
  128. <Email>{email}</Email>
  129. </MemberDescription>
  130. </MemberHeading>
  131. <div data-test-id="member-role">{this.renderMemberRole()}</div>
  132. <div data-test-id="member-status">
  133. {showResendButton ? (
  134. <Fragment>
  135. {isInviting && (
  136. <LoadingContainer>
  137. <LoadingIndicator mini />
  138. </LoadingContainer>
  139. )}
  140. {isInviteSuccessful && <span>{t('Sent!')}</span>}
  141. {!isInviting && !isInviteSuccessful && (
  142. <Button
  143. disabled={!canAddMembers && !canEditInvite}
  144. priority="primary"
  145. size="sm"
  146. onClick={this.handleSendInvite}
  147. >
  148. {pending ? t('Resend invite') : t('Resend SSO link')}
  149. </Button>
  150. )}
  151. </Fragment>
  152. ) : (
  153. <AuthStatus>
  154. {has2fa ? (
  155. <IconCheckmark isCircled color="success" />
  156. ) : (
  157. <IconFlag color="error" />
  158. )}
  159. {has2fa ? t('2FA Enabled') : t('2FA Not Enabled')}
  160. </AuthStatus>
  161. )}
  162. </div>
  163. {showRemoveButton || showLeaveButton ? (
  164. <RightColumn>
  165. {showRemoveButton && canRemoveMember && (
  166. <Confirm
  167. message={tct('Are you sure you want to remove [name] from [orgName]?', {
  168. name,
  169. orgName: organization.slug,
  170. })}
  171. onConfirm={this.handleRemove}
  172. >
  173. <Button
  174. data-test-id="remove"
  175. icon={<IconSubtract isCircled />}
  176. size="sm"
  177. busy={this.state.busy}
  178. >
  179. {t('Remove')}
  180. </Button>
  181. </Confirm>
  182. )}
  183. {showRemoveButton && !canRemoveMember && (
  184. <Button
  185. disabled
  186. size="sm"
  187. title={
  188. isIdpProvisioned
  189. ? t(
  190. "This user is managed through your organization's identity provider."
  191. )
  192. : isPartnershipUser
  193. ? t('You cannot make changes to this partner-provisioned user.')
  194. : // only show this message if member can remove invites but invite was not sent by them
  195. pending && canInvite && !isInviteFromCurrentUser
  196. ? t('You cannot modify this invite.')
  197. : t('You do not have access to remove members')
  198. }
  199. icon={<IconSubtract isCircled />}
  200. >
  201. {t('Remove')}
  202. </Button>
  203. )}
  204. {showLeaveButton && memberCanLeave && (
  205. <Confirm
  206. message={tct('Are you sure you want to leave [orgName]?', {
  207. orgName: organization.slug,
  208. })}
  209. onConfirm={this.handleLeave}
  210. >
  211. <Button priority="danger" size="sm" icon={<IconClose />}>
  212. {t('Leave')}
  213. </Button>
  214. </Confirm>
  215. )}
  216. {showLeaveButton && !memberCanLeave && (
  217. <Button
  218. size="sm"
  219. icon={<IconClose />}
  220. disabled
  221. title={
  222. isIdpProvisioned
  223. ? t(
  224. "Your account is managed through your organization's identity provider."
  225. )
  226. : isPartnershipUser
  227. ? t('You cannot make changes as a partner-provisioned user.')
  228. : t(
  229. 'You cannot leave this organization as you are the only organization owner.'
  230. )
  231. }
  232. >
  233. {t('Leave')}
  234. </Button>
  235. )}
  236. </RightColumn>
  237. ) : null}
  238. </StyledPanelItem>
  239. );
  240. }
  241. }
  242. const StyledPanelItem = styled(PanelItem)`
  243. display: grid;
  244. grid-template-columns: minmax(150px, 4fr) minmax(90px, 2fr) minmax(120px, 2fr) minmax(
  245. 100px,
  246. 1fr
  247. );
  248. gap: ${space(2)};
  249. align-items: center;
  250. `;
  251. // Force action button at the end to align to right
  252. const RightColumn = styled('div')`
  253. display: flex;
  254. justify-content: flex-end;
  255. `;
  256. const Section = styled('div')`
  257. display: inline-grid;
  258. grid-template-columns: max-content auto;
  259. gap: ${space(1)};
  260. align-items: center;
  261. `;
  262. const MemberHeading = styled(Section)``;
  263. const MemberDescription = styled(Link)`
  264. overflow: hidden;
  265. `;
  266. const UserName = styled('div')`
  267. display: block;
  268. overflow: hidden;
  269. font-size: ${p => p.theme.fontSizeMedium};
  270. text-overflow: ellipsis;
  271. `;
  272. const Email = styled('div')`
  273. color: ${p => p.theme.subText};
  274. font-size: ${p => p.theme.fontSizeSmall};
  275. overflow: hidden;
  276. text-overflow: ellipsis;
  277. `;
  278. const InvitedRole = styled(Section)``;
  279. const LoadingContainer = styled('div')`
  280. margin-top: 0;
  281. margin-bottom: ${space(1.5)};
  282. `;
  283. const AuthStatus = styled(Section)``;