import {Fragment, useState} from 'react';
import styled from '@emotion/styled';
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import {resendMemberInvite} from 'sentry/actionCreators/members';
import {openInviteMembersModal} from 'sentry/actionCreators/modal';
import {redirectToRemainingOrganization} from 'sentry/actionCreators/organizations';
import FeatureDisabled from 'sentry/components/acl/featureDisabled';
import {Button} from 'sentry/components/button';
import EmptyMessage from 'sentry/components/emptyMessage';
import HookOrDefault from 'sentry/components/hookOrDefault';
import {Hovercard} from 'sentry/components/hovercard';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import Pagination from 'sentry/components/pagination';
import Panel from 'sentry/components/panels/panel';
import PanelBody from 'sentry/components/panels/panelBody';
import PanelHeader from 'sentry/components/panels/panelHeader';
import SearchBar from 'sentry/components/searchBar';
import {Tooltip} from 'sentry/components/tooltip';
import {ORG_ROLES} from 'sentry/constants';
import {IconMail} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import ConfigStore from 'sentry/stores/configStore';
import {space} from 'sentry/styles/space';
import type {OrganizationAuthProvider} from 'sentry/types/auth';
import type {Member} from 'sentry/types/organization';
import {trackAnalytics} from 'sentry/utils/analytics';
import {
type ApiQueryKey,
setApiQueryData,
useApiQuery,
useQueryClient,
} from 'sentry/utils/queryClient';
import useApi from 'sentry/utils/useApi';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import useOrganization from 'sentry/utils/useOrganization';
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
import InviteBanner from 'sentry/views/settings/organizationMembers/inviteBanner';
import MembersFilter from './components/membersFilter';
import InviteRequestRow from './inviteRequestRow';
import OrganizationMemberRow from './organizationMemberRow';
const MemberListHeader = HookOrDefault({
hookName: 'component:member-list-header',
defaultComponent: () => {t('Active Members')},
});
const InviteMembersButtonHook = HookOrDefault({
hookName: 'member-invite-button:customization',
defaultComponent: ({children, organization, onTriggerModal}) => {
const isSsoRequired = organization.requiresSso;
const disabled = isSsoRequired || !organization.features.includes('invite-members');
return children({disabled, isSsoRequired, onTriggerModal});
},
});
const getMembersQueryKey = ({
orgSlug,
query,
}: {
orgSlug: string;
query: Record;
}): ApiQueryKey => [`/organizations/${orgSlug}/members/`, {query}];
const getInviteRequestsQueryKey = ({organization}): ApiQueryKey => [
`/organizations/${organization.slug}/invite-requests/`,
];
function OrganizationMembersList() {
const queryClient = useQueryClient();
const api = useApi({persistInFlight: true});
const organization = useOrganization();
const navigate = useNavigate();
const location = useLocation();
const {data: inviteRequests = [], refetch: refetchInviteRequests} = useApiQuery<
Member[]
>(getInviteRequestsQueryKey({organization}), {staleTime: 0});
const {data: authProvider} = useApiQuery(
[`/organizations/${organization.slug}/auth-provider/`],
{staleTime: 0}
);
const {data: currentMember} = useApiQuery(
[`/organizations/${organization.slug}/members/me/`],
{staleTime: 0}
);
const {
data: members = [],
isLoading: isLoadingMembers,
refetch: refetchMembers,
getResponseHeader,
} = useApiQuery(
getMembersQueryKey({
orgSlug: organization.slug,
query: {
query: location.query.query as string,
cursor: location.query.cursor as string,
},
}),
{staleTime: 0}
);
const [invited, setInvited] = useState<{
[memberId: string]: 'loading' | 'success' | null;
}>({});
const removeMember = async (id: string) => {
await api.requestPromise(`/organizations/${organization.slug}/members/${id}/`, {
method: 'DELETE',
data: {},
});
setApiQueryData(
queryClient,
getMembersQueryKey({
orgSlug: organization.slug,
query: {
query: location.query.query as string,
cursor: location.query.cursor as string,
},
}),
currentMembers => currentMembers?.filter(member => member.id !== id)
);
};
const handleRemove = async ({id, name}: Member) => {
const {slug: orgName} = organization;
try {
await removeMember(id);
} catch {
addErrorMessage(tct('Error removing [name] from [orgName]', {name, orgName}));
return;
}
addSuccessMessage(tct('Removed [name] from [orgName]', {name, orgName}));
};
const handleLeave = async ({id}: Member) => {
try {
await removeMember(id);
} catch {
addErrorMessage(tct('Error leaving [orgName]', {orgName: organization.slug}));
return;
}
redirectToRemainingOrganization({
orgId: organization.slug,
removeOrg: true,
});
addSuccessMessage(tct('You left [orgName]', {orgName: organization.slug}));
};
const handleSendInvite = async ({id, expired}) => {
setInvited(state => ({...state, [id]: 'loading'}));
try {
await resendMemberInvite(api, {
orgId: organization.slug,
memberId: id,
regenerate: expired,
});
} catch {
setInvited(state => ({...state, [id]: null}));
addErrorMessage(t('Error sending invite'));
return;
}
setInvited(state => ({...state, [id]: 'success'}));
};
const updateInviteRequest = (id: string, data: Partial) => {
setApiQueryData(
queryClient,
getInviteRequestsQueryKey({organization}),
curentInviteRequests => {
const newInviteRequests = curentInviteRequests.map(request => {
if (request.id === id) {
return {...request, ...data};
}
return request;
});
return newInviteRequests;
}
);
};
const removeInviteRequest = (id: string) => {
setApiQueryData(
queryClient,
getInviteRequestsQueryKey({organization}),
curentInviteRequests => curentInviteRequests?.filter(request => request.id !== id)
);
};
const handleInviteRequestAction = async ({
inviteRequest,
method,
data,
successMessage,
errorMessage,
eventKey,
}) => {
try {
await api.requestPromise(
`/organizations/${organization.slug}/invite-requests/${inviteRequest.id}/`,
{
method,
data,
}
);
removeInviteRequest(inviteRequest.id);
addSuccessMessage(successMessage);
trackAnalytics(eventKey, {
member_id: parseInt(inviteRequest.id, 10),
invite_status: inviteRequest.inviteStatus,
organization,
});
} catch {
addErrorMessage(errorMessage);
}
};
const handleInviteRequestApprove = (inviteRequest: Member) => {
handleInviteRequestAction({
inviteRequest,
method: 'PUT',
data: {
role: inviteRequest.role,
teams: inviteRequest.teams,
approve: 1,
},
successMessage: tct('[email] has been invited', {email: inviteRequest.email}),
errorMessage: tct('Error inviting [email]', {email: inviteRequest.email}),
eventKey: 'invite_request.approved',
});
};
const handleInviteRequestDeny = (inviteRequest: Member) => {
handleInviteRequestAction({
inviteRequest,
method: 'DELETE',
data: {},
successMessage: tct('Invite request for [email] denied', {
email: inviteRequest.email,
}),
errorMessage: tct('Error denying invite request for [email]', {
email: inviteRequest.email,
}),
eventKey: 'invite_request.denied',
});
};
const handleQueryChange = (query: string) => {
navigate({
pathname: location.pathname,
query: {...location.query, query, cursor: undefined},
});
};
const canAddMembers = organization.access.includes('member:write');
const canRemove = organization.access.includes('member:admin');
const currentUser = ConfigStore.get('user');
// Find out if current user is the only owner
const isOnlyOwner = !members.find(
({role, email, pending}) =>
role === 'owner' && email !== currentUser.email && !pending
);
// Only admins/owners can remove members
const requireLink = !!authProvider && authProvider.require_link;
const searchQuery = (location.query.query as string) || '';
const membersPageLinks = getResponseHeader?.('Link');
const action = (
{
openInviteMembersModal({
onClose: () => {
refetchInviteRequests();
refetchMembers();
},
source: 'members_settings',
});
}}
>
{({disabled, isSsoRequired, onTriggerModal}) => (
)}
);
return (
{
refetchMembers();
}}
onModalClose={() => {
refetchInviteRequests();
refetchMembers();
}}
allowedRoles={currentMember ? currentMember.roles : ORG_ROLES}
/>
{inviteRequests.length > 0 && (
{t('Pending Members')}
{t('Role')}
{t('Teams')}
{inviteRequests.map(inviteRequest => (
updateInviteRequest(inviteRequest.id, data)}
/>
))}
)}
{isLoadingMembers ? (
) : (
{members.map(member => (
))}
{members.length === 0 && (
{t('No members found.')}
)}
)}
);
}
const SearchWrapperWithFilter = styled('div')`
position: relative;
display: grid;
grid-template-columns: max-content 1fr;
gap: ${space(1.5)};
margin-bottom: ${space(1.5)};
`;
const StyledPanelItem = styled('div')`
display: grid;
grid-template-columns: minmax(150px, auto) minmax(100px, 140px) 420px;
gap: ${space(2)};
align-items: center;
width: 100%;
`;
export default OrganizationMembersList;
function InviteMembersButton({
disabled,
isSsoRequired,
onTriggerModal,
}: {
onTriggerModal: () => void;
disabled?: boolean;
isSsoRequired?: boolean;
}) {
const action = (
}
disabled={disabled}
>
{t('Invite Members')}
);
return disabled ? (
isSsoRequired ? (
{action}
) : (
}
>
{action}
)
) : (
action
);
}