Browse Source

ref(notification platform): Warn if identity not linked (#28003)

* ref(notification platform): Warn if identity not linked
Colleen O'Rourke 3 years ago
parent
commit
2833297680

+ 27 - 0
src/sentry/api/endpoints/user_identity.py

@@ -0,0 +1,27 @@
+from sentry.api.bases.user import UserEndpoint
+from sentry.api.paginator import OffsetPaginator
+from sentry.api.serializers import serialize
+from sentry.models import Identity
+
+
+class UserIdentityEndpoint(UserEndpoint):
+    def get(self, request, user):
+        """
+        Retrieve all of a users' identities (NOT AuthIdentities)
+        `````````````````````````````````
+
+        :pparam string user ID: user ID, or 'me'
+        :auth: required
+        """
+        queryset = Identity.objects.filter(user=user)
+
+        provider = request.GET.get("provider")
+        if provider:
+            queryset = queryset.filter(idp__type=provider.lower())
+
+        return self.paginate(
+            request=request,
+            queryset=queryset,
+            on_results=lambda x: serialize(x, request.user),
+            paginator_cls=OffsetPaginator,
+        )

+ 31 - 0
src/sentry/api/endpoints/user_organizationintegrations.py

@@ -0,0 +1,31 @@
+from sentry.api.bases.user import UserEndpoint
+from sentry.api.paginator import OffsetPaginator
+from sentry.api.serializers import serialize
+from sentry.models import ObjectStatus, OrganizationIntegration
+
+
+class UserOrganizationIntegrationsEndpoint(UserEndpoint):
+    def get(self, request, user):
+        """
+        Retrieve all of a users' organization integrations
+        `````````````````````````````````
+
+        :pparam string user ID: user ID, or 'me'
+        :qparam string provider: optional provider to filter by
+        :auth: required
+        """
+        queryset = OrganizationIntegration.objects.filter(
+            organization__in=user.get_orgs(),
+            status=ObjectStatus.VISIBLE,
+            integration__status=ObjectStatus.VISIBLE,
+        )
+        provider = request.GET.get("provider")
+        if provider:
+            queryset = queryset.filter(integration__provider=provider.lower())
+
+        return self.paginate(
+            request=request,
+            queryset=queryset,
+            on_results=lambda x: serialize(x, request.user),
+            paginator_cls=OffsetPaginator,
+        )

+ 13 - 0
src/sentry/api/serializers/models/identity.py

@@ -0,0 +1,13 @@
+from sentry.api.serializers import Serializer, register, serialize
+from sentry.models import Identity
+
+
+@register(Identity)
+class IdentitySerializer(Serializer):
+    def serialize(self, obj, attrs, user):
+        return {
+            "id": str(obj.id),
+            "identityProvider": serialize(obj.idp),
+            "externalId": obj.external_id,
+            "status": obj.status,
+        }

+ 12 - 0
src/sentry/api/serializers/models/identityprovider.py

@@ -0,0 +1,12 @@
+from sentry.api.serializers import Serializer, register
+from sentry.models import IdentityProvider
+
+
+@register(IdentityProvider)
+class IdentityProviderSerializer(Serializer):
+    def serialize(self, obj, attrs, user):
+        return {
+            "id": str(obj.id),
+            "type": obj.type,
+            "externalId": obj.external_id,
+        }

+ 7 - 1
src/sentry/api/serializers/models/integration.py

@@ -134,7 +134,13 @@ class OrganizationIntegrationSerializer(Serializer):  # type: ignore
                 }
                 logger.info(name, extra=log_info)
 
-        integration.update({"configData": config_data})
+        integration.update(
+            {
+                "configData": config_data,
+                "externalId": obj.integration.external_id,
+                "organizationId": obj.organization.id,
+            }
+        )
 
         if dynamic_display_information:
             integration.update({"dynamicDisplayInformation": dynamic_display_information})

+ 12 - 0
src/sentry/api/urls.py

@@ -403,12 +403,14 @@ from .endpoints.user_authenticator_index import UserAuthenticatorIndexEndpoint
 from .endpoints.user_details import UserDetailsEndpoint
 from .endpoints.user_emails import UserEmailsEndpoint
 from .endpoints.user_emails_confirm import UserEmailsConfirmEndpoint
+from .endpoints.user_identity import UserIdentityEndpoint
 from .endpoints.user_identity_details import UserIdentityDetailsEndpoint
 from .endpoints.user_index import UserIndexEndpoint
 from .endpoints.user_ips import UserIPsEndpoint
 from .endpoints.user_notification_details import UserNotificationDetailsEndpoint
 from .endpoints.user_notification_fine_tuning import UserNotificationFineTuningEndpoint
 from .endpoints.user_notification_settings_details import UserNotificationSettingsDetailsEndpoint
