123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302 |
- import {Fragment, PureComponent} from 'react';
- import styled from '@emotion/styled';
- import UserAvatar from 'sentry/components/avatar/userAvatar';
- import {Button} from 'sentry/components/button';
- import Confirm from 'sentry/components/confirm';
- import HookOrDefault from 'sentry/components/hookOrDefault';
- import Link from 'sentry/components/links/link';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import PanelItem from 'sentry/components/panels/panelItem';
- import {IconCheckmark, IconClose, IconFlag, IconMail, IconSubtract} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {Member, Organization} from 'sentry/types/organization';
- import type {AvatarUser} from 'sentry/types/user';
- import isMemberDisabledFromLimit from 'sentry/utils/isMemberDisabledFromLimit';
- import {capitalize} from 'sentry/utils/string/capitalize';
- type Props = {
- canAddMembers: boolean;
- canRemoveMembers: boolean;
- currentUser: AvatarUser;
- member: Member;
- memberCanLeave: boolean;
- onLeave: (member: Member) => void;
- onRemove: (member: Member) => void;
- onSendInvite: (member: Member) => void;
- organization: Organization;
- requireLink: boolean;
- status: '' | 'loading' | 'success' | 'error' | null;
- };
- type State = {
- busy: boolean;
- };
- const DisabledMemberTooltip = HookOrDefault({
- hookName: 'component:disabled-member-tooltip',
- defaultComponent: ({children}) => <Fragment>{children}</Fragment>,
- });
- export default class OrganizationMemberRow extends PureComponent<Props, State> {
- state: State = {
- busy: false,
- };
- handleRemove = () => {
- const {onRemove} = this.props;
- if (typeof onRemove !== 'function') {
- return;
- }
- this.setState({busy: true});
- onRemove(this.props.member);
- };
- handleLeave = () => {
- const {onLeave} = this.props;
- if (typeof onLeave !== 'function') {
- return;
- }
- this.setState({busy: true});
- onLeave(this.props.member);
- };
- handleSendInvite = () => {
- const {onSendInvite, member} = this.props;
- if (typeof onSendInvite !== 'function') {
- return;
- }
- onSendInvite(member);
- };
- renderMemberRole() {
- const {member} = this.props;
- const {roleName, pending, expired} = member;
- if (isMemberDisabledFromLimit(member)) {
- return <DisabledMemberTooltip>{t('Deactivated')}</DisabledMemberTooltip>;
- }
- if (pending) {
- return (
- <InvitedRole>
- <IconMail size="md" />
- {expired ? t('Expired Invite') : tct('Invited [roleName]', {roleName})}
- </InvitedRole>
- );
- }
- return <Fragment>{capitalize(member.orgRole)}</Fragment>;
- }
- render() {
- const {
- member,
- organization,
- status,
- requireLink,
- memberCanLeave,
- currentUser,
- canRemoveMembers,
- canAddMembers,
- } = this.props;
- const {id, flags, email, name, pending, user} = member;
- // if member is not the only owner, they can leave
- const isIdpProvisioned = flags['idp:provisioned'];
- const isPartnershipUser = flags['partnership:restricted'];
- const needsSso = !flags['sso:linked'] && requireLink;
- const isCurrentUser = currentUser.email === email;
- const showRemoveButton = !isCurrentUser;
- const showLeaveButton = isCurrentUser;
- const canRemoveMember =
- canRemoveMembers && !isCurrentUser && !isIdpProvisioned && !isPartnershipUser;
- // member has a `user` property if they are registered with sentry
- // i.e. has accepted an invite to join org
- const has2fa = user?.has2fa;
- const detailsUrl = `/settings/${organization.slug}/members/${id}/`;
- const isInviteSuccessful = status === 'success';
- const isInviting = status === 'loading';
- const showResendButton = pending || needsSso;
- return (
- <StyledPanelItem data-test-id={email}>
- <MemberHeading>
- <UserAvatar
- size={32}
- user={user ?? {email, id: email, name: email, type: 'user'}}
- />
- <MemberDescription to={detailsUrl}>
- <h5 style={{margin: '0 0 3px'}}>
- <UserName>{name}</UserName>
- </h5>
- <Email>{email}</Email>
- </MemberDescription>
- </MemberHeading>
- <div data-test-id="member-role">{this.renderMemberRole()}</div>
- <div data-test-id="member-status">
- {showResendButton ? (
- <Fragment>
- {isInviting && (
- <LoadingContainer>
- <LoadingIndicator mini />
- </LoadingContainer>
- )}
- {isInviteSuccessful && <span>{t('Sent!')}</span>}
- {!isInviting && !isInviteSuccessful && (
- <Button
- disabled={!canAddMembers}
- priority="primary"
- size="sm"
- onClick={this.handleSendInvite}
- >
- {pending ? t('Resend invite') : t('Resend SSO link')}
- </Button>
- )}
- </Fragment>
- ) : (
- <AuthStatus>
- {has2fa ? (
- <IconCheckmark isCircled color="success" />
- ) : (
- <IconFlag color="error" />
- )}
- {has2fa ? t('2FA Enabled') : t('2FA Not Enabled')}
- </AuthStatus>
- )}
- </div>
- {showRemoveButton || showLeaveButton ? (
- <RightColumn>
- {showRemoveButton && canRemoveMember && (
- <Confirm
- message={tct('Are you sure you want to remove [name] from [orgName]?', {
- name,
- orgName: organization.slug,
- })}
- onConfirm={this.handleRemove}
- >
- <Button
- data-test-id="remove"
- icon={<IconSubtract isCircled />}
- size="sm"
- busy={this.state.busy}
- >
- {t('Remove')}
- </Button>
- </Confirm>
- )}
- {showRemoveButton && !canRemoveMember && (
- <Button
- disabled
- size="sm"
- title={
- isIdpProvisioned
- ? t(
- "This user is managed through your organization's identity provider."
- )
- : isPartnershipUser
- ? t('You cannot make changes to this partner-provisioned user.')
- : t('You do not have access to remove members')
- }
- icon={<IconSubtract isCircled />}
- >
- {t('Remove')}
- </Button>
- )}
- {showLeaveButton && memberCanLeave && (
- <Confirm
- message={tct('Are you sure you want to leave [orgName]?', {
- orgName: organization.slug,
- })}
- onConfirm={this.handleLeave}
- >
- <Button priority="danger" size="sm" icon={<IconClose />}>
- {t('Leave')}
- </Button>
- </Confirm>
- )}
- {showLeaveButton && !memberCanLeave && (
- <Button
- size="sm"
- icon={<IconClose />}
- disabled
- title={
- isIdpProvisioned
- ? t(
- "Your account is managed through your organization's identity provider."
- )
- : isPartnershipUser
- ? t('You cannot make changes as a partner-provisioned user.')
- : t(
- 'You cannot leave this organization as you are the only organization owner.'
- )
- }
- >
- {t('Leave')}
- </Button>
- )}
- </RightColumn>
- ) : null}
- </StyledPanelItem>
- );
- }
- }
- const StyledPanelItem = styled(PanelItem)`
- display: grid;
- grid-template-columns: minmax(150px, 4fr) minmax(90px, 2fr) minmax(120px, 2fr) minmax(
- 100px,
- 1fr
- );
- gap: ${space(2)};
- align-items: center;
- `;
- // Force action button at the end to align to right
- const RightColumn = styled('div')`
- display: flex;
- justify-content: flex-end;
- `;
- const Section = styled('div')`
- display: inline-grid;
- grid-template-columns: max-content auto;
- gap: ${space(1)};
- align-items: center;
- `;
- const MemberHeading = styled(Section)``;
- const MemberDescription = styled(Link)`
- overflow: hidden;
- `;
- const UserName = styled('div')`
- display: block;
- overflow: hidden;
- font-size: ${p => p.theme.fontSizeMedium};
- text-overflow: ellipsis;
- `;
- const Email = styled('div')`
- color: ${p => p.theme.subText};
- font-size: ${p => p.theme.fontSizeSmall};
- overflow: hidden;
- text-overflow: ellipsis;
- `;
- const InvitedRole = styled(Section)``;
- const LoadingContainer = styled('div')`
- margin-top: 0;
- margin-bottom: ${space(1.5)};
- `;
- const AuthStatus = styled(Section)``;
|