Browse Source

ref(app-store-connect): Add app store connect modal url & expiration alert (#26303)

Priscila Oliveira 3 years ago
parent
commit
f12dd97020

+ 8 - 3
src/sentry/api/endpoints/project_app_store_connect_credentials.py

@@ -60,6 +60,11 @@ from rest_framework.response import Response
 
 from sentry import features
 from sentry.api.bases.project import ProjectEndpoint, StrictProjectPermission
+from sentry.api.exceptions import (
+    AppConnectAuthenticationError,
+    ItunesAuthenticationError,
+    ItunesTwoFactorAuthenticationRequired,
+)
 from sentry.models import Project
 from sentry.utils import fernet_encrypt as encrypt
 from sentry.utils import json
@@ -180,7 +185,7 @@ class AppStoreConnectAppsEndpoint(ProjectEndpoint):
         apps = appstore_connect.get_apps(session, credentials)
 
         if apps is None:
-            return Response("App connect authentication error.", status=401)
+            raise AppConnectAuthenticationError()
 
         apps = [{"name": app.name, "bundleId": app.bundle_id, "appId": app.app_id} for app in apps]
         result = {"apps": apps}
@@ -547,7 +552,7 @@ class AppStoreConnectStartAuthEndpoint(ProjectEndpoint):
             session, service_key=auth_key, account_name=user_name, password=password
         )
         if init_login_result is None:
-            return Response("ITunes login failed.", status=401)
+            raise ItunesAuthenticationError()
 
         # send session context to be used in next calls
         session_context = {
@@ -774,7 +779,7 @@ class AppStoreConnect2FactorAuthEndpoint(ProjectEndpoint):
 
                 return Response(response_body, status=200)
             else:
-                return Response("2FA failed.", status=401)
+                raise ItunesTwoFactorAuthenticationRequired()
 
         except ValueError:
             return Response("Invalid validation context passed.", status=400)

+ 18 - 0
src/sentry/api/exceptions.py

@@ -97,6 +97,24 @@ class TwoFactorRequired(SentryAPIException):
     message = "Organization requires two-factor authentication to be enabled"
 
 
+class AppConnectAuthenticationError(SentryAPIException):
+    status_code = status.HTTP_401_UNAUTHORIZED
+    code = "app-connect-authentication-error"
+    message = "App connect authentication error"
+
+
+class ItunesAuthenticationError(SentryAPIException):
+    status_code = status.HTTP_401_UNAUTHORIZED
+    code = "itunes-authentication-error"
+    message = "Itunes authentication error"
+
+
+class ItunesTwoFactorAuthenticationRequired(SentryAPIException):
+    status_code = status.HTTP_401_UNAUTHORIZED
+    code = "itunes-2fa-required"
+    message = "Itunes requires two-factor authentication to be enabled"
+
+
 class ConflictError(APIException):
     status_code = status.HTTP_409_CONFLICT
 

+ 8 - 4
src/sentry/lang/native/symbolicator.py

@@ -48,6 +48,7 @@ COMMON_SOURCE_PROPERTIES = {
     "filetypes": {"type": "array", "items": {"type": "string", "enum": list(VALID_FILE_TYPES)}},
 }
 
+
 APP_STORE_CONNECT_SCHEMA = {
     "type": "object",
     "properties": {
@@ -56,23 +57,26 @@ APP_STORE_CONNECT_SCHEMA = {
         "name": {"type": "string"},
         "appconnectIssuer": {"type": "string", "minLength": 36, "maxLength": 36},
         "appconnectKey": {"type": "string", "minLength": 2, "maxLength": 20},
+        "appconnectPrivateKey": {"type": "string"},
         "itunesUser": {"type": "string", "minLength": 1, "maxLength": 100},
+        "itunesCreated": {"type": "string"},
+        "itunesPassword": {"type": "string"},
         "appName": {"type": "string", "minLength": 1, "maxLength": 512},
         "appId": {"type": "string", "minLength": 1, "maxLength": 512},
         "orgId": {"type": "integer"},
         "orgName": {"type": "string", "minLength": 1, "maxLength": 512},
         "encrypted": {"type": "string"},
-        "itunesCreated": {"type": "string"},
-        "itunesPassword": {"type": "string"},
-        "appconnectPrivateKey": {"type": "string"},
     },
     "required": [
+        "type",
         "id",
         "name",
-        "type",
         "appconnectIssuer",
         "appconnectKey",
+        "appconnectPrivateKey",
         "itunesUser",
+        "itunesCreated",
+        "itunesPassword",
         "appName",
         "appId",
         "orgId",

+ 0 - 0
src/sentry/utils/appleconnect/__init__.py


+ 1 - 0
src/sentry/utils/prompts.py

@@ -2,6 +2,7 @@ DEFAULT_PROMPTS = {
     "releases": {"required_fields": ["organization_id", "project_id"]},
     "suspect_commits": {"required_fields": ["organization_id", "project_id"]},
     "alert_stream": {"required_fields": ["organization_id"]},
+    "app_store_connect_updates": {"required_fields": ["organization_id", "project_id"]},
     "sdk_updates": {"required_fields": ["organization_id"]},
     "suggest_mobile_project": {"required_fields": ["organization_id"]},
     "stacktrace_link": {"required_fields": ["organization_id", "project_id"]},

+ 12 - 3
static/app/actionCreators/modal.tsx

@@ -4,6 +4,7 @@ import ModalActions from 'app/actions/modalActions';
 import GlobalModal from 'app/components/globalModal';
 import type {DashboardWidgetModalOptions} from 'app/components/modals/addDashboardWidgetModal';
 import type {ReprocessEventModalOptions} from 'app/components/modals/reprocessEventModal';
+import {AppStoreConnectContextProps} from 'app/components/projects/appStoreConnectContext';
 import {
   DebugFileSource,
   Group,
@@ -196,16 +197,24 @@ export type SentryAppDetailsModalOptions = {
 type DebugFileSourceModalOptions = {
   sourceType: DebugFileSource;
   onSave: (data: Record<string, string>) => void;
+  appStoreConnectContext?: AppStoreConnectContextProps;
+  onClose?: () => void;
   sourceConfig?: Record<string, string>;
 };
 
-export async function openDebugFileSourceModal(options: DebugFileSourceModalOptions) {
+export async function openDebugFileSourceModal({
+  onClose,
+  ...restOptions
+}: DebugFileSourceModalOptions) {
   const mod = await import(
     /* webpackChunkName: "DebugFileCustomRepository" */ 'app/components/modals/debugFileCustomRepository'
   );
-  const {default: Modal, modalCss} = mod;
 
-  openModal(deps => <Modal {...deps} {...options} />, {backdrop: 'static', modalCss});
+  const {default: Modal, modalCss} = mod;
+  openModal(deps => <Modal {...deps} {...restOptions} />, {
+    modalCss,
+    onClose,
+  });
 }
 
 export async function openInviteMembersModal(options = {}) {

+ 10 - 1
static/app/api.tsx

@@ -79,7 +79,16 @@ export const initApiClientErrorHandling = () =>
 
     // 401s can also mean sudo is required or it's a request that is allowed to fail
     // Ignore if these are the cases
-    if (['sudo-required', 'ignore', '2fa-required'].includes(code)) {
+    if (
+      [
+        'sudo-required',
+        'ignore',
+        '2fa-required',
+        'app-connect-authentication-error',
+        'itunes-authentication-error',
+        'itunes-2fa-required',
+      ].includes(code)
+    ) {
       return;
     }
 

+ 30 - 0
static/app/components/globalAppStoreConnectUpdateAlert/index.tsx

@@ -0,0 +1,30 @@
+import * as AppStoreConnectContext from 'app/components/projects/appStoreConnectContext';
+import {Organization, Project} from 'app/types';
+
+import UpdateAlert from './updateAlert';
+
+type Props = Pick<
+  React.ComponentProps<typeof UpdateAlert>,
+  'isCompact' | 'className' | 'Wrapper'
+> & {
+  organization: Organization;
+  project?: Project;
+};
+
+function GlobalAppStoreConnectUpdateAlert({project, organization, ...rest}: Props) {
+  const hasAppConnectStoreFeatureFlag = !!organization.features?.includes(
+    'app-store-connect'
+  );
+
+  if (!hasAppConnectStoreFeatureFlag) {
+    return null;
+  }
+
+  return (
+    <AppStoreConnectContext.Provider project={project} orgSlug={organization.slug}>
+      <UpdateAlert project={project} organization={organization} {...rest} />
+    </AppStoreConnectContext.Provider>
+  );
+}
+
+export default GlobalAppStoreConnectUpdateAlert;

+ 175 - 0
static/app/components/globalAppStoreConnectUpdateAlert/updateAlert.tsx

@@ -0,0 +1,175 @@
+import {useContext, useEffect, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {promptsCheck, promptsUpdate} from 'app/actionCreators/prompts';
+import {Client} from 'app/api';
+import Alert from 'app/components/alert';
+import Button from 'app/components/button';
+import Link from 'app/components/links/link';
+import AppStoreConnectContext from 'app/components/projects/appStoreConnectContext';
+import {IconClose, IconRefresh} from 'app/icons';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {Organization, Project} from 'app/types';
+import {AppStoreConnectValidationData} from 'app/types/debugFiles';
+import {promptIsDismissed} from 'app/utils/promptIsDismissed';
+import withApi from 'app/utils/withApi';
+
+import {getAppConnectStoreUpdateAlertMessage} from './utils';
+
+const APP_STORE_CONNECT_UPDATES = 'app_store_connect_updates';
+
+type Props = {
+  api: Client;
+  organization: Organization;
+  project?: Project;
+  Wrapper?: React.ComponentType;
+  isCompact?: boolean;
+  className?: string;
+};
+
+function UpdateAlert({api, Wrapper, isCompact, project, organization, className}: Props) {
+  const appStoreConnectContext = useContext(AppStoreConnectContext);
+  const [isDismissed, setIsDismissed] = useState(false);
+
+  useEffect(() => {
+    checkPrompt();
+  }, []);
+
+  async function checkPrompt() {
+    if (!project) {
+      return;
+    }
+
+    const prompt = await promptsCheck(api, {
+      organizationId: organization.id,
+      projectId: project.id,
+      feature: APP_STORE_CONNECT_UPDATES,
+    });
+
+    setIsDismissed(promptIsDismissed(prompt));
+  }
+
+  function handleDismiss() {
+    if (!project) {
+      return;
+    }
+
+    promptsUpdate(api, {
+      organizationId: organization.id,
+      projectId: project.id,
+      feature: APP_STORE_CONNECT_UPDATES,
+      status: 'dismissed',
+    });
+
+    setIsDismissed(true);
+  }
+
+  function renderMessage(
+    appConnectValidationData: AppStoreConnectValidationData,
+    projectSettingsLink: string
+  ) {
+    const appConnectStoreUpdateAlertMessage = getAppConnectStoreUpdateAlertMessage(
+      appConnectValidationData
+    );
+
+    if (!appConnectStoreUpdateAlertMessage) {
+      return null;
+    }
+
+    if (appConnectValidationData.appstoreCredentialsValid === false) {
+      return (
+        <div>
+          {appConnectStoreUpdateAlertMessage}&nbsp;
+          {isCompact ? (
+            <Link to={projectSettingsLink}>
+              {t('Update it in the project settings to reconnect.')}
+            </Link>
+          ) : undefined}
+        </div>
+      );
+    }
+
+    const commonMessage = isCompact ? (
+      <Link to={`${projectSettingsLink}&revalidateItunesSession=true`}>
+        {t('Update it in the project settings to reconnect.')}
+      </Link>
+    ) : undefined;
+
+    return (
+      <div>
+        {appConnectStoreUpdateAlertMessage}&nbsp;{commonMessage}
+      </div>
+    );
+  }
+
+  function renderActions(projectSettingsLink: string) {
+    if (isCompact) {
+      return (
+        <ButtonClose
+          priority="link"
+          title={t('Dismiss')}
+          label={t('Dismiss')}
+          onClick={handleDismiss}
+          icon={<IconClose />}
+        />
+      );
+    }
+
+    return (
+      <Actions>
+        <Button priority="link" onClick={handleDismiss}>
+          {t('Dismiss')}
+        </Button>
+        |
+        <Button priority="link" to={projectSettingsLink}>
+          {t('Review updates')}
+        </Button>
+      </Actions>
+    );
+  }
+
+  if (
+    !project ||
+    appStoreConnectContext.isLoading !== false ||
+    appStoreConnectContext.id === undefined ||
+    isDismissed
+  ) {
+    return null;
+  }
+
+  const projectSettingsLink = `/settings/${organization.slug}/projects/${project.slug}/debug-symbols/?customRepository=${appStoreConnectContext.id}`;
+
+  const notice = (
+    <Alert type="warning" icon={<IconRefresh />} className={className}>
+      <Content>
+        {renderMessage(appStoreConnectContext, projectSettingsLink)}
+        {renderActions(projectSettingsLink)}
+      </Content>
+    </Alert>
+  );
+
+  return Wrapper ? <Wrapper>{notice}</Wrapper> : notice;
+}
+
+export default withApi(UpdateAlert);
+
+const Actions = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(3, max-content);
+  grid-gap: ${space(1)};
+  align-items: center;
+`;
+
+const Content = styled('div')`
+  display: flex;
+  flex-wrap: wrap;
+
+  @media (min-width: ${p => p.theme.breakpoints[0]}) {
+    justify-content: space-between;
+  }
+`;
+
+const ButtonClose = styled(Button)`
+  color: ${p => p.theme.textColor};
+`;

+ 50 - 0
static/app/components/globalAppStoreConnectUpdateAlert/utils.tsx

@@ -0,0 +1,50 @@
+import moment from 'moment';
+
+import {t, tct} from 'app/locale';
+import {AppStoreConnectValidationData} from 'app/types/debugFiles';
+
+export function getAppConnectStoreUpdateAlertMessage(
+  appConnectValidationData: AppStoreConnectValidationData
+) {
+  if (appConnectValidationData.itunesSessionValid === false) {
+    return t('The iTunes session of your configured App Store Connect has expired.');
+  }
+
+  if (appConnectValidationData.appstoreCredentialsValid === false) {
+    return t('The credentials of your configured App Store Connect are invalid.');
+  }
+
+  const itunesSessionRefreshAt = appConnectValidationData.itunesSessionRefreshAt;
+
+  if (!itunesSessionRefreshAt) {
+    return undefined;
+  }
+
+  const foreseenDaysLeftForTheITunesSessionToExpire = moment(itunesSessionRefreshAt).diff(
+    moment(),
+    'days'
+  );
+
+  if (foreseenDaysLeftForTheITunesSessionToExpire === 0) {
+    return t(
+      'We recommend that you update the iTunes session of your configured App Store Connect as it will likely expire today.'
+    );
+  }
+
+  if (foreseenDaysLeftForTheITunesSessionToExpire === 1) {
+    return t(
+      'We recommend that you update the iTunes session of your configured App Store Connect as it will likely expire tomorrow.'
+    );
+  }
+
+  if (foreseenDaysLeftForTheITunesSessionToExpire <= 6) {
+    return tct(
+      'We recommend that you update the iTunes session of your configured App Store Connect as it will likely expire in [days] days.',
+      {
+        days: foreseenDaysLeftForTheITunesSessionToExpire,
+      }
+    );
+  }
+
+  return undefined;
+}

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