+from .endpoints.user_organizationintegrations import UserOrganizationIntegrationsEndpoint
 from .endpoints.user_organizations import UserOrganizationsEndpoint
 from .endpoints.user_password import UserPasswordEndpoint
 from .endpoints.user_social_identities_index import UserSocialIdentitiesIndexEndpoint
@@ -633,6 +635,11 @@ urlpatterns = [
                     UserIdentityDetailsEndpoint.as_view(),
                     name="sentry-api-0-user-identity-details",
                 ),
+                url(
+                    r"^(?P<user_id>[^\/]+)/identities/$",
+                    UserIdentityEndpoint.as_view(),
+                    name="sentry-api-0-user-identity",
+                ),
                 url(
                     r"^(?P<user_id>[^\/]+)/ips/$",
                     UserIPsEndpoint.as_view(),
@@ -678,6 +685,11 @@ urlpatterns = [
                     UserSubscriptionsEndpoint.as_view(),
                     name="sentry-api-0-user-subscriptions",
                 ),
+                url(
+                    r"^(?P<user_id>[^\/]+)/organization-integrations/$",
+                    UserOrganizationIntegrationsEndpoint.as_view(),
+                    name="sentry-api-0-user-organization-integrations",
+                ),
             ]
         ),
     ),

+ 54 - 6
static/app/views/settings/account/notifications/notificationSettingsByType.tsx

@@ -2,6 +2,8 @@ import React from 'react';
 
 import AsyncComponent from 'app/components/asyncComponent';
 import {t} from 'app/locale';
