|
@@ -1,5 +1,4 @@
|
|
|
-import {Fragment} from 'react';
|
|
|
-import {RouteComponentProps} from 'react-router';
|
|
|
+import {Fragment, useCallback, useMemo} from 'react';
|
|
|
import styled from '@emotion/styled';
|
|
|
import moment from 'moment';
|
|
|
|
|
@@ -9,187 +8,217 @@ import {Button} from 'sentry/components/button';
|
|
|
import Confirm from 'sentry/components/confirm';
|
|
|
import DateTime from 'sentry/components/dateTime';
|
|
|
import EmptyMessage from 'sentry/components/emptyMessage';
|
|
|
+import LoadingError from 'sentry/components/loadingError';
|
|
|
+import LoadingIndicator from 'sentry/components/loadingIndicator';
|
|
|
import Panel from 'sentry/components/panels/panel';
|
|
|
import PanelBody from 'sentry/components/panels/panelBody';
|
|
|
import PanelHeader from 'sentry/components/panels/panelHeader';
|
|
|
import PanelItem from 'sentry/components/panels/panelItem';
|
|
|
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
|
|
|
import Tag from 'sentry/components/tag';
|
|
|
import {t, tct} from 'sentry/locale';
|
|
|
import {space} from 'sentry/styles/space';
|
|
|
import {UserIdentityCategory, UserIdentityConfig, UserIdentityStatus} from 'sentry/types';
|
|
|
-import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
|
|
|
+import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
|
|
|
import IdentityIcon from 'sentry/views/settings/components/identityIcon';
|
|
|
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
|
|
|
import TextBlock from 'sentry/views/settings/components/text/textBlock';
|
|
|
|
|
|
-const ENDPOINT = '/users/me/user-identities/';
|
|
|
+const EMPTY_ARRAY = [];
|
|
|
+const IDENTITIES_ENDPOINT = '/users/me/user-identities/';
|
|
|
|
|
|
-type Props = RouteComponentProps<{}, {}>;
|
|
|
-
|
|
|
-type State = {
|
|
|
- identities: UserIdentityConfig[] | null;
|
|
|
-} & DeprecatedAsyncView['state'];
|
|
|
-
|
|
|
-class AccountIdentities extends DeprecatedAsyncView<Props, State> {
|
|
|
- getDefaultState() {
|
|
|
- return {
|
|
|
- ...super.getDefaultState(),
|
|
|
- identities: [],
|
|
|
- };
|
|
|
+function itemOrder(a: UserIdentityConfig, b: UserIdentityConfig) {
|
|
|
+ function categoryRank(c: UserIdentityConfig) {
|
|
|
+ return [
|
|
|
+ UserIdentityCategory.GLOBAL_IDENTITY,
|
|
|
+ UserIdentityCategory.SOCIAL_IDENTITY,
|
|
|
+ UserIdentityCategory.ORG_IDENTITY,
|
|
|
+ ].indexOf(c.category);
|
|
|
}
|
|
|
|
|
|
- getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
|
|
|
- return [['identities', ENDPOINT]];
|
|
|
+ if (a.provider.name !== b.provider.name) {
|
|
|
+ return a.provider.name < b.provider.name ? -1 : 1;
|
|
|
}
|
|
|
-
|
|
|
- getTitle() {
|
|
|
- return t('Identities');
|
|
|
+ if (a.category !== b.category) {
|
|
|
+ return categoryRank(a) - categoryRank(b);
|
|
|
}
|
|
|
+ const nameA = a.organization?.name ?? '';
|
|
|
+ const nameB = b.organization?.name ?? '';
|
|
|
+ return nameA.localeCompare(nameB);
|
|
|
+}
|
|
|
|
|
|
- renderItem = (identity: UserIdentityConfig) => {
|
|
|
- return (
|
|
|
- <IdentityPanelItem key={`${identity.category}:${identity.id}`}>
|
|
|
- <InternalContainer>
|
|
|
- <IdentityIcon providerId={identity.provider.key} />
|
|
|
- <IdentityText isSingleLine={!identity.dateAdded}>
|
|
|
- <IdentityName>{identity.provider.name}</IdentityName>
|
|
|
- {identity.dateAdded && <IdentityDateTime date={moment(identity.dateAdded)} />}
|
|
|
- </IdentityText>
|
|
|
- </InternalContainer>
|
|
|
- <InternalContainer>
|
|
|
- <TagWrapper>
|
|
|
- {identity.category === UserIdentityCategory.SOCIAL_IDENTITY && (
|
|
|
- <Tag type="default">{t('Legacy')}</Tag>
|
|
|
- )}
|
|
|
- {identity.category !== UserIdentityCategory.ORG_IDENTITY && (
|
|
|
- <Tag type="default">
|
|
|
- {identity.isLogin ? t('Sign In') : t('Integration')}
|
|
|
- </Tag>
|
|
|
- )}
|
|
|
- {identity.organization && (
|
|
|
- <Tag type="highlight">{identity.organization.slug}</Tag>
|
|
|
- )}
|
|
|
- </TagWrapper>
|
|
|
-
|
|
|
- {this.renderButton(identity)}
|
|
|
- </InternalContainer>
|
|
|
- </IdentityPanelItem>
|
|
|
- );
|
|
|
- };
|
|
|
-
|
|
|
- renderButton(identity: UserIdentityConfig) {
|
|
|
- return identity.status === UserIdentityStatus.CAN_DISCONNECT ? (
|
|
|
- <Confirm
|
|
|
- onConfirm={() => this.handleDisconnect(identity)}
|
|
|
- priority="danger"
|
|
|
- confirmText={t('Disconnect')}
|
|
|
- message={
|
|
|
- <Fragment>
|
|
|
- <Alert type="error" showIcon>
|
|
|
- {tct('Disconnect Your [provider] Identity?', {
|
|
|
- provider: identity.provider.name,
|
|
|
- })}
|
|
|
- </Alert>
|
|
|
- <TextBlock>
|
|
|
- {identity.isLogin
|
|
|
+interface IdentityItemProps {
|
|
|
+ identity: UserIdentityConfig;
|
|
|
+ onDisconnect: (identity: UserIdentityConfig) => void;
|
|
|
+}
|
|
|
+
|
|
|
+function IdentityItem({identity, onDisconnect}: IdentityItemProps) {
|
|
|
+ return (
|
|
|
+ <IdentityPanelItem key={`${identity.category}:${identity.id}`}>
|
|
|
+ <InternalContainer>
|
|
|
+ <IdentityIcon providerId={identity.provider.key} />
|
|
|
+ <IdentityText isSingleLine={!identity.dateAdded}>
|
|
|
+ <IdentityName>{identity.provider.name}</IdentityName>
|
|
|
+ {identity.dateAdded && <IdentityDateTime date={moment(identity.dateAdded)} />}
|
|
|
+ </IdentityText>
|
|
|
+ </InternalContainer>
|
|
|
+ <InternalContainer>
|
|
|
+ <TagWrapper>
|
|
|
+ {identity.category === UserIdentityCategory.SOCIAL_IDENTITY && (
|
|
|
+ <Tag type="default">{t('Legacy')}</Tag>
|
|
|
+ )}
|
|
|
+ {identity.category !== UserIdentityCategory.ORG_IDENTITY && (
|
|
|
+ <Tag type="default">{identity.isLogin ? t('Sign In') : t('Integration')}</Tag>
|
|
|
+ )}
|
|
|
+ {identity.organization && (
|
|
|
+ <Tag type="highlight">{identity.organization.slug}</Tag>
|
|
|
+ )}
|
|
|
+ </TagWrapper>
|
|
|
+
|
|
|
+ {identity.status === UserIdentityStatus.CAN_DISCONNECT ? (
|
|
|
+ <Confirm
|
|
|
+ onConfirm={() => onDisconnect(identity)}
|
|
|
+ priority="danger"
|
|
|
+ confirmText={t('Disconnect')}
|
|
|
+ message={
|
|
|
+ <Fragment>
|
|
|
+ <Alert type="error" showIcon>
|
|
|
+ {tct('Disconnect Your [provider] Identity?', {
|
|
|
+ provider: identity.provider.name,
|
|
|
+ })}
|
|
|
+ </Alert>
|
|
|
+ <TextBlock>
|
|
|
+ {identity.isLogin
|
|
|
+ ? t(
|
|
|
+ 'After disconnecting, you will need to use a password or another identity to sign in.'
|
|
|
+ )
|
|
|
+ : t("This action can't be undone.")}
|
|
|
+ </TextBlock>
|
|
|
+ </Fragment>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <Button size="sm">{t('Disconnect')}</Button>
|
|
|
+ </Confirm>
|
|
|
+ ) : (
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ disabled
|
|
|
+ title={
|
|
|
+ identity.status === UserIdentityStatus.NEEDED_FOR_GLOBAL_AUTH
|
|
|
? t(
|
|
|
- 'After disconnecting, you will need to use a password or another identity to sign in.'
|
|
|
+ 'You need this identity to sign into your account. If you want to disconnect it, set a password first.'
|
|
|
)
|
|
|
- : t("This action can't be undone.")}
|
|
|
- </TextBlock>
|
|
|
- </Fragment>
|
|
|
- }
|
|
|
- >
|
|
|
- <Button size="sm">{t('Disconnect')}</Button>
|
|
|
- </Confirm>
|
|
|
- ) : (
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- disabled
|
|
|
- title={
|
|
|
- identity.status === UserIdentityStatus.NEEDED_FOR_GLOBAL_AUTH
|
|
|
- ? t(
|
|
|
- 'You need this identity to sign into your account. If you want to disconnect it, set a password first.'
|
|
|
- )
|
|
|
- : identity.status === UserIdentityStatus.NEEDED_FOR_ORG_AUTH
|
|
|
- ? t('You need this identity to access your organization.')
|
|
|
- : null
|
|
|
- }
|
|
|
- >
|
|
|
- {t('Disconnect')}
|
|
|
- </Button>
|
|
|
- );
|
|
|
+ : identity.status === UserIdentityStatus.NEEDED_FOR_ORG_AUTH
|
|
|
+ ? t('You need this identity to access your organization.')
|
|
|
+ : null
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {t('Disconnect')}
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ </InternalContainer>
|
|
|
+ </IdentityPanelItem>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function AccountIdentities() {
|
|
|
+ const queryClient = useQueryClient();
|
|
|
+ const {
|
|
|
+ data: identities = EMPTY_ARRAY,
|
|
|
+ isLoading,
|
|
|
+ isError,
|
|
|
+ refetch,
|
|
|
+ } = useApiQuery<UserIdentityConfig[]>([IDENTITIES_ENDPOINT], {
|
|
|
+ staleTime: 0,
|
|
|
+ });
|
|
|
+
|
|
|
+ const appIdentities = useMemo(
|
|
|
+ () =>
|
|
|
+ identities
|
|
|
+ .filter(identity => identity.category !== UserIdentityCategory.ORG_IDENTITY)
|
|
|
+ .sort(itemOrder),
|
|
|
+ [identities]
|
|
|
+ );
|
|
|
+
|
|
|
+ const orgIdentities = useMemo(
|
|
|
+ () =>
|
|
|
+ identities
|
|
|
+ .filter(identity => identity.category === UserIdentityCategory.ORG_IDENTITY)
|
|
|
+ .sort(itemOrder),
|
|
|
+ [identities]
|
|
|
+ );
|
|
|
+
|
|
|
+ const handleDisconnect = useCallback(
|
|
|
+ (identity: UserIdentityConfig) => {
|
|
|
+ disconnectIdentity(identity, () => {
|
|
|
+ setApiQueryData(queryClient, [IDENTITIES_ENDPOINT], oldData => {
|
|
|
+ if (!Array.isArray(oldData)) {
|
|
|
+ return oldData;
|
|
|
+ }
|
|
|
+
|
|
|
+ return oldData.filter(i => i.id !== identity.id);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ },
|
|
|
+ [queryClient]
|
|
|
+ );
|
|
|
+
|
|
|
+ if (isLoading) {
|
|
|
+ return <LoadingIndicator />;
|
|
|
}
|
|
|
|
|
|
- handleDisconnect = (identity: UserIdentityConfig) => {
|
|
|
- disconnectIdentity(identity, () => this.reloadData());
|
|
|
- };
|
|
|
-
|
|
|
- itemOrder = (a: UserIdentityConfig, b: UserIdentityConfig) => {
|
|
|
- function categoryRank(c: UserIdentityConfig) {
|
|
|
- return [
|
|
|
- UserIdentityCategory.GLOBAL_IDENTITY,
|
|
|
- UserIdentityCategory.SOCIAL_IDENTITY,
|
|
|
- UserIdentityCategory.ORG_IDENTITY,
|
|
|
- ].indexOf(c.category);
|
|
|
- }
|
|
|
-
|
|
|
- if (a.provider.name !== b.provider.name) {
|
|
|
- return a.provider.name < b.provider.name ? -1 : 1;
|
|
|
- }
|
|
|
- if (a.category !== b.category) {
|
|
|
- return categoryRank(a) - categoryRank(b);
|
|
|
- }
|
|
|
- if ((a.organization?.name ?? '') !== (b.organization?.name ?? '')) {
|
|
|
- return (a.organization?.name ?? '') < (b.organization?.name ?? '') ? -1 : 1;
|
|
|
- }
|
|
|
- return 0;
|
|
|
- };
|
|
|
-
|
|
|
- renderBody() {
|
|
|
- const appIdentities = this.state.identities
|
|
|
- ?.filter(identity => identity.category !== UserIdentityCategory.ORG_IDENTITY)
|
|
|
- .sort(this.itemOrder);
|
|
|
- const orgIdentities = this.state.identities
|
|
|
- ?.filter(identity => identity.category === UserIdentityCategory.ORG_IDENTITY)
|
|
|
- .sort(this.itemOrder);
|
|
|
-
|
|
|
- return (
|
|
|
- <Fragment>
|
|
|
- <SettingsPageHeader title="Identities" />
|
|
|
-
|
|
|
- <Panel>
|
|
|
- <PanelHeader>{t('Application Identities')}</PanelHeader>
|
|
|
- <PanelBody>
|
|
|
- {!appIdentities?.length ? (
|
|
|
- <EmptyMessage>
|
|
|
- {t(
|
|
|
- 'There are no application identities associated with your Sentry account'
|
|
|
- )}
|
|
|
- </EmptyMessage>
|
|
|
- ) : (
|
|
|
- appIdentities.map(this.renderItem)
|
|
|
- )}
|
|
|
- </PanelBody>
|
|
|
- </Panel>
|
|
|
-
|
|
|
- <Panel>
|
|
|
- <PanelHeader>{t('Organization Identities')}</PanelHeader>
|
|
|
- <PanelBody>
|
|
|
- {!orgIdentities?.length ? (
|
|
|
- <EmptyMessage>
|
|
|
- {t(
|
|
|
- 'There are no organization identities associated with your Sentry account'
|
|
|
- )}
|
|
|
- </EmptyMessage>
|
|
|
- ) : (
|
|
|
- orgIdentities.map(this.renderItem)
|
|
|
- )}
|
|
|
- </PanelBody>
|
|
|
- </Panel>
|
|
|
- </Fragment>
|
|
|
- );
|
|
|
+ if (isError) {
|
|
|
+ return <LoadingError onRetry={refetch} />;
|
|
|
}
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Fragment>
|
|
|
+ <SentryDocumentTitle title={t('Identities')} />
|
|
|
+ <SettingsPageHeader title="Identities" />
|
|
|
+
|
|
|
+ <Panel>
|
|
|
+ <PanelHeader>{t('Application Identities')}</PanelHeader>
|
|
|
+ <PanelBody>
|
|
|
+ {!appIdentities.length ? (
|
|
|
+ <EmptyMessage>
|
|
|
+ {t(
|
|
|
+ 'There are no application identities associated with your Sentry account'
|
|
|
+ )}
|
|
|
+ </EmptyMessage>
|
|
|
+ ) : (
|
|
|
+ appIdentities.map(identity => (
|
|
|
+ <IdentityItem
|
|
|
+ key={identity.id}
|
|
|
+ identity={identity}
|
|
|
+ onDisconnect={handleDisconnect}
|
|
|
+ />
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </PanelBody>
|
|
|
+ </Panel>
|
|
|
+
|
|
|
+ <Panel>
|
|
|
+ <PanelHeader>{t('Organization Identities')}</PanelHeader>
|
|
|
+ <PanelBody>
|
|
|
+ {!orgIdentities.length ? (
|
|
|
+ <EmptyMessage>
|
|
|
+ {t(
|
|
|
+ 'There are no organization identities associated with your Sentry account'
|
|
|
+ )}
|
|
|
+ </EmptyMessage>
|
|
|
+ ) : (
|
|
|
+ orgIdentities.map(identity => (
|
|
|
+ <IdentityItem
|
|
|
+ key={identity.id}
|
|
|
+ identity={identity}
|
|
|
+ onDisconnect={handleDisconnect}
|
|
|
+ />
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </PanelBody>
|
|
|
+ </Panel>
|
|
|
+ </Fragment>
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
const IdentityPanelItem = styled(PanelItem)`
|