organizationMemberRow.tsx 8.4 KB

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