+import {Organization, OrganizationSummary} from 'app/types';
+import withOrganizations from 'app/utils/withOrganizations';
 import {
   NotificationSettingsByProviderObject,
   NotificationSettingsObject,
@@ -11,6 +13,11 @@ import {ACCOUNT_NOTIFICATION_FIELDS} from 'app/views/settings/account/notificati
 import {NOTIFICATION_SETTING_FIELDS} from 'app/views/settings/account/notifications/fields2';
 import NotificationSettingsByOrganization from 'app/views/settings/account/notifications/notificationSettingsByOrganization';
 import NotificationSettingsByProjects from 'app/views/settings/account/notifications/notificationSettingsByProjects';
+import {
+  Identity,
+  OrganizationIntegration,
+} from 'app/views/settings/account/notifications/types';
+import UnlinkedAlert from 'app/views/settings/account/notifications/unlinkedAlert';
 import {
   getCurrentDefault,
   getCurrentProviders,
@@ -31,10 +38,13 @@ import TextBlock from 'app/views/settings/components/text/textBlock';
 
 type Props = {
   notificationType: string;
+  organizations: Organization[];
 } & AsyncComponent['props'];
 
 type State = {
   notificationSettings: NotificationSettingsObject;
+  identities: Identity[];
+  organizationIntegrations: OrganizationIntegration[];
 } & AsyncComponent['state'];
 
 class NotificationSettingsByType extends AsyncComponent<Props, State> {
@@ -42,14 +52,26 @@ class NotificationSettingsByType extends AsyncComponent<Props, State> {
     return {
       ...super.getDefaultState(),
       notificationSettings: {},
+      identities: [],
+      organizationIntegrations: [],
     };
   }
 
   getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
     const {notificationType} = this.props;
-
-    const query = {type: notificationType};
-    return [['notificationSettings', `/users/me/notification-settings/`, {query}]];
+    return [
+      [
+        'notificationSettings',
+        `/users/me/notification-settings/`,
+        {query: {type: notificationType}},
+      ],
+      ['identities', `/users/me/identities/`, {query: {provider: 'slack'}}],
+      [
+        'organizationIntegrations',
+        `/users/me/organization-integrations/`,
+        {query: {provider: 'slack'}},
+      ],
+    ];
   }
 
   /* Methods responsible for updating state and hitting the API. */
@@ -163,16 +185,42 @@ class NotificationSettingsByType extends AsyncComponent<Props, State> {
     return fields as FieldObject[];
   }
 
+  getUnlinkedOrgs = (): OrganizationSummary[] => {
+    const {organizations} = this.props;
+    const {identities, organizationIntegrations} = this.state;
+    const integrationExternalIDsByOrganizationID = Object.fromEntries(
+      organizationIntegrations.map(organizationIntegration => [
+        organizationIntegration.organizationId,
+        organizationIntegration.externalId,
+      ])
+    );
+
+    const identitiesByExternalId = Object.fromEntries(
+      identities.map(identity => [identity?.identityProvider?.externalId, identity])
+    );
+
+    return organizations.filter(organization => {
+      const externalID = integrationExternalIDsByOrganizationID[organization.id];
+      const identity = identitiesByExternalId[externalID];
+      return identity === undefined || identity === null;
+    });
+  };
+
   renderBody() {
     const {notificationType} = this.props;
     const {notificationSettings} = this.state;
-
+    const hasSlack = getCurrentProviders(notificationType, notificationSettings).includes(
+      'slack'
+    );
+    const unlinkedOrgs = this.getUnlinkedOrgs();
     const {title, description} = ACCOUNT_NOTIFICATION_FIELDS[notificationType];
-
     return (
       <React.Fragment>
         <SettingsPageHeader title={title} />
         {description && <TextBlock>{description}</TextBlock>}
+        {hasSlack && unlinkedOrgs.length > 0 && (
+          <UnlinkedAlert organizations={unlinkedOrgs} />
+        )}
         <FeedbackAlert />
         <Form
           saveOnBlur
@@ -208,4 +256,4 @@ class NotificationSettingsByType extends AsyncComponent<Props, State> {
   }
 }
 
-export default NotificationSettingsByType;
+export default withOrganizations(NotificationSettingsByType);

+ 47 - 0
static/app/views/settings/account/notifications/types.tsx

@@ -0,0 +1,47 @@
+type ProviderAlert = {
+  type?: string;
+  text?: string;
+};
+
+type Provider = {
+  key?: string;
+  slug?: string;
+  name?: string;
+  canAdd?: boolean;
+  canDisable?: boolean;
+  features?: string[];
+  aspects?: {
+    alerts?: ProviderAlert[];
+  };
+};
+
+type ConfigOrganization = any; // this is very complicated and unimportant
+
+export type OrganizationIntegration = {
+  id?: string;
+  name?: string;
+  icon?: string;
+  domainName?: string;
+  accountType?: string;
+  status?: string;
+  provider?: Provider;
+  configOrganization?: ConfigOrganization[];
+  configData: {
+    installationType?: string;
+  };
+  organizationId?: number;
+  externalId?: string;
+};
+
+type IdentityProvider = {
+  id?: string;
+  type?: string;
+  externalId?: string;
+};
+
+export type Identity = {
+  id?: string;
+  identityProvider?: IdentityProvider;
+  externalId?: string;
+  status?: string;
+};

+ 34 - 0
static/app/views/settings/account/notifications/unlinkedAlert.tsx

@@ -0,0 +1,34 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import Alert from 'app/components/alert';
+import {IconWarning} from 'app/icons';
+import {t} from 'app/locale';
+import {OrganizationSummary} from 'app/types';
+
+type Props = {
+  organizations: OrganizationSummary[];
+};
+
+class UnlinkedAlert extends React.Component<Props> {
+  render = () => {
+    const {organizations} = this.props;
+    return (
+      <StyledAlert type="warning" icon={<IconWarning />}>
+        {t(
+          'You\'ve selected Slack as your delivery method, but do not have a linked account for the following organizations. You\'ll receive email notifications instead until you type "/sentry link" into your Slack workspace to link your account. If slash commands are not working, please re-install the Slack integration.'
+        )}
+        <ul>
+          {organizations.map(organization => (
+            <li key={organization.id}>{organization.slug}</li>
+          ))}
+        </ul>
+      </StyledAlert>
+    );
+  };
+}
+const StyledAlert = styled(Alert)`
+  margin: 20px 0px;
+`;
+
+export default UnlinkedAlert;

+ 30 - 0
tests/fixtures/js-stubs/organizationIntegrations.js

@@ -0,0 +1,30 @@
+export const OrganizationIntegrations = organizationId => ({
+  id: '15',
+  name: 'hb-testing',
+  icon: 'https://a.slack-edge.com/80588/img/avatars-teams/ava_0012-132.png',
+  domainName: 'hb-testing.slack.com',
+  accountType: null,
+  status: 'active',
+  provider: {
+    key: 'slack',
+    slug: 'slack',
+    name: 'Slack',
+    canAdd: true,
+    canDisable: false,
+    features: ['alert-rule', 'chat-unfurl'],
+    aspects: {
+      alerts: [
+        {
+          type: 'info',
+          text: 'The Slack integration adds a new Alert Rule action to all projects. To enable automatic notifications sent to Slack you must create a rule using the slack workspace action in your project settings.',
+        },
+      ],
+    },
+  },
+  configOrganization: [],
+  configData: {
+    installationType: 'born_as_bot',
+  },
+  organizationId,
+  externalId: 'TA99AB9CD',
+});

Some files were not shown because too many files changed in this